Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions docs/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 or 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 an architecture/decisions section heading or a link to an external ADR/RFC repository. 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)

---

Expand Down
84 changes: 79 additions & 5 deletions src/agentready/assessors/documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -601,22 +611,36 @@ 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
except OSError:
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",
score=0.0,
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/specs/, specs/, and variants — all case-insensitive)"
],
remediation=self._create_remediation(),
error_message=None,
Expand Down Expand Up @@ -686,6 +710,56 @@ 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.
"""
adr_keywords = re.compile(
r"(architecture|architectural|decision|adr|rfc|design)",
re.IGNORECASE,
)
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 or (adr_keywords.search(content) 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:
Expand Down
119 changes: 115 additions & 4 deletions tests/unit/test_doc_scoring_precision.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -65,3 +67,112 @@ 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."""

def test_claude_md_with_architecture_section(self, assessor, tmp_path):
(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 == 60.0
assert finding.status == "fail"
assert any("partial credit" in e for e in finding.evidence)

def test_agents_md_with_adr_section(self, assessor, tmp_path):
(tmp_path / "AGENTS.md").write_text(
"# Guide\n\n## ADR Summary\n\nKey decisions are logged here.\n"
)
repo = _make_repo(tmp_path)
finding = assessor.assess(repo)
assert finding.score == 60.0

def test_claude_md_with_external_link(self, assessor, tmp_path):
(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 == 60.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
Loading