Skip to content

Commit d79d26f

Browse files
Dannyb48cursoragent
andcommitted
feat(assessors): add adr_frontmatter_completeness assessor with central ADR repo support (#512, #513)
Adds a new `adr_frontmatter_completeness` assessor (Tier 3, 2% weight) that scores repos on whether ADR files contain structured YAML frontmatter with required `status` and `applies_to` fields. Supports both local ADR directories and a centrally maintained ADR repository via config. ## New assessor: adr_frontmatter_completeness Scoring: - ≥80% of ADR files have valid frontmatter → pass (100) - 50–79% → partial/fail (60) - <50% → fail (0) - No ADR files found → skipped Valid statuses cover MADR (Proposed, Accepted, Implementable, Implemented, Replaced, Deprecated, Superseded, Approved) and adr-tools (active, superseded, draft) conventions. ## Central ADR repository support Configure via `.agentready-config.yaml`: ```yaml adr_source: repo: /path/to/local/clone # locally cloned central ADR repo path: ADR # relative path within that repo ``` When configured, ADRs are read from the central repo and filtered by their `applies_to` frontmatter field matching the assessed repo name. Supports wildcard (`"*"`), short name, full org/repo, and list matching. No network calls — the central repo must be locally cloned. Gracefully skips if the path does not exist or no ADRs match. ## Fix: architecture_decisions respects central ADR repos `ArchitectureDecisionsAssessor` now passes when `adr_source` is configured and the central repo path exists and contains ADRs that match the repo via applies_to, rather than penalising repos that deliberately maintain ADRs centrally. ## Weight rebalancing (Tier 3) Added `adr_frontmatter_completeness: 0.02`. Funded by reducing `architecture_decisions` (2% → 1%) and `progressive_disclosure` (2% → 1%). Also synced `default_weight` values in ArchitectureDecisionsAssessor, OpenAPISpecsAssessor, StructuredLoggingAssessor, and ProgressiveDisclosureAssessor to match default-weights.yaml. Total weights remain 1.0. ## Shared utility module Extracted `parse_frontmatter` into `assessors/_adr_utils.py` to avoid a circular import between `adr_frontmatter` and `adr_sources`. Closes #512, #513 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5ae94eb commit d79d26f

13 files changed

Lines changed: 1436 additions & 15 deletions

src/agentready/assessors/__init__.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
v2.0.0: Updated with evidence-based rebalancing (ETH Zurich, Red Hat, Anthropic).
77
"""
88

9+
from .adr_frontmatter import AdrFrontmatterAssessor
910
from .base import BaseAssessor
1011
from .code_quality import (
1112
CyclomaticComplexityAssessor,
@@ -54,7 +55,12 @@
5455
)
5556
from .verification import SingleFileVerificationAssessor
5657

57-
__all__ = ["create_all_assessors", "BaseAssessor", "LockFilesAssessor"]
58+
__all__ = [
59+
"create_all_assessors",
60+
"BaseAssessor",
61+
"LockFilesAssessor",
62+
"AdrFrontmatterAssessor",
63+
]
5864

5965

6066
def create_all_assessors() -> list[BaseAssessor]:
@@ -91,12 +97,13 @@ def create_all_assessors() -> list[BaseAssessor]:
9197
DesignIntentAssessor(), # 3% (moved from T3)
9298
DbtDataTestsAssessor(), # dbt conditional
9399
DbtProjectStructureAssessor(), # dbt conditional
94-
# Tier 3 Important — 13% total (7 attributes)
95-
ArchitectureDecisionsAssessor(), # 2%
100+
# Tier 3 Important — 13% total (8 attributes)
101+
ArchitectureDecisionsAssessor(), # 1%
102+
AdrFrontmatterAssessor(), # 2% (2.4 - ADR Frontmatter Completeness)
96103
OpenAPISpecsAssessor(), # 2%
97104
CyclomaticComplexityAssessor(), # 2%
98105
StructuredLoggingAssessor(), # 1%
99-
ProgressiveDisclosureAssessor(), # 2% (moved from T4)
106+
ProgressiveDisclosureAssessor(), # 1% (moved from T4)
100107
ArchitecturalBoundaryAssessor(), # 2% (ADR B.1)
101108
ThreatModelAssessor(), # 2% (ADR B.2)
102109
# Tier 4 Advanced — 2% total (2 attributes, 1% each)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Shared helpers for ADR assessor modules.
2+
3+
Extracted to avoid a circular import between adr_frontmatter and adr_sources.
4+
"""
5+
6+
import yaml
7+
8+
9+
def parse_frontmatter(content: str) -> dict | None:
10+
"""Extract YAML frontmatter from a markdown string.
11+
12+
Returns a dict if a valid frontmatter block is found, empty dict
13+
for an empty block, or None if no block exists or YAML is invalid.
14+
"""
15+
if not content.startswith("---"):
16+
return None
17+
end = content.find("---", 3)
18+
if end == -1:
19+
return None
20+
try:
21+
return yaml.safe_load(content[3:end]) or {}
22+
except yaml.YAMLError:
23+
return None
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"""ADR Frontmatter Completeness assessor.
2+
3+
Scores repositories on whether ADR files contain structured YAML
4+
frontmatter with required fields (status + applies_to).
5+
"""
6+
7+
from pathlib import Path
8+
9+
from ..models.attribute import Attribute
10+
from ..models.finding import Finding, Remediation
11+
from ..models.repository import Repository
12+
from ._adr_utils import parse_frontmatter
13+
from .adr_sources import CentralAdrSource, LocalAdrSource
14+
from .base import BaseAssessor
15+
16+
# Title Case entries follow MADR convention; lowercase entries follow adr-tools convention.
17+
# "Accepted", "Implementable", "Superseded", "Approved" cover the Konflux/CNCF MADR variant.
18+
VALID_STATUSES: frozenset[str] = frozenset(
19+
[
20+
# MADR core
21+
"Proposed",
22+
"Accepted",
23+
"Implementable",
24+
"Implemented",
25+
"Replaced",
26+
"Deprecated",
27+
"Superseded",
28+
"Approved",
29+
# adr-tools lowercase
30+
"active",
31+
"superseded",
32+
"draft",
33+
]
34+
)
35+
36+
37+
38+
def classify_adr_file(path: Path) -> str:
39+
"""Classify a single ADR markdown file.
40+
41+
Returns one of:
42+
- "valid" — frontmatter present, status + applies_to valid
43+
- "incomplete" — frontmatter present but field(s) missing/invalid
44+
- "no_frontmatter" — no leading --- block
45+
"""
46+
try:
47+
content = path.read_text(encoding="utf-8", errors="replace")
48+
except OSError:
49+
return "no_frontmatter"
50+
51+
fm = parse_frontmatter(content)
52+
if fm is None:
53+
return "no_frontmatter"
54+
55+
status = fm.get("status")
56+
applies_to = fm.get("applies_to")
57+
58+
# status must be present, non-empty string, and in the allowed set
59+
if not status or not isinstance(status, str) or status not in VALID_STATUSES:
60+
return "incomplete"
61+
62+
# applies_to must be present and non-empty (string or non-empty list)
63+
if applies_to is None:
64+
return "incomplete"
65+
if not isinstance(applies_to, (str, list)):
66+
return "incomplete"
67+
if isinstance(applies_to, str) and not applies_to.strip():
68+
return "incomplete"
69+
if isinstance(applies_to, list) and not applies_to:
70+
return "incomplete"
71+
72+
return "valid"
73+
74+
75+
def score_from_coverage(valid: int, total: int) -> tuple[str, float]:
76+
"""Compute (status_label, score) from valid/total ADR counts.
77+
78+
Args:
79+
valid: Number of valid ADRs.
80+
total: Total number of ADRs (valid + incomplete + no_frontmatter).
81+
82+
Returns:
83+
Tuple of (status_label, score) where status_label is one of
84+
"pass", "partial", or "fail", and score is 0.0, 60.0, or 100.0.
85+
"""
86+
if total == 0:
87+
return ("pass", 100.0)
88+
coverage = valid / total
89+
if coverage >= 0.80:
90+
return ("pass", 100.0)
91+
elif coverage >= 0.50:
92+
return ("partial", 60.0)
93+
else:
94+
return ("fail", 0.0)
95+
96+
97+
class AdrFrontmatterAssessor(BaseAssessor):
98+
"""Assesses ADR frontmatter completeness (status + applies_to fields).
99+
100+
Tier 3 Important (2% weight). Structured frontmatter enables automated
101+
tooling to filter ADRs by applicability, status, and lifecycle stage.
102+
"""
103+
104+
@property
105+
def attribute_id(self) -> str:
106+
"""Unique identifier used to register and look up this assessor."""
107+
return "adr_frontmatter_completeness"
108+
109+
@property
110+
def tier(self) -> int:
111+
"""Tier 3 — Important."""
112+
return 3
113+
114+
@property
115+
def attribute(self) -> Attribute:
116+
"""Attribute metadata including name, category, and default weight."""
117+
return Attribute(
118+
id=self.attribute_id,
119+
name="ADR Frontmatter Completeness",
120+
category="Documentation Standards",
121+
tier=self.tier,
122+
description=(
123+
"ADR files contain structured YAML frontmatter with required "
124+
"status and applies_to fields"
125+
),
126+
criteria="≥80% of ADR files have valid frontmatter",
127+
default_weight=0.02,
128+
)
129+
130+
def assess(self, repository: Repository) -> Finding:
131+
"""Score ADR frontmatter completeness using LocalAdrSource or CentralAdrSource.
132+
133+
If repository.config.adr_source is set, uses CentralAdrSource to
134+
read ADRs from a locally cloned central repo filtered by applies_to.
135+
Otherwise, uses LocalAdrSource to scan the assessed repo's filesystem.
136+
137+
Returns skipped if no ADR files are found or the central repo path
138+
does not exist.
139+
"""
140+
adr_source_config = (
141+
repository.config.adr_source if repository.config is not None else None
142+
)
143+
144+
if adr_source_config is not None:
145+
return self._assess_central(repository, adr_source_config)
146+
147+
return self._assess_local(repository)
148+
149+
def _assess_local(self, repository: Repository) -> Finding:
150+
"""Assess using LocalAdrSource (default path)."""
151+
source = LocalAdrSource()
152+
adr_dir = source.find_adr_dir(repository)
153+
154+
if adr_dir is None:
155+
return Finding.skipped(
156+
self.attribute,
157+
"No ADR files found in standard locations",
158+
)
159+
160+
adr_files = source.get_adr_files(adr_dir)
161+
return self._score_files(adr_files, adr_dir)
162+
163+
def _assess_central(self, repository: Repository, config: dict) -> Finding:
164+
"""Assess using CentralAdrSource (central repo path)."""
165+
local_path_str = config.get("repo", "")
166+
if not local_path_str:
167+
return Finding.skipped(
168+
self.attribute, "Central ADR repo: 'repo' path not configured"
169+
)
170+
local_path = Path(local_path_str)
171+
adr_path = config.get("path") or "ADR"
172+
173+
if not local_path.exists():
174+
return Finding.skipped(
175+
self.attribute,
176+
f"Central ADR repo not found at {local_path}",
177+
)
178+
179+
source = CentralAdrSource(local_path=local_path, adr_path=adr_path)
180+
181+
try:
182+
total_in_central = sum(
183+
1 for p in source.adr_dir.iterdir() if p.suffix == ".md" and p.is_file()
184+
)
185+
except OSError:
186+
total_in_central = 0
187+
188+
matched_files = source.get_matching_adr_files(repository.name)
189+
190+
if not matched_files:
191+
return Finding.skipped(
192+
self.attribute,
193+
f"No ADRs in central repo match applies_to: {repository.name}",
194+
)
195+
196+
extra_evidence = [
197+
f"ADR source: {source.adr_dir}/ (central repo)",
198+
(
199+
f"Filtered by applies_to: {repository.name} "
200+
f"({len(matched_files)} of {total_in_central} total ADRs matched)"
201+
),
202+
]
203+
return self._score_files(
204+
matched_files, source.adr_dir, extra_evidence=extra_evidence
205+
)
206+
207+
def _score_files(
208+
self,
209+
adr_files: list[Path],
210+
adr_dir: Path,
211+
extra_evidence: list[str] | None = None,
212+
) -> Finding:
213+
"""Classify files, compute coverage, and build Finding."""
214+
counts: dict[str, int] = {"valid": 0, "incomplete": 0, "no_frontmatter": 0}
215+
for f in adr_files:
216+
label = classify_adr_file(f)
217+
counts[label] += 1
218+
219+
total = len(adr_files)
220+
valid = counts["valid"]
221+
status_label, score = score_from_coverage(valid, total)
222+
223+
pct = int(valid / total * 100) if total > 0 else 100
224+
measured_value = f"{valid}/{total} ADRs valid ({pct}%)"
225+
evidence = [
226+
f"ADR location: {adr_dir}",
227+
(
228+
f"{valid} valid, {counts['incomplete']} incomplete, "
229+
f"{counts['no_frontmatter']} no-frontmatter ({total} total)"
230+
),
231+
]
232+
if extra_evidence:
233+
evidence.extend(extra_evidence)
234+
235+
remediation = None
236+
if status_label != "pass":
237+
remediation = Remediation(
238+
summary="Add YAML frontmatter with status and applies_to to all ADR files",
239+
steps=[
240+
"Add a frontmatter block at the top of each ADR file",
241+
"Include 'status' (one of: Proposed, Accepted, Implementable, Implemented, Replaced, Deprecated, Superseded, Approved, active, superseded, draft)",
242+
"Include 'applies_to' (repo name, list of repos, or '*' for all)",
243+
"Aim for ≥80% of ADRs to have valid frontmatter",
244+
],
245+
tools=[],
246+
commands=[],
247+
examples=[
248+
"---\nstatus: Implemented\napplies_to: my-service\n---\n# ADR 0001: ..."
249+
],
250+
citations=[],
251+
)
252+
253+
# "partial" is reported as "fail" with score=60 (Finding requires pass/fail)
254+
finding_status = "pass" if status_label == "pass" else "fail"
255+
256+
return Finding(
257+
attribute=self.attribute,
258+
status=finding_status,
259+
score=score,
260+
measured_value=measured_value,
261+
threshold="≥80% valid ADRs",
262+
evidence=evidence,
263+
remediation=remediation,
264+
error_message=None,
265+
)

0 commit comments

Comments
 (0)