|
1 | 1 | """Documentation assessor for CLAUDE.md, README, docstrings, and ADRs.""" |
2 | 2 |
|
| 3 | +import ast |
3 | 4 | import re |
4 | 5 |
|
5 | 6 | from ..models.attribute import Attribute |
6 | 7 | from ..models.finding import Citation, Finding, Remediation |
7 | 8 | from ..models.repository import Repository |
| 9 | +from ..utils.subprocess_utils import safe_subprocess_run |
8 | 10 | from .base import BaseAssessor |
9 | 11 |
|
10 | 12 |
|
@@ -820,3 +822,224 @@ def _create_remediation(self) -> Remediation: |
820 | 822 | ), |
821 | 823 | ], |
822 | 824 | ) |
| 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 | + ) |
0 commit comments