diff --git a/.agentready-config.example.yaml b/.agentready-config.example.yaml
index f4c4ae1f..7cdb7291 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 entirely (suppressions and LOC) from the calculation
+
# 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..ee6e2e48 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).
@@ -1150,7 +1150,7 @@ setup:
### 14. Cyclomatic Complexity Limits
**ID**: `cyclomatic_complexity`
-**Weight**: 2%
+**Weight**: 1%
**Category**: Code Quality
**Status**: ✅ Implemented
@@ -1327,7 +1327,7 @@ Aim for ≥80% of ADR files to have valid frontmatter.
**ID**: `architectural_boundaries`
**Tier**: Tier 3
-**Weight**: 2%
+**Weight**: 1%
**Category**: Repository Structure
**Status**: ✅ Implemented
@@ -1501,6 +1501,73 @@ EOF
---
+### Lint Suppression Density
+
+**ID**: `lint_suppression_density`
+**Tier**: Tier 3
+**Weight**: 2%
+**Category**: Code Quality
+**Status**: ✅ Implemented
+
+#### Definition
+
+Counts lint suppression directives (`//nolint`, `# noqa`, `# ruff: noqa`, `# flake8: noqa`, `# type: ignore`, `# pylint: disable`, `// eslint-disable`, `// @ts-ignore`, `// @ts-nocheck`, `// @ts-expect-error`, `# rubocop:disable`, `@SuppressWarnings`, `# tflint-ignore:`, `# shellcheck disable=`, `# hadolint ignore=`) across source files and normalizes the count per 1,000 lines of source code.
+
+#### Why It Matters
+
+A repo can have a comprehensive lint config and still render it meaningless through blanket suppression usage — lint passes, but not because the code is clean. A high density means violations are being silenced rather than fixed, which gives AI agents a false signal that the codebase is healthy.
+
+#### Measurable Criteria
+
+**Supported languages**: Go, Python, JavaScript, TypeScript, Ruby, Java, Terraform, Shell, Dockerfile.
+
+Markdown and YAML are intentionally excluded from scanning: they are documentation/config, not source code, and are near-universally present with ~zero suppressions — counting their LOC would dilute the density signal for any repo that simply has good docs.
+
+**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 MB. Blank lines are excluded from the line count. Scan health (files scanned/missing/unreadable/truncated) is reported in evidence; if too large a fraction of matched files couldn't be read, the assessor returns **skipped** rather than a computed score. Also returns **skipped** if git inventory is unavailable (missing git, non-git checkout, output-size limit, or timeout) — does not fall back to ignore-unaware directory walks.
+
+**Scoring**:
+
+| Condition | Score |
+|-----------|-------|
+| ≤5 suppressions/1k LOC (default) | 100 (pass) |
+| Between pass and fail thresholds | Linear 100→0 (fail) |
+| ≥15 suppressions/1k LOC (default) | 0 (fail) |
+
+**Pass threshold**: density at or below the configured `pass_per_kloc` (default 5.0 suppressions per 1,000 source lines).
+
+**Evidence reported**: total suppression count, total LOC scanned, density ratio, scan health tally, and the top files by suppression count.
+
+**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 entirely (suppressions and LOC) from the calculation
+```
+
+#### 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.
+
+**Tools**: [ruff](https://docs.astral.sh/ruff/), [golangci-lint](https://golangci-lint.run/), [ESLint](https://eslint.org/)
+
+**Citations**:
+
+- agentready: Issue [#510](https://github.com/ambient-code/agentready/issues/510)
+
+---
+
*Full details for each attribute available in the [research document](https://github.com/ambient-code/agentready/blob/main/RESEARCH_REPORT.md).*
---
@@ -1520,12 +1587,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..cc66658a 100644
--- a/src/agentready/assessors/code_quality.py
+++ b/src/agentready/assessors/code_quality.py
@@ -6,19 +6,24 @@
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.language_detector import LanguageDetector
from ..services.scanner import MissingToolError
from ..utils.subprocess_utils import (
+ SubprocessSecurityError,
safe_subprocess_run,
safe_subprocess_run_stream,
sanitize_subprocess_error,
@@ -590,7 +595,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 +1930,460 @@ 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=")],
+ # Markdown/YAML are intentionally excluded: they're documentation/config,
+ # not source code, and are near-universally present with ~zero
+ # suppressions — counting their LOC in the denominator dilutes the
+ # density signal for repos that simply have good docs (see PR #518 review).
+}
+
+
+def _derive_lang_extensions() -> dict[str, list[str]]:
+ """Build the extension table from LanguageDetector's maps instead of
+ hand-maintaining a second, drift-prone copy (PR #518 review). This also
+ picks up extensions the old hand-written table missed (.pyx/.pyi for
+ Python, .mjs/.cjs for JavaScript, .zsh for Shell)."""
+ result: dict[str, list[str]] = {}
+ for ext, lang in LanguageDetector.EXTENSION_MAP.items():
+ if lang in _SUPPRESSION_PATTERNS:
+ result.setdefault(lang, []).append(ext)
+ for name, lang in LanguageDetector.BASENAME_MAP.items():
+ if lang in _SUPPRESSION_PATTERNS:
+ result.setdefault(lang, []).append(name)
+ return result
+
+
+# Source file extensions per language, derived from LanguageDetector so the
+# two tables can't silently drift apart (see test_suppression_tables_agree_with_detector).
+_LANG_EXTENSIONS: dict[str, list[str]] = _derive_lang_extensions()
+
+_TEST_DIR_FRAGMENTS: set[str] = {"/tests/", "/test/", "/__tests__/", "/spec/"}
+_TEST_ROOT_PREFIXES: set[str] = {"tests/", "test/", "__tests__/", "spec/"}
+
+_SUPPRESSION_EXCLUDED_DIRS = frozenset(
+ [
+ ".git",
+ "vendor",
+ "node_modules",
+ "__pycache__",
+ ".tox",
+ "dist",
+ "build",
+ "target",
+ "venv",
+ ".venv",
+ ".mypy_cache",
+ ".ruff_cache",
+ ]
+)
+
+_TOP_FILES_SHOWN = 5
+_MAX_SUPPRESSION_FILE_BYTES = 1_000_000 # 1 MB hard cap per source file read
+_SUPPRESSION_DEFAULTS = LintSuppressionOptions()
+
+
+class _GitInventoryUnavailable(RuntimeError):
+ """Raised when ignore-aware discovery via git ls-files is unavailable."""
+
+
+class LintSuppressionAssessor(BaseAssessor):
+ """Assesses lint suppression directive density across the codebase.
+
+ Tier 3 Important (2% weight). Heavy use of //nolint, # noqa, eslint-disable,
+ etc. degrades lint as a quality signal for AI agents: lint passes but not
+ because code is clean.
+
+ Scoring (suppressions per 1,000 source lines):
+ - ≤ pass_per_kloc (default 5) → score 100, pass
+ - pass_per_kloc … fail_per_kloc → linear 100→0, fail
+ - ≥ fail_per_kloc (default 15) → score 0, fail
+
+ Thresholds are configurable via .agentready-config.yaml::lint_suppression_density.
+
+ Test file exclusion (exclude_tests=True) applies the shared test
+ directory/path-prefix check (e.g. tests/, __tests__/, spec/) to every
+ supported language first. Go, Python, JavaScript, TypeScript, Ruby, and
+ Java additionally get language-specific filename heuristics (e.g.
+ *_test.go, test_*.py); other languages rely on the directory check alone.
+ """
+
+ @property
+ def attribute_id(self) -> str:
+ return "lint_suppression_density"
+
+ @property
+ def tier(self) -> int:
+ return 3
+
+ @property
+ def attribute(self) -> Attribute:
+ return Attribute(
+ id=self.attribute_id,
+ name="Lint Suppression Density",
+ category="Code Quality",
+ tier=self.tier,
+ description=(
+ "Density of lint suppression directives (//nolint, # noqa, "
+ "eslint-disable, @SuppressWarnings, # tflint-ignore, "
+ "# shellcheck disable, # hadolint ignore, etc.) normalized per "
+ "1,000 lines of source code. Markdown and YAML are excluded "
+ "from scanning since they are documentation/config, not "
+ "source code, and would dilute the density signal."
+ ),
+ criteria=(
+ "Density at or below the configured pass_per_kloc threshold "
+ f"(default {_SUPPRESSION_DEFAULTS.pass_per_kloc} suppressions "
+ "per 1,000 lines of source code); see Finding.threshold for "
+ "the threshold actually applied to this run"
+ ),
+ 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) -> LintSuppressionOptions:
+ if repository.config:
+ return repository.config.lint_suppression_density
+ return _SUPPRESSION_DEFAULTS
+
+ 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 _list_tracked_files(self, root: Path) -> list[str]:
+ """List all git-tracked files once per assessment.
+
+ Uses the streaming variant (line-by-line, unbounded cumulative size
+ governed by its own guardrail) instead of safe_subprocess_run's
+ buffered stdout, which a ~200k-path monorepo can exceed — the
+ likeliest real failure mode, not "missing git" (PR #518 review).
+ Raises _GitInventoryUnavailable with a message reflecting the
+ actual failure instead of a hardcoded guess.
+ """
+ try:
+ files: list[str] = []
+ with safe_subprocess_run_stream(
+ ["git", "ls-files"], cwd=root, timeout=30
+ ) as stream:
+ for line in stream:
+ line = line.rstrip("\n")
+ if line:
+ files.append(line)
+ if stream.returncode != 0:
+ stderr_msg = sanitize_subprocess_error(stream.stderr.strip(), root)
+ raise _GitInventoryUnavailable(
+ f"git ls-files failed (exit {stream.returncode}): "
+ f"{stderr_msg or 'no error output'}"
+ )
+ return files
+ except _GitInventoryUnavailable:
+ raise
+ except subprocess.TimeoutExpired:
+ raise _GitInventoryUnavailable(
+ "Git file inventory unavailable: git ls-files timed out after 30s"
+ ) from None
+ except SubprocessSecurityError as exc:
+ raise _GitInventoryUnavailable(
+ f"Git file inventory unavailable: {exc}"
+ ) from exc
+ except OSError as exc:
+ raise _GitInventoryUnavailable(
+ f"Git file inventory unavailable: {exc}"
+ ) from exc
+
+ def _walk_source_files(
+ self,
+ root: Path,
+ extensions: list[str],
+ tracked_files: list[str],
+ stats: dict[str, int],
+ ) -> 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)
+
+ for rel_path in tracked_files:
+ 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:
+ # Index entry with no file on disk (e.g. sparse checkout) —
+ # a real gap in coverage, not an intentional exclusion.
+ stats["files_missing"] += 1
+ continue
+ # Reject symlinks and non-regular files without following links.
+ # Intentional exclusion, not a scan failure — tracked separately
+ # from files_missing/files_unreadable for the skip-ratio check.
+ if not stat.S_ISREG(mode):
+ stats["files_non_regular"] += 1
+ continue
+ yield abs_path, rel_path
+
+ def _count_file_suppressions(
+ self,
+ file_path: Path,
+ patterns: list[re.Pattern],
+ stats: dict[str, int],
+ ) -> tuple[int, int]:
+ try:
+ with open(file_path, "rb") as handle:
+ # Read one byte past the cap so truncation can be detected
+ # (and disclosed) instead of silently reporting a partial
+ # file as if it were the whole, clean file.
+ raw = handle.read(_MAX_SUPPRESSION_FILE_BYTES + 1)
+ except OSError:
+ # Unreadable (permissions, race with deletion, etc.) is worse
+ # than a crash for a density metric: byte-identical to "(0, 0)"
+ # would silently present as a clean empty file. Surface it.
+ stats["files_unreadable"] += 1
+ return 0, 0
+ if len(raw) > _MAX_SUPPRESSION_FILE_BYTES:
+ stats["files_truncated"] += 1
+ raw = raw[:_MAX_SUPPRESSION_FILE_BYTES]
+ text = raw.decode("utf-8", errors="ignore")
+ # Split on "\n" only, not str.splitlines(), which also breaks on
+ # \v \f \x1c \x1d \x1e \x85 \u2028 \u2029 — form feeds and friends
+ # would otherwise inflate the LOC count. Blank lines are excluded to
+ # match "lines of source code" (mirrors LanguageDetector.count_total_lines).
+ lines = [ln for ln in text.split("\n") if ln.strip()]
+ 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:
+ options = self._get_options(repository)
+ pass_per_kloc = options.pass_per_kloc
+ fail_per_kloc = options.fail_per_kloc
+ exclude_tests = options.exclude_tests
+ detected_langs = set(repository.languages.keys()) & set(
+ _SUPPRESSION_PATTERNS.keys()
+ )
+ total_suppressions = 0
+ total_lines = 0
+ files_scanned = 0
+ file_stats: list[tuple[int, str]] = []
+ stats = {
+ "files_missing": 0,
+ "files_non_regular": 0,
+ "files_unreadable": 0,
+ "files_truncated": 0,
+ }
+
+ try:
+ tracked_files = self._list_tracked_files(repository.path)
+ 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."
+ ),
+ )
+
+ 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, tracked_files, stats
+ ):
+ if exclude_tests and self._is_test_file(rel, lang):
+ continue
+ files_scanned += 1
+ sup_count, line_count = self._count_file_suppressions(
+ src_file, patterns, stats
+ )
+ total_suppressions += sup_count
+ total_lines += line_count
+ if sup_count > 0:
+ file_stats.append((sup_count, rel))
+
+ # files_non_regular (symlinks etc.) is an intentional exclusion, not a
+ # failure — excluded from the skip-ratio so it can't trigger a skip.
+ unscanned = stats["files_missing"] + stats["files_unreadable"]
+ files_considered = files_scanned + unscanned
+ unscanned_ratio = (unscanned / files_considered) if files_considered else 0.0
+
+ if files_considered and unscanned_ratio > 0.10:
+ return Finding.skipped(
+ self.attribute,
+ reason=(
+ f"{unscanned} of {files_considered} matched files could not "
+ f"be read ({unscanned_ratio:.0%}: {stats['files_missing']} "
+ f"missing from disk, {stats['files_unreadable']} unreadable) "
+ "— a density computed over an unknown fraction of the "
+ "codebase is not a reliable measurement"
+ ),
+ remediation=(
+ "Investigate file permissions, sparse-checkout gaps, or "
+ "filesystem errors for the affected paths, then re-run."
+ ),
+ )
+
+ if files_scanned == 0:
+ return Finding.not_applicable(
+ self.attribute,
+ reason=(
+ f"Language(s) {', '.join(sorted(detected_langs))} detected, "
+ "but no matching files remained after directory exclusions "
+ "and test-file filtering"
+ ),
+ )
+
+ if total_lines == 0:
+ return Finding.not_applicable(
+ self.attribute,
+ reason=(
+ f"{files_scanned} matching file(s) scanned but none "
+ "contained non-blank source lines to measure"
+ ),
+ )
+
+ 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"
+
+ scan_health = f"Scan health: {files_scanned} files scanned"
+ if stats["files_missing"]:
+ scan_health += f", {stats['files_missing']} missing from disk"
+ if stats["files_unreadable"]:
+ scan_health += f", {stats['files_unreadable']} unreadable"
+ if stats["files_truncated"]:
+ scan_health += (
+ f", {stats['files_truncated']} truncated at "
+ f"{_MAX_SUPPRESSION_FILE_BYTES:,} bytes"
+ )
+
+ evidence = [
+ f"Total suppressions: {total_suppressions} across {total_lines:,} LOC "
+ f"({density:.1f}/1k lines)",
+ scan_health,
+ 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..e9df936d 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 entirely (suppressions and LOC) from the calculation
+
# 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..1a69eaf1 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.
#
@@ -9,7 +9,7 @@
#
# Tier 1 (Essential): 58% total (9 attributes)
# Tier 2 (Critical): 27% total (10 attributes, mixed weights)
-# Tier 3 (Important): 13% total (varies, 8 attributes)
+# Tier 3 (Important): 13% total (varies, 9 attributes)
# Tier 4 (Advanced): 2% total (1% each, 2 attributes)
# TOTAL: 100% (sum to 1.0)
#
@@ -36,10 +36,15 @@
# - Reduced architecture_decisions (2% -> 1%) to fund new weight
# - Reduced progressive_disclosure (2% -> 1%) to fund new weight
#
-# Changes in v2.4.0 (feat/lint-config-coverage, issue #511):
+# Changes for feat/lint-config-coverage (issue #511):
# - 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 for 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
@@ -64,15 +69,16 @@ pattern_references: 0.02 # 17.1 - Pattern References (reduced 3%→2%
design_intent: 0.03 # 17.2 - Design Intent Documentation (moved from T3)
lint_config_coverage: 0.02 # 5.4 - Lint Config Coverage (issue #511, new)
-# Tier 3 (Important) - 13% total weight (8 attributes)
+# Tier 3 (Important) - 13% total weight (9 attributes)
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..1e59aeb6 100644
--- a/src/agentready/models/config.py
+++ b/src/agentready/models/config.py
@@ -3,11 +3,56 @@
from pathlib import Path
from typing import Annotated
-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,
+ ge=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"
+ ),
+ ]
+
+ # This instance (_SUPPRESSION_DEFAULTS) is a module-level singleton shared
+ # across every assessment; pydantic v2 doesn't re-validate on attribute
+ # assignment, so freeze it to catch accidental mutation immediately.
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ @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,7 +154,13 @@ 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",
+ ),
+ ]
model_config = ConfigDict(
arbitrary_types_allowed=True, # Allow Path objects
extra="forbid", # Reject unknown fields
diff --git a/src/agentready/services/language_detector.py b/src/agentready/services/language_detector.py
index ccb03861..faf2c181 100644
--- a/src/agentready/services/language_detector.py
+++ b/src/agentready/services/language_detector.py
@@ -51,11 +51,18 @@ class LanguageDetector:
".zsh": "Shell",
".sql": "SQL",
".md": "Markdown",
+ ".mdx": "Markdown",
".yaml": "YAML",
".yml": "YAML",
".json": "JSON",
".toml": "TOML",
".xml": "XML",
+ ".tf": "Terraform",
+ }
+
+ # Extensionless filenames matched by exact name (suffix-based lookup can't see these)
+ BASENAME_MAP = {
+ "Dockerfile": "Dockerfile",
}
def __init__(self, repository_path: Path):
@@ -107,8 +114,9 @@ def detect_languages(self) -> dict[str, int]:
suffix = path.suffix.lower()
if suffix in self.EXTENSION_MAP:
- language = self.EXTENSION_MAP[suffix]
- language_counts[language] += 1
+ language_counts[self.EXTENSION_MAP[suffix]] += 1
+ elif path.name in self.BASENAME_MAP:
+ language_counts[self.BASENAME_MAP[path.name]] += 1
# Filter by minimum threshold
return {
diff --git a/tests/unit/test_assessors_code_quality.py b/tests/unit/test_assessors_code_quality.py
index 407950ea..37e914d5 100644
--- a/tests/unit/test_assessors_code_quality.py
+++ b/tests/unit/test_assessors_code_quality.py
@@ -4,12 +4,19 @@
import subprocess
from unittest.mock import patch
+import pytest
+
from agentready.assessors.code_quality import (
+ _LANG_EXTENSIONS,
+ _SUPPRESSION_PATTERNS,
CyclomaticComplexityAssessor,
LintConfigCoverageAssessor,
+ LintSuppressionAssessor,
TypeAnnotationsAssessor,
)
+from agentready.models.config import Config, LintSuppressionOptions
from agentready.models.repository import Repository
+from agentready.services.language_detector import LanguageDetector
def _make_python_repo(tmp_path, **kwargs):
@@ -838,3 +845,769 @@ 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 TestLintSuppressionExactThresholds:
+ """Pin the boundary conditions exactly so a <=/>= -> > mutation on
+ either threshold comparison can't survive silently (PR #518 review)."""
+
+ def test_density_exactly_at_pass_threshold_passes(self, tmp_path):
+ # 5 suppressions / 1000 LOC = 5.0/1k exactly == default pass_per_kloc.
+ lines = ["x = bad() # noqa\n"] * 5 + ["x = 1\n"] * 995
+ (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
+
+ def test_density_exactly_at_fail_threshold_fails_with_zero_score(self, tmp_path):
+ # 15 suppressions / 1000 LOC = 15.0/1k exactly == default fail_per_kloc.
+ lines = ["x = bad() # noqa\n"] * 15 + ["x = 1\n"] * 985
+ (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
+
+
+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"
+
+
+class TestLintSuppressionLanguageTableConsistency:
+ """Guards against the two suppression tables (patterns, extensions)
+ silently drifting apart, and against them naming a language the
+ LanguageDetector can never actually detect (dead code) — see PR #518
+ review, where Terraform/Dockerfile were undetectable for this exact
+ reason."""
+
+ def test_suppression_tables_agree_with_detector(self):
+ assert set(_SUPPRESSION_PATTERNS) == set(_LANG_EXTENSIONS)
+ assert set(_SUPPRESSION_PATTERNS) <= (
+ set(LanguageDetector.EXTENSION_MAP.values())
+ | set(LanguageDetector.BASENAME_MAP.values())
+ )
+
+
+class TestLintSuppressionDocLanguagesExcluded:
+ """Markdown/YAML are documentation/config, not source code — they must
+ never be scanned or dilute the density denominator (PR #518 review)."""
+
+ def test_markdown_not_applicable_alone(self, tmp_path):
+ repo = _make_suppression_repo(tmp_path, languages={"Markdown": 10})
+ assert not LintSuppressionAssessor().is_applicable(repo)
+
+ def test_yaml_not_applicable_alone(self, tmp_path):
+ repo = _make_suppression_repo(tmp_path, languages={"YAML": 10})
+ assert not LintSuppressionAssessor().is_applicable(repo)
+
+ def test_markdown_docs_do_not_dilute_density(self, tmp_path):
+ """A genuinely bad Python density must not flip to pass just because
+ the repo also has a lot of (clean) Markdown documentation."""
+ py_lines = ["x = bad() # noqa\n"] * 20 + ["x = 1\n"] * 80
+ (tmp_path / "code.py").write_text("".join(py_lines))
+ (tmp_path / "README.md").write_text("word\n" * 5000)
+ (tmp_path / "config.yaml").write_text("key: value\n" * 5000)
+ repo = _make_suppression_repo(
+ tmp_path, languages={"Python": 1, "Markdown": 1, "YAML": 1}
+ )
+ finding = LintSuppressionAssessor().assess(repo)
+ assert finding.status == "fail"
+ assert "Python" in " ".join(finding.evidence)
+ assert "Markdown" not in " ".join(finding.evidence)
+ assert "YAML" not in " ".join(finding.evidence)
+
+
+class TestLintSuppressionLocCounting:
+ def test_blank_lines_excluded_from_loc(self, tmp_path):
+ """Blank-line padding must not dilute density (mirrors
+ LanguageDetector.count_total_lines, which also excludes them)."""
+ lines = ["x = bad() # noqa\n"] * 10 + ["x = 1\n"] * 10 + ["\n"] * 4000
+ (tmp_path / "code.py").write_text("".join(lines))
+ repo = _make_suppression_repo(tmp_path, languages={"Python": 1})
+ finding = LintSuppressionAssessor().assess(repo)
+ # 10 suppressions / 20 real LOC = 500/1k, not diluted by 4000 blank lines
+ assert finding.status == "fail"
+
+ def test_form_feed_not_treated_as_line_separator(self, tmp_path):
+ """str.split('\\n') must be used instead of str.splitlines(), which
+ also breaks on \\v \\f \\x1c \\x1d \\x1e \\x85, inflating LOC.
+
+ 5 real "\\n"-delimited lines below; str.splitlines() would have
+ reported 7 due to the embedded \\x0c/\\x0b splitting mid-line.
+ """
+ text = "x = 1\n" * 4 + "x = bad() # noqa\x0cy = 1\x0bz = 2\n"
+ (tmp_path / "code.py").write_bytes(text.encode())
+ repo = _make_suppression_repo(tmp_path, languages={"Python": 1})
+ finding = LintSuppressionAssessor().assess(repo)
+ assert "across 5 LOC" in " ".join(finding.evidence)
+
+
+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_stream",
+ 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_git_ls_files_nonzero_exit_skips_with_specific_reason(self, tmp_path):
+ """A non-timeout, non-security failure (e.g. corrupt index) must
+ surface its actual exit code/stderr, not a hardcoded guess."""
+ (tmp_path / "app.py").write_text("x = 1\n" * 100)
+ repo = _make_suppression_repo(tmp_path, languages={"Python": 1})
+
+ class _FakeStream:
+ returncode = 128
+ stderr = "fatal: not a git repository (or any parent up to mount point)"
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+ def __iter__(self):
+ return iter(())
+
+ with patch(
+ "agentready.assessors.code_quality.safe_subprocess_run_stream",
+ return_value=_FakeStream(),
+ ):
+ finding = LintSuppressionAssessor().assess(repo)
+ assert finding.status == "skipped"
+ evidence_text = " ".join(finding.evidence).lower()
+ assert "128" in evidence_text
+ assert "not a git repository" in evidence_text
+
+ 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, but the
+ # truncation itself must be disclosed, not silently hidden (PR #518 review).
+ (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)
+ evidence_text = " ".join(finding.evidence)
+ assert finding.status == "pass"
+ assert "Total suppressions: 0" in evidence_text
+ assert "1 truncated" in evidence_text
+
+ def test_missing_file_triggers_skip_when_ratio_high(self, tmp_path):
+ """A sparse-checkout-style gap (index entry with no file on disk)
+ must not silently vanish from the count — it must surface and, at
+ a high enough ratio, cause the assessor to skip rather than report
+ a false-clean density (PR #518 review)."""
+ (tmp_path / "good.py").write_text("x = 1\n" * 100)
+ ghost = tmp_path / "ghost.py"
+ ghost.write_text("x = bad() # noqa\n" * 50)
+ repo = _make_suppression_repo(tmp_path, languages={"Python": 1})
+ ghost.unlink() # gone from disk, still present in the git index
+ finding = LintSuppressionAssessor().assess(repo)
+ assert finding.status == "skipped"
+ assert "missing from disk" in " ".join(finding.evidence).lower()
+
+ def test_unreadable_file_triggers_skip_when_ratio_high(self, tmp_path):
+ """An unreadable file (permissions, race with deletion, etc.) must
+ not be byte-identical to a clean empty file."""
+ (tmp_path / "good.py").write_text("x = 1\n" * 100)
+ (tmp_path / "bad.py").write_text("x = bad() # noqa\n" * 50)
+ repo = _make_suppression_repo(tmp_path, languages={"Python": 1})
+
+ real_open = open
+
+ def flaky_open(file, *args, **kwargs):
+ if str(file).endswith("bad.py"):
+ raise OSError("Permission denied")
+ return real_open(file, *args, **kwargs)
+
+ with patch("builtins.open", side_effect=flaky_open):
+ finding = LintSuppressionAssessor().assess(repo)
+ assert finding.status == "skipped"
+ assert "unreadable" in " ".join(finding.evidence).lower()
+
+ def test_unreadable_file_disclosed_without_skip_when_ratio_low(self, tmp_path):
+ """Below the 10% skip threshold, an unreadable file is disclosed in
+ evidence but doesn't block a computed score."""
+ for i in range(19):
+ (tmp_path / f"good{i}.py").write_text("x = 1\n" * 50)
+ (tmp_path / "bad.py").write_text("x = bad() # noqa\n" * 50)
+ repo = _make_suppression_repo(tmp_path, languages={"Python": 1})
+
+ real_open = open
+
+ def flaky_open(file, *args, **kwargs):
+ if str(file).endswith("bad.py"):
+ raise OSError("Permission denied")
+ return real_open(file, *args, **kwargs)
+
+ with patch("builtins.open", side_effect=flaky_open):
+ finding = LintSuppressionAssessor().assess(repo)
+ assert finding.status in ("pass", "fail")
+ assert "1 unreadable" 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/tests/unit/test_assessors_structure.py b/tests/unit/test_assessors_structure.py
index 5c8d2d11..c64fe1f7 100644
--- a/tests/unit/test_assessors_structure.py
+++ b/tests/unit/test_assessors_structure.py
@@ -1510,7 +1510,7 @@ def test_attribute_properties(self):
assessor = ArchitecturalBoundaryAssessor()
assert assessor.attribute_id == "architectural_boundaries"
assert assessor.tier == 3
- assert assessor.attribute.default_weight == 0.02
+ assert assessor.attribute.default_weight == 0.01
def test_java_repo_not_applicable(self, tmp_path):
"""Java-only repo gets not_applicable (unsupported language)."""
diff --git a/tests/unit/test_language_detector.py b/tests/unit/test_language_detector.py
new file mode 100644
index 00000000..7daca52f
--- /dev/null
+++ b/tests/unit/test_language_detector.py
@@ -0,0 +1,60 @@
+"""Tests for LanguageDetector, focused on extension/basename detection coverage."""
+
+import subprocess
+
+from agentready.services.language_detector import LanguageDetector
+
+
+def _git_init(tmp_path):
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
+
+
+class TestLanguageDetectorTerraform:
+ def test_tf_files_detected(self, tmp_path):
+ _git_init(tmp_path)
+ for i in range(3):
+ (tmp_path / f"main{i}.tf").write_text('resource "x" "y" {}\n')
+ subprocess.run(
+ ["git", "add", "-A"], cwd=tmp_path, capture_output=True, check=True
+ )
+ detected = LanguageDetector(tmp_path).detect_languages()
+ assert detected.get("Terraform") == 3
+
+
+class TestLanguageDetectorDockerfile:
+ def test_bare_dockerfile_below_threshold_not_reported(self, tmp_path):
+ _git_init(tmp_path)
+ (tmp_path / "Dockerfile").write_text("FROM ubuntu\n")
+ # Bare filenames have no extension; pad with unrelated tracked files
+ # so git ls-files has something realistic to walk alongside it.
+ (tmp_path / "app.py").write_text("x = 1\n")
+ subprocess.run(
+ ["git", "add", "-A"], cwd=tmp_path, capture_output=True, check=True
+ )
+ # minimum_file_threshold is 3; Dockerfile below threshold still resolves
+ # via BASENAME_MAP, it just won't clear the reporting threshold alone.
+ detected = LanguageDetector(tmp_path).detect_languages()
+ assert "Dockerfile" not in detected # only 1 Dockerfile, below threshold
+
+ def test_bare_dockerfile_detected_above_threshold(self, tmp_path):
+ _git_init(tmp_path)
+ for sub in ("a", "b", "c"):
+ d = tmp_path / sub
+ d.mkdir()
+ (d / "Dockerfile").write_text("FROM ubuntu\n")
+ subprocess.run(
+ ["git", "add", "-A"], cwd=tmp_path, capture_output=True, check=True
+ )
+ detected = LanguageDetector(tmp_path).detect_languages()
+ assert detected.get("Dockerfile") == 3
+
+ def test_dockerfile_variant_suffix_not_matched(self, tmp_path):
+ """Dockerfile.prod etc. are out of scope for the exact-basename rule."""
+ _git_init(tmp_path)
+ for i in range(3):
+ (tmp_path / f"Dockerfile.stage{i}").write_text("FROM ubuntu\n")
+ subprocess.run(
+ ["git", "add", "-A"], cwd=tmp_path, capture_output=True, check=True
+ )
+ detected = LanguageDetector(tmp_path).detect_languages()
+ assert "Dockerfile" not in detected
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" },