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