Skip to content

Commit 5976332

Browse files
fix: address P1 code quality issues from code review (#36)
- Fix TOCTOU vulnerabilities in documentation assessors by using try-except around file reads - Replace regex-based type annotation detection with AST parsing to eliminate false positives - Rename Assessment.attributes_skipped to attributes_not_assessed for semantic clarity - Update all references in reporters, scanner, and tests Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremy Eder <jeremyeder@users.noreply.github.com>
1 parent 0f8c942 commit 5976332

8 files changed

Lines changed: 80 additions & 63 deletions

File tree

src/agentready/assessors/code_quality.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Code quality assessors for complexity, file length, type annotations, and code smells."""
22

3+
import ast
34
import subprocess
45

56
from ..models.attribute import Attribute
@@ -67,8 +68,8 @@ def assess(self, repository: Repository) -> Finding:
6768
)
6869

6970
def _assess_python_types(self, repository: Repository) -> Finding:
70-
"""Assess Python type annotations using file inspection."""
71-
# Simple heuristic: count functions with/without type hints
71+
"""Assess Python type annotations using AST parsing."""
72+
# Use AST parsing to accurately detect type annotations
7273
try:
7374
result = subprocess.run(
7475
["git", "ls-files", "*.py"],
@@ -92,14 +93,29 @@ def _assess_python_types(self, repository: Repository) -> Finding:
9293
full_path = repository.path / file_path
9394
try:
9495
with open(full_path, "r", encoding="utf-8") as f:
95-
for line in f:
96-
line = line.strip()
97-
if line.startswith("def ") and "(" in line:
98-
total_functions += 1
99-
# Check for type hints (-> in signature)
100-
if "->" in line or ":" in line.split("(")[1]:
101-
typed_functions += 1
102-
except (OSError, UnicodeDecodeError):
96+
content = f.read()
97+
98+
# Parse the file with AST
99+
tree = ast.parse(content, filename=str(file_path))
100+
101+
# Walk the AST and count functions with type annotations
102+
for node in ast.walk(tree):
103+
if isinstance(node, ast.FunctionDef):
104+
total_functions += 1
105+
# Check if function has type annotations
106+
# Return type annotation: node.returns is not None
107+
# Parameter annotations: any arg has annotation
108+
has_return_annotation = node.returns is not None
109+
has_param_annotations = any(
110+
arg.annotation is not None for arg in node.args.args
111+
)
112+
113+
# Consider function typed if it has either return or param annotations
114+
if has_return_annotation or has_param_annotations:
115+
typed_functions += 1
116+
117+
except (OSError, UnicodeDecodeError, SyntaxError):
118+
# Skip files that can't be read or parsed
103119
continue
104120

105121
if total_functions == 0:

src/agentready/assessors/documentation.py

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -41,39 +41,37 @@ def assess(self, repository: Repository) -> Finding:
4141
"""
4242
claude_md_path = repository.path / "CLAUDE.md"
4343

44-
if claude_md_path.exists():
45-
# Check file size (should have content)
46-
try:
47-
size = claude_md_path.stat().st_size
48-
if size < 50:
49-
# File exists but is too small
50-
return Finding(
51-
attribute=self.attribute,
52-
status="fail",
53-
score=25.0,
54-
measured_value=f"{size} bytes",
55-
threshold=">50 bytes",
56-
evidence=[f"CLAUDE.md exists but is minimal ({size} bytes)"],
57-
remediation=self._create_remediation(),
58-
error_message=None,
59-
)
44+
# Fix TOCTOU: Use try-except around file read instead of existence check
45+
try:
46+
with open(claude_md_path, "r", encoding="utf-8") as f:
47+
content = f.read()
6048

49+
size = len(content)
50+
if size < 50:
51+
# File exists but is too small
6152
return Finding(
6253
attribute=self.attribute,
63-
status="pass",
64-
score=100.0,
65-
measured_value="present",
66-
threshold="present",
67-
evidence=[f"CLAUDE.md found at {claude_md_path}"],
68-
remediation=None,
54+
status="fail",
55+
score=25.0,
56+
measured_value=f"{size} bytes",
57+
threshold=">50 bytes",
58+
evidence=[f"CLAUDE.md exists but is minimal ({size} bytes)"],
59+
remediation=self._create_remediation(),
6960
error_message=None,
7061
)
7162

72-
except OSError:
73-
return Finding.error(
74-
self.attribute, reason="Could not read CLAUDE.md file"
75-
)
76-
else:
63+
return Finding(
64+
attribute=self.attribute,
65+
status="pass",
66+
score=100.0,
67+
measured_value="present",
68+
threshold="present",
69+
evidence=[f"CLAUDE.md found at {claude_md_path}"],
70+
remediation=None,
71+
error_message=None,
72+
)
73+
74+
except FileNotFoundError:
7775
return Finding(
7876
attribute=self.attribute,
7977
status="fail",
@@ -84,6 +82,10 @@ def assess(self, repository: Repository) -> Finding:
8482
remediation=self._create_remediation(),
8583
error_message=None,
8684
)
85+
except OSError as e:
86+
return Finding.error(
87+
self.attribute, reason=f"Could not read CLAUDE.md file: {e}"
88+
)
8789

8890
def _create_remediation(self) -> Remediation:
8991
"""Create remediation guidance for missing/inadequate CLAUDE.md."""
@@ -171,19 +173,7 @@ def assess(self, repository: Repository) -> Finding:
171173
"""
172174
readme_path = repository.path / "README.md"
173175

174-
if not readme_path.exists():
175-
return Finding(
176-
attribute=self.attribute,
177-
status="fail",
178-
score=0.0,
179-
measured_value="missing",
180-
threshold="present with sections",
181-
evidence=["README.md not found"],
182-
remediation=self._create_remediation(),
183-
error_message=None,
184-
)
185-
186-
# Read README and check for key sections
176+
# Fix TOCTOU: Use try-except around file read instead of existence check
187177
try:
188178
with open(readme_path, "r", encoding="utf-8") as f:
189179
content = f.read().lower()
@@ -231,6 +221,17 @@ def assess(self, repository: Repository) -> Finding:
231221
error_message=None,
232222
)
233223

224+
except FileNotFoundError:
225+
return Finding(
226+
attribute=self.attribute,
227+
status="fail",
228+
score=0.0,
229+
measured_value="missing",
230+
threshold="present with sections",
231+
evidence=["README.md not found"],
232+
remediation=self._create_remediation(),
233+
error_message=None,
234+
)
234235
except OSError as e:
235236
return Finding.error(
236237
self.attribute, reason=f"Could not read README.md: {str(e)}"

src/agentready/models/assessment.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class Assessment:
2020
overall_score: Weighted average score 0-100
2121
certification_level: Platinum/Gold/Silver/Bronze based on score
2222
attributes_assessed: Number of successfully evaluated attributes
23-
attributes_skipped: Number of skipped attributes (tool missing, errors)
23+
attributes_not_assessed: Number of not assessed attributes (skipped, error, not_applicable)
2424
attributes_total: Total attributes (should be 25)
2525
findings: Individual attribute results
2626
config: Custom configuration used (if any)
@@ -34,7 +34,7 @@ class Assessment:
3434
overall_score: float
3535
certification_level: str
3636
attributes_assessed: int
37-
attributes_skipped: int
37+
attributes_not_assessed: int
3838
attributes_total: int
3939
findings: list[Finding]
4040
config: Config | None
@@ -57,10 +57,10 @@ def __post_init__(self):
5757
f"{self.certification_level}"
5858
)
5959

60-
if self.attributes_assessed + self.attributes_skipped != self.attributes_total:
60+
if self.attributes_assessed + self.attributes_not_assessed != self.attributes_total:
6161
raise ValueError(
62-
f"Assessed ({self.attributes_assessed}) + skipped "
63-
f"({self.attributes_skipped}) must equal total "
62+
f"Assessed ({self.attributes_assessed}) + not assessed "
63+
f"({self.attributes_not_assessed}) must equal total "
6464
f"({self.attributes_total})"
6565
)
6666

@@ -79,7 +79,7 @@ def to_dict(self) -> dict:
7979
"overall_score": self.overall_score,
8080
"certification_level": self.certification_level,
8181
"attributes_assessed": self.attributes_assessed,
82-
"attributes_skipped": self.attributes_skipped,
82+
"attributes_not_assessed": self.attributes_not_assessed,
8383
"attributes_total": self.attributes_total,
8484
"findings": [f.to_dict() for f in self.findings],
8585
"config": self.config.to_dict() if self.config else None,

src/agentready/reporters/html.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def generate(self, assessment: Assessment, output_path: Path) -> Path:
5252
"overall_score": assessment.overall_score,
5353
"certification_level": assessment.certification_level,
5454
"attributes_assessed": assessment.attributes_assessed,
55-
"attributes_skipped": assessment.attributes_skipped,
55+
"attributes_not_assessed": assessment.attributes_not_assessed,
5656
"attributes_total": assessment.attributes_total,
5757
"findings": assessment.findings,
5858
"duration_seconds": assessment.duration_seconds,

src/agentready/reporters/markdown.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def _generate_summary(self, assessment: Assessment) -> str:
9898
| **Overall Score** | **{assessment.overall_score:.1f}/100** |
9999
| **Certification Level** | **{assessment.certification_level}** |
100100
| **Attributes Assessed** | {assessment.attributes_assessed}/{assessment.attributes_total} |
101-
| **Attributes Skipped** | {assessment.attributes_skipped} |
101+
| **Attributes Not Assessed** | {assessment.attributes_not_assessed} |
102102
| **Assessment Duration** | {assessment.duration_seconds:.1f}s |
103103
104104
### Languages Detected

src/agentready/services/scanner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def scan(
140140
overall_score=overall_score,
141141
certification_level=certification_level,
142142
attributes_assessed=assessed,
143-
attributes_skipped=skipped,
143+
attributes_not_assessed=skipped,
144144
attributes_total=len(findings),
145145
findings=findings,
146146
config=self.config,

tests/unit/test_models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ def test_assessment_creation(self, tmp_path):
295295
overall_score=75.0,
296296
certification_level="Gold",
297297
attributes_assessed=20,
298-
attributes_skipped=5,
298+
attributes_not_assessed=5,
299299
attributes_total=25,
300300
findings=findings,
301301
config=None,
@@ -413,7 +413,7 @@ def test_assessment_with_metadata(self, tmp_path):
413413
overall_score=75.0,
414414
certification_level="Gold",
415415
attributes_assessed=25,
416-
attributes_skipped=0,
416+
attributes_not_assessed=0,
417417
attributes_total=25,
418418
findings=findings,
419419
config=None,

tests/unit/test_security.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def test_malicious_repository_name_escaped(self, tmp_path):
5757
overall_score=100.0,
5858
certification_level="Platinum",
5959
attributes_assessed=1,
60-
attributes_skipped=0,
60+
attributes_not_assessed=0,
6161
attributes_total=1,
6262
findings=[finding],
6363
config=None,
@@ -140,7 +140,7 @@ def test_malicious_commit_message_escaped(self, tmp_path):
140140
overall_score=0.0,
141141
certification_level="Needs Improvement",
142142
attributes_assessed=1,
143-
attributes_skipped=0,
143+
attributes_not_assessed=0,
144144
attributes_total=1,
145145
findings=[finding],
146146
config=None,

0 commit comments

Comments
 (0)