From 960e005136241527f1699710c4b0cb0d482a9774 Mon Sep 17 00:00:00 2001 From: Bill Murdock Date: Tue, 12 May 2026 09:49:09 -0400 Subject: [PATCH 1/2] fix: case-insensitive file lookups and GNUmakefile support - READMEAssessor: find README.md/rst/txt case-insensitively via iterdir() instead of hardcoded path (closes #387) - OneCommandSetupAssessor: recognize GNUmakefile and makefile in addition to Makefile (closes #380) - ArchitectureDecisionsAssessor: scan docs/ and repo root for ADR directories case-insensitively (closes #379) Co-Authored-By: Claude Sonnet 4.6 --- src/agentready/assessors/documentation.py | 86 +++++++++++++++-------- src/agentready/assessors/structure.py | 2 + 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/agentready/assessors/documentation.py b/src/agentready/assessors/documentation.py index 2d4240ca..03745e38 100644 --- a/src/agentready/assessors/documentation.py +++ b/src/agentready/assessors/documentation.py @@ -400,9 +400,29 @@ def assess(self, repository: Repository) -> Finding: Pass criteria: README.md exists with essential sections Scoring: Proportional based on section count """ - readme_path = repository.path / "README.md" + # Case-insensitive README lookup — handles readme.md, README.md, Readme.rst, etc. + readme_names = {"readme.md", "readme.rst", "readme.txt", "readme"} + readme_path = next( + ( + f + for f in repository.path.iterdir() + if f.is_file() and f.name.lower() in readme_names + ), + None, + ) + + if readme_path is None: + return Finding( + attribute=self.attribute, + status="fail", + score=0.0, + measured_value="missing", + threshold="present with sections", + evidence=["README not found"], + remediation=self._create_remediation(), + error_message=None, + ) - # Fix TOCTOU: Use try-except around file read instead of existence check try: with open(readme_path, "r", encoding="utf-8") as f: content = f.read().lower() @@ -450,20 +470,9 @@ def assess(self, repository: Repository) -> Finding: error_message=None, ) - except FileNotFoundError: - return Finding( - attribute=self.attribute, - status="fail", - score=0.0, - measured_value="missing", - threshold="present with sections", - evidence=["README.md not found"], - remediation=self._create_remediation(), - error_message=None, - ) except OSError as e: return Finding.error( - self.attribute, reason=f"Could not read README.md: {str(e)}" + self.attribute, reason=f"Could not read {readme_path.name}: {str(e)}" ) def _create_remediation(self) -> Remediation: @@ -549,23 +558,38 @@ def assess(self, repository: Repository) -> Finding: - ADR count (40%, up to 5 ADRs) - Template compliance (20%) """ - # Check for ADR directory in common locations - adr_paths = [ - repository.path / "docs" / "adr", - repository.path / ".adr", - repository.path / "adr", - repository.path / "docs" / "decisions", - repository.path / "specs", - repository.path / "docs" / "specs", - repository.path / "docs" / "architecture", - repository.path / "docs" / "design", - ] - + # Case-insensitive ADR directory scan — handles docs/adr, docs/ADRs, docs/Adr, adr/, etc. + adr_target_names = { + "adr", + "adrs", + "decisions", + "architecture-decisions", + "architecture", + "design", + "specs", + } adr_dir = None - for path in adr_paths: - if path.exists() and path.is_dir(): - adr_dir = path - break + + # Search docs/ first (most common location) + docs_dir = repository.path / "docs" + if docs_dir.is_dir(): + for candidate in sorted(docs_dir.iterdir()): + if candidate.is_dir() and candidate.name.lower() in adr_target_names: + adr_dir = candidate + break + + # Fall back to repo root and hidden .adr + if not adr_dir: + if (repository.path / ".adr").is_dir(): + adr_dir = repository.path / ".adr" + else: + for candidate in sorted(repository.path.iterdir()): + if ( + candidate.is_dir() + and candidate.name.lower() in adr_target_names + ): + adr_dir = candidate + break if not adr_dir: return Finding( @@ -575,7 +599,7 @@ def assess(self, repository: Repository) -> Finding: measured_value="no ADR directory", threshold="ADR directory with decisions", evidence=[ - "No ADR directory found (checked docs/adr/, .adr/, adr/, docs/decisions/, specs/, docs/specs/, docs/architecture/, docs/design/)" + "No ADR directory found (checked docs/adr/, docs/architecture/, docs/specs/, specs/, adr/, and variants — all case-insensitive)" ], remediation=self._create_remediation(), error_message=None, diff --git a/src/agentready/assessors/structure.py b/src/agentready/assessors/structure.py index 6409ecf4..bfc59fef 100644 --- a/src/agentready/assessors/structure.py +++ b/src/agentready/assessors/structure.py @@ -531,6 +531,8 @@ def _check_setup_files(self, repository: Repository) -> list: # Check for common setup files files_to_check = { "Makefile": "Makefile", + "GNUmakefile": "GNUmakefile", + "makefile": "Makefile", "setup.sh": "shell script", "bootstrap.sh": "bootstrap script", "package.json": "npm/yarn", From fd2be32314d37652d7e8a45b3641598b1e31283c Mon Sep 17 00:00:00 2001 From: Bill Murdock Date: Tue, 12 May 2026 11:59:21 -0400 Subject: [PATCH 2/2] fix: sort README candidates for determinism and guard iterdir OSErrors - README scan: sort candidates before next() so selection is deterministic when multiple variants exist (e.g. readme.md and README.rst both present) - README scan: wrap iterdir() in try/except OSError, return Finding.error instead of crashing - ADR scan: wrap both docs/ and root iterdir() calls in try/except OSError, fall through gracefully rather than propagating Co-Authored-By: Claude Sonnet 4.6 --- src/agentready/assessors/documentation.py | 55 +++++++++++++++-------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/agentready/assessors/documentation.py b/src/agentready/assessors/documentation.py index 03745e38..7dc153a1 100644 --- a/src/agentready/assessors/documentation.py +++ b/src/agentready/assessors/documentation.py @@ -401,15 +401,23 @@ def assess(self, repository: Repository) -> Finding: Scoring: Proportional based on section count """ # Case-insensitive README lookup — handles readme.md, README.md, Readme.rst, etc. + # sorted() ensures deterministic selection when multiple variants exist. readme_names = {"readme.md", "readme.rst", "readme.txt", "readme"} - readme_path = next( - ( - f - for f in repository.path.iterdir() - if f.is_file() and f.name.lower() in readme_names - ), - None, - ) + try: + readme_path = next( + ( + f + for f in sorted( + repository.path.iterdir(), key=lambda f: f.name.lower() + ) + if f.is_file() and f.name.lower() in readme_names + ), + None, + ) + except OSError as e: + return Finding.error( + self.attribute, reason=f"Could not scan repository root: {e}" + ) if readme_path is None: return Finding( @@ -573,23 +581,32 @@ def assess(self, repository: Repository) -> Finding: # Search docs/ first (most common location) docs_dir = repository.path / "docs" if docs_dir.is_dir(): - for candidate in sorted(docs_dir.iterdir()): - if candidate.is_dir() and candidate.name.lower() in adr_target_names: - adr_dir = candidate - break - - # Fall back to repo root and hidden .adr - if not adr_dir: - if (repository.path / ".adr").is_dir(): - adr_dir = repository.path / ".adr" - else: - for candidate in sorted(repository.path.iterdir()): + try: + for candidate in sorted(docs_dir.iterdir()): if ( candidate.is_dir() and candidate.name.lower() in adr_target_names ): adr_dir = candidate break + except OSError: + pass # docs/ unreadable — fall through to root scan + + # Fall back to repo root and hidden .adr + if not adr_dir: + if (repository.path / ".adr").is_dir(): + adr_dir = repository.path / ".adr" + else: + try: + for candidate in sorted(repository.path.iterdir()): + if ( + candidate.is_dir() + and candidate.name.lower() in adr_target_names + ): + adr_dir = candidate + break + except OSError: + pass # root unreadable — adr_dir stays None, fail finding follows if not adr_dir: return Finding(