diff --git a/docs/attributes.md b/docs/attributes.md index a2822a81..54f09ed3 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -967,7 +967,7 @@ echo "*.log" >> .gitignore #### Definition -Single command to set up development environment from fresh clone (`make setup`, `npm install`, `./bootstrap.sh`). +Single command to set up development environment from fresh clone (`make setup`, `npm install`, `./bootstrap.sh`). Recognized Makefile variants: `Makefile`, `GNUmakefile`, `makefile`. #### Why It Matters @@ -1080,7 +1080,68 @@ radon cc src/ -s -nb **Design Intent Documentation** (`design_intent`, 2%) — Preconditions, invariants, and rationale in design docs **Structured Logging** (`structured_logging`, 2%) — JSON logs with consistent fields **OpenAPI/Swagger Specs** (`openapi_specs`, 3%) — Machine-readable API docs -**Architecture Decision Records** (`architecture_decisions`, 3%) — Document major decisions in `docs/adr/` +### Architecture Decision Records + +**ID**: `architecture_decisions` +**Tier**: Tier 3 +**Weight**: 3% +**Category**: Documentation Standards +**Status**: ✅ Implemented + +#### Definition + +Lightweight documents (ADRs) that record why significant architectural choices were made, not just what was chosen. + +#### Why It Matters + +Agents can read current code but cannot infer the constraints, failed alternatives, or tradeoffs that shaped it. Without ADRs, agents confidently suggest changes that were already tried and rejected. + +#### Measurable Criteria + +Scoring is based on directory presence, ADR count, and template compliance: + +| State | Score | Status | +|-------|-------|--------| +| No ADR directory, no agent context file reference | 0 | fail | +| Architecture section AND external link in CLAUDE.md/AGENTS.md | 60 | fail | +| ADR directory found, empty | 40 | fail | +| ADR directory + 1-4 ADRs | 40-72 | fail/pass | +| ADR directory + 5+ ADRs + template compliance | up to 100 | pass | + +**Recognized directory locations** (case-insensitive): + +- `docs/adr/`, `docs/adrs/`, `docs/ADRs/` +- `docs/architecture/`, `docs/design/`, `docs/specs/` +- `adr/`, `specs/`, `.adr/` + +**Partial credit (60/100)** is awarded when no inline ADR directory exists but `CLAUDE.md` or `AGENTS.md` contains both an architecture/decisions section heading and a link to an external ADR/RFC repository. Both conditions are required — a heading alone is too common a false positive, and a link alone provides insufficient signal. Inline ADRs are more agent-ready because agents cannot follow external links. + +**Template compliance** checks for the four Michael Nygard sections: Status, Context, Decision, Consequences. + +#### Remediation + +```bash +mkdir -p docs/adr + +cat > docs/adr/0001-use-python.md << 'EOF' +# 1. Use Python as primary language + +## Status +Accepted + +## Context +Team has strong Python expertise; data science integrations are Python-first. + +## Decision +Python 3.12+ is the primary implementation language. + +## Consequences +Strong ML/data library access. Type annotations required to compensate for +dynamic typing risks. +EOF +``` + +**Tools**: [adr-tools](https://github.com/npryce/adr-tools), [log4brains](https://github.com/thomvaill/log4brains) --- diff --git a/src/agentready/assessors/documentation.py b/src/agentready/assessors/documentation.py index 7dc153a1..25612d85 100644 --- a/src/agentready/assessors/documentation.py +++ b/src/agentready/assessors/documentation.py @@ -565,9 +565,12 @@ def assess(self, repository: Repository) -> Finding: - ADR directory exists (40%) - ADR count (40%, up to 5 ADRs) - Template compliance (20%) + - Partial credit (60%) when no directory exists but CLAUDE.md/AGENTS.md + references an architecture decisions section or external ADR repo """ - # Case-insensitive ADR directory scan — handles docs/adr, docs/ADRs, docs/Adr, adr/, etc. - adr_target_names = { + # Case-insensitive ADR directory scan. Covers common conventions: + # docs/adr, docs/ADRs, docs/architecture, docs/design, docs/specs, specs/, adr/, etc. + docs_target_names = { "adr", "adrs", "decisions", @@ -576,6 +579,13 @@ def assess(self, repository: Repository) -> Finding: "design", "specs", } + root_target_names = { + "adr", + "adrs", + "decisions", + "architecture-decisions", + "specs", + } adr_dir = None # Search docs/ first (most common location) @@ -585,7 +595,7 @@ def assess(self, repository: Repository) -> Finding: for candidate in sorted(docs_dir.iterdir()): if ( candidate.is_dir() - and candidate.name.lower() in adr_target_names + and candidate.name.lower() in docs_target_names ): adr_dir = candidate break @@ -601,7 +611,7 @@ def assess(self, repository: Repository) -> Finding: for candidate in sorted(repository.path.iterdir()): if ( candidate.is_dir() - and candidate.name.lower() in adr_target_names + and candidate.name.lower() in root_target_names ): adr_dir = candidate break @@ -609,6 +619,20 @@ def assess(self, repository: Repository) -> Finding: pass # root unreadable — adr_dir stays None, fail finding follows if not adr_dir: + partial_score, partial_evidence = self._check_agent_file_adr_summary( + repository + ) + if partial_score > 0: + return Finding( + attribute=self.attribute, + status="fail", + score=partial_score, + measured_value="ADR summary in agent context file", + threshold="ADR directory with decisions", + evidence=partial_evidence, + remediation=self._create_remediation(), + error_message=None, + ) return Finding( attribute=self.attribute, status="fail", @@ -616,7 +640,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/, docs/architecture/, docs/specs/, specs/, adr/, and variants — all case-insensitive)" + "No ADR directory found (checked docs/adr/, docs/architecture/, docs/design/, docs/specs/, specs/, and variants — all case-insensitive)" ], remediation=self._create_remediation(), error_message=None, @@ -686,6 +710,52 @@ def assess(self, repository: Repository) -> Finding: error_message=None, ) + def _check_agent_file_adr_summary( + self, repository: Repository + ) -> tuple[float, list[str]]: + """Check CLAUDE.md/AGENTS.md for an architecture decisions section or + external ADR repo link. Returns (score, evidence) — score is 0 if nothing found. + + Partial credit (60/100) recognises projects that summarise key architectural + decisions in their agent context file and link out to an external ADR repo. + This is less agent-ready than an inline directory (agents can't follow links), + but it's meaningfully better than no documentation at all. + """ + section_header = re.compile( + r"^#{1,3}\s.*(architecture|decision|adr|rfc|design)", + re.IGNORECASE | re.MULTILINE, + ) + external_link = re.compile( + r"https?://\S*(adr|rfc|decision|architecture)\S*", + re.IGNORECASE, + ) + + for filename in ("CLAUDE.md", "AGENTS.md"): + filepath = repository.path / filename + if not filepath.is_file(): + continue + try: + content = filepath.read_text() + except OSError: + continue + + has_section = bool(section_header.search(content)) + has_link = bool(external_link.search(content)) + + if has_section and has_link: + evidence = [ + "No inline ADR directory found", + f"{filename} contains architectural decision documentation (partial credit: 60/100)", + "Add a docs/adr/ directory with inline ADRs for full credit", + ] + if has_link: + evidence.insert( + 2, f"{filename} links to external ADR/RFC repository" + ) + return 60.0, evidence + + return 0.0, [] + def _has_consistent_naming(self, adr_files: list) -> bool: """Check if ADR files follow consistent naming pattern.""" if len(adr_files) < 2: diff --git a/tests/unit/test_doc_scoring_precision.py b/tests/unit/test_doc_scoring_precision.py index 0276ea99..f4c0cb33 100644 --- a/tests/unit/test_doc_scoring_precision.py +++ b/tests/unit/test_doc_scoring_precision.py @@ -1,13 +1,15 @@ -"""Regression test for integer division bug in documentation scoring. +"""Tests for ArchitectureDecisionsAssessor. -Tests that _check_template_compliance() uses float division so that -repositories with any number of ADR files can achieve the maximum -score of 20/20 when all required sections are present. +Covers: +- Integer division regression in _check_template_compliance() +- Case-insensitive and extended directory path detection (#379, #414) +- Partial credit via CLAUDE.md/AGENTS.md architecture sections (#392) """ import pytest from agentready.assessors.documentation import ArchitectureDecisionsAssessor +from agentready.models.repository import Repository @pytest.fixture @@ -65,3 +67,137 @@ def test_seven_files_no_longer_capped_at_14(self, assessor, tmp_path): files = _make_adr_files(tmp_path, 7) score = assessor._check_template_compliance(files) assert score == 20, f"7-file regression: expected 20, got {score}" + + +def _make_repo(tmp_path) -> Repository: + (tmp_path / ".git").mkdir(exist_ok=True) + return Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 100}, + total_files=10, + total_lines=100, + ) + + +def _make_adr_dir(parent, count=3): + parent.mkdir(parents=True, exist_ok=True) + for i in range(count): + f = parent / f"adr-{i:03d}.md" + f.write_text( + "# ADR\n\n## Status\nAccepted\n\n## Context\n" + "Info\n\n## Decision\nWe decided X\n\n## Consequences\nResult\n" + ) + + +class TestExtendedDirectoryPaths: + """Issue #414: additional directory paths for ADR detection.""" + + @pytest.mark.parametrize( + "rel_path", + [ + "docs/adr", + "docs/ADRs", + "docs/Adr", + "docs/architecture", + "docs/Architecture", + "docs/design", + "docs/Design", + "docs/specs", + "docs/Specs", + "adr", + "specs", + ], + ) + def test_recognized_directory(self, assessor, tmp_path, rel_path): + _make_adr_dir(tmp_path / rel_path) + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score > 0, f"{rel_path} should be recognized; got score 0" + assert finding.status == "pass", f"{rel_path} with 3 ADRs should pass" + + def test_hidden_adr_still_recognized(self, assessor, tmp_path): + _make_adr_dir(tmp_path / ".adr") + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score > 0 + + def test_unrelated_docs_subdir_not_matched(self, assessor, tmp_path): + (tmp_path / "docs" / "guides").mkdir(parents=True) + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score == 0 or finding.score == 60 + + +class TestClaudeMdPartialCredit: + """Issue #392: partial credit when CLAUDE.md/AGENTS.md references ADRs. + + Partial credit requires BOTH a matching section heading AND an external link. + Either condition alone is insufficient (too many false positives). + """ + + def test_section_and_link_scores_60(self, assessor, tmp_path): + (tmp_path / "CLAUDE.md").write_text( + "# Project\n\n## Architecture Decisions\n\nSee " + "https://github.com/org/architecture-decisions for the full record.\n" + ) + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score == 60.0 + assert finding.status == "fail" + assert any("partial credit" in e for e in finding.evidence) + + def test_agents_md_section_and_link_scores_60(self, assessor, tmp_path): + (tmp_path / "AGENTS.md").write_text( + "# Guide\n\n## ADR Summary\n\nDecisions tracked at " + "https://github.com/org/adrs.\n" + ) + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score == 60.0 + + def test_section_only_scores_zero(self, assessor, tmp_path): + """Section heading without a link is not sufficient — too many false positives.""" + (tmp_path / "CLAUDE.md").write_text( + "# Project\n\n## Architecture Decisions\n\nWe use hexagonal architecture.\n" + ) + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score == 0.0 + + def test_link_only_scores_zero(self, assessor, tmp_path): + """External link without a matching section heading is not sufficient.""" + (tmp_path / "CLAUDE.md").write_text( + "# Project\n\nArchitectural decisions are tracked at " + "https://github.com/org/architecture-decisions.\n" + ) + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score == 0.0 + + def test_keyword_only_scores_zero(self, assessor, tmp_path): + """Architecture keyword in body text with no heading and no link scores 0.""" + (tmp_path / "CLAUDE.md").write_text( + "# Project\n\nWe follow an architecture-first approach.\n" + ) + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score == 0.0 + + def test_claude_md_without_adr_content_scores_zero(self, assessor, tmp_path): + (tmp_path / "CLAUDE.md").write_text("# Project\n\nRun tests with pytest.\n") + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score == 0.0 + + def test_inline_adr_dir_takes_priority_over_claude_md(self, assessor, tmp_path): + (tmp_path / "CLAUDE.md").write_text( + "## Architecture Decisions\n\nSee docs/adr.\n" + ) + _make_adr_dir(tmp_path / "docs" / "adr") + repo = _make_repo(tmp_path) + finding = assessor.assess(repo) + assert finding.score > 60.0 # full scoring path, not partial credit