Skip to content

Commit e56e570

Browse files
authored
feat: Implement InlineDocumentationAssessor (fixes #77) (#94)
- Analyzes Python docstring coverage using AST parsing - Checks public functions, classes, and modules for docstrings - Uses ast.get_docstring() for accurate detection - Scoring: proportional based on ≥80% coverage threshold - Test result: AgentReady scored 100/100 (92% coverage, 751/816 items documented) - Removed duplicate stub assessor for inline_documentation
1 parent 578fc31 commit e56e570

File tree

3 files changed

+226
-8
lines changed

3 files changed

+226
-8
lines changed

src/agentready/assessors/documentation.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Documentation assessor for CLAUDE.md, README, docstrings, and ADRs."""
22

3+
import ast
34
import re
45

56
from ..models.attribute import Attribute
67
from ..models.finding import Citation, Finding, Remediation
78
from ..models.repository import Repository
9+
from ..utils.subprocess_utils import safe_subprocess_run
810
from .base import BaseAssessor
911

1012

@@ -820,3 +822,224 @@ def _create_remediation(self) -> Remediation:
820822
),
821823
],
822824
)
825+
826+
827+
class InlineDocumentationAssessor(BaseAssessor):
828+
"""Assesses inline documentation (docstrings) coverage.
829+
830+
Tier 2 Critical (3% weight) - Docstrings provide function-level
831+
context that helps LLMs understand code without reading implementation.
832+
"""
833+
834+
@property
835+
def attribute_id(self) -> str:
836+
return "inline_documentation"
837+
838+
@property
839+
def tier(self) -> int:
840+
return 2 # Critical
841+
842+
@property
843+
def attribute(self) -> Attribute:
844+
return Attribute(
845+
id=self.attribute_id,
846+
name="Inline Documentation",
847+
category="Documentation",
848+
tier=self.tier,
849+
description="Function, class, and module-level documentation using language-specific conventions",
850+
criteria="≥80% of public functions/classes have docstrings",
851+
default_weight=0.03,
852+
)
853+
854+
def is_applicable(self, repository: Repository) -> bool:
855+
"""Only applicable to languages with docstring conventions."""
856+
applicable_languages = {"Python", "JavaScript", "TypeScript"}
857+
return bool(set(repository.languages.keys()) & applicable_languages)
858+
859+
def assess(self, repository: Repository) -> Finding:
860+
"""Check docstring coverage for public functions and classes.
861+
862+
Currently supports Python only. JavaScript/TypeScript can be added later.
863+
"""
864+
if "Python" in repository.languages:
865+
return self._assess_python_docstrings(repository)
866+
else:
867+
return Finding.not_applicable(
868+
self.attribute,
869+
reason=f"Docstring check not implemented for {list(repository.languages.keys())}",
870+
)
871+
872+
def _assess_python_docstrings(self, repository: Repository) -> Finding:
873+
"""Assess Python docstring coverage using AST parsing."""
874+
# Get list of Python files
875+
try:
876+
result = safe_subprocess_run(
877+
["git", "ls-files", "*.py"],
878+
cwd=repository.path,
879+
capture_output=True,
880+
text=True,
881+
timeout=30,
882+
check=True,
883+
)
884+
python_files = [f for f in result.stdout.strip().split("\n") if f]
885+
except Exception:
886+
python_files = [
887+
str(f.relative_to(repository.path))
888+
for f in repository.path.rglob("*.py")
889+
]
890+
891+
total_public_items = 0
892+
documented_items = 0
893+
894+
for file_path in python_files:
895+
full_path = repository.path / file_path
896+
try:
897+
with open(full_path, "r", encoding="utf-8") as f:
898+
content = f.read()
899+
900+
# Parse the file with AST
901+
tree = ast.parse(content, filename=str(file_path))
902+
903+
# Check module-level docstring
904+
module_doc = ast.get_docstring(tree)
905+
if module_doc:
906+
documented_items += 1
907+
total_public_items += 1
908+
909+
# Walk the AST and count functions/classes with docstrings
910+
for node in ast.walk(tree):
911+
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
912+
# Skip private functions/classes (starting with _)
913+
if node.name.startswith("_"):
914+
continue
915+
916+
total_public_items += 1
917+
docstring = ast.get_docstring(node)
918+
if docstring:
919+
documented_items += 1
920+
921+
except (OSError, UnicodeDecodeError, SyntaxError):
922+
# Skip files that can't be read or parsed
923+
continue
924+
925+
if total_public_items == 0:
926+
return Finding.not_applicable(
927+
self.attribute,
928+
reason="No public Python functions or classes found",
929+
)
930+
931+
coverage_percent = (documented_items / total_public_items) * 100
932+
score = self.calculate_proportional_score(
933+
measured_value=coverage_percent,
934+
threshold=80.0,
935+
higher_is_better=True,
936+
)
937+
938+
status = "pass" if score >= 75 else "fail"
939+
940+
# Build evidence
941+
evidence = [
942+
f"Documented items: {documented_items}/{total_public_items}",
943+
f"Coverage: {coverage_percent:.1f}%",
944+
]
945+
946+
if coverage_percent >= 80:
947+
evidence.append("Good docstring coverage")
948+
elif coverage_percent >= 60:
949+
evidence.append("Moderate docstring coverage")
950+
else:
951+
evidence.append("Many public functions/classes lack docstrings")
952+
953+
return Finding(
954+
attribute=self.attribute,
955+
status=status,
956+
score=score,
957+
measured_value=f"{coverage_percent:.1f}%",
958+
threshold="≥80%",
959+
evidence=evidence,
960+
remediation=self._create_remediation() if status == "fail" else None,
961+
error_message=None,
962+
)
963+
964+
def _create_remediation(self) -> Remediation:
965+
"""Create remediation guidance for missing docstrings."""
966+
return Remediation(
967+
summary="Add docstrings to public functions and classes",
968+
steps=[
969+
"Identify functions/classes without docstrings",
970+
"Add PEP 257 compliant docstrings for Python",
971+
"Add JSDoc comments for JavaScript/TypeScript",
972+
"Include: description, parameters, return values, exceptions",
973+
"Add examples for complex functions",
974+
"Run pydocstyle to validate docstring format",
975+
],
976+
tools=["pydocstyle", "jsdoc"],
977+
commands=[
978+
"# Install pydocstyle",
979+
"pip install pydocstyle",
980+
"",
981+
"# Check docstring coverage",
982+
"pydocstyle src/",
983+
"",
984+
"# Generate documentation",
985+
"pip install sphinx",
986+
"sphinx-apidoc -o docs/ src/",
987+
],
988+
examples=[
989+
'''# Python - Good docstring
990+
def calculate_discount(price: float, discount_percent: float) -> float:
991+
"""Calculate discounted price.
992+
993+
Args:
994+
price: Original price in USD
995+
discount_percent: Discount percentage (0-100)
996+
997+
Returns:
998+
Discounted price
999+
1000+
Raises:
1001+
ValueError: If discount_percent not in 0-100 range
1002+
1003+
Example:
1004+
>>> calculate_discount(100.0, 20.0)
1005+
80.0
1006+
"""
1007+
if not 0 <= discount_percent <= 100:
1008+
raise ValueError("Discount must be 0-100")
1009+
return price * (1 - discount_percent / 100)
1010+
''',
1011+
"""// JavaScript - Good JSDoc
1012+
/**
1013+
* Calculate discounted price
1014+
*
1015+
* @param {number} price - Original price in USD
1016+
* @param {number} discountPercent - Discount percentage (0-100)
1017+
* @returns {number} Discounted price
1018+
* @throws {Error} If discountPercent not in 0-100 range
1019+
* @example
1020+
* calculateDiscount(100.0, 20.0)
1021+
* // Returns: 80.0
1022+
*/
1023+
function calculateDiscount(price, discountPercent) {
1024+
if (discountPercent < 0 || discountPercent > 100) {
1025+
throw new Error("Discount must be 0-100");
1026+
}
1027+
return price * (1 - discountPercent / 100);
1028+
}
1029+
""",
1030+
],
1031+
citations=[
1032+
Citation(
1033+
source="Python.org",
1034+
title="PEP 257 - Docstring Conventions",
1035+
url="https://peps.python.org/pep-0257/",
1036+
relevance="Python docstring standards",
1037+
),
1038+
Citation(
1039+
source="TypeScript",
1040+
title="TSDoc Reference",
1041+
url="https://tsdoc.org/",
1042+
relevance="TypeScript documentation standard",
1043+
),
1044+
],
1045+
)

src/agentready/assessors/stub_assessors.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,6 @@ def create_stub_assessors():
276276
2,
277277
0.03,
278278
),
279-
StubAssessor(
280-
"inline_documentation",
281-
"Inline Documentation",
282-
"Documentation Standards",
283-
2,
284-
0.03,
285-
),
286279
StubAssessor(
287280
"file_size_limits",
288281
"File Size Limits",

src/agentready/cli/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
ArchitectureDecisionsAssessor,
2323
CLAUDEmdAssessor,
2424
ConciseDocumentationAssessor,
25+
InlineDocumentationAssessor,
2526
READMEAssessor,
2627
)
2728
from ..assessors.structure import (
@@ -77,14 +78,15 @@ def create_all_assessors():
7778
TypeAnnotationsAssessor(),
7879
StandardLayoutAssessor(),
7980
LockFilesAssessor(),
80-
# Tier 2 Critical (10 assessors - 5 implemented, 5 stubs)
81+
# Tier 2 Critical (10 assessors - 6 implemented, 4 stubs)
8182
TestCoverageAssessor(),
8283
PreCommitHooksAssessor(),
8384
ConventionalCommitsAssessor(),
8485
GitignoreAssessor(),
8586
OneCommandSetupAssessor(),
8687
SeparationOfConcernsAssessor(),
8788
ConciseDocumentationAssessor(),
89+
InlineDocumentationAssessor(),
8890
CyclomaticComplexityAssessor(), # Actually Tier 3, but including here
8991
# Tier 3 Important (4 implemented)
9092
ArchitectureDecisionsAssessor(),

0 commit comments

Comments
 (0)