diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d996584 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# CHANGES + +## [Unreleased] + +### Added +- Inline PR review comments: GitHub Action now posts review comments directly on + specific code lines (file:line format) via Pull Request Review API, in addition + to the existing summary comment. Controlled by `inline_comments` input (default: true). + +### Changed +- Tightened LLM prompt to reduce noise: requires file:line references, prohibits + generic advice, reports only issues visible in the actual diff. +- `--offline` CLI flag: run static analysis only, without any LLM calls. No API key + or network connection required. Automatically forces `check_docstrings: True`. + Mutually exclusive with `--test-connection`. Useful for quick local checks, + CI environments without API access, or when no internet is available. +- Docstrings suggestions feature: detects functions, methods, and classes missing + documentation comments in added code. Supports Python docstrings, JSDoc (JS/TS/JSX/TSX), + Javadoc, Doxygen (C/C++), godoc (Go), and Rust doc comments. Works in both static analysis fallback + and LLM-powered review modes. Controlled by `review.check_docstrings` config option. + +### TODO +- VSCode extension integration: provide real-time code review feedback directly + in the editor via a dedicated VSCode extension. +- PyCharm/IntelliJ IDEA plugin integration: provide code review inspections + and quick-fixes via a dedicated JetBrains IDE plugin. + diff --git a/README.md b/README.md index 354b623..75f8ce0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Automated code review powered by LLM. Works with any OpenAI-compatible API (Open - **Multiple modes** - CLI, Git hooks, GitHub Actions - **Flexible** - Works with any OpenAI-compatible endpoint - **Graceful fallback** - Static analysis when LLM unavailable +- **Offline mode** - Run static analysis only, without LLM calls (`--offline`) ## Quick Start @@ -99,6 +100,7 @@ llm-code-review --base main --head dev # Compare branches llm-code-review --format json # JSON output for CI/CD llm-code-review --strict # Block on warnings too llm-code-review --verbose # Detailed output +llm-code-review --offline # Static analysis only (no API key needed) llm-code-review --test-connection # Test API connectivity ``` @@ -294,6 +296,7 @@ Add optional inputs to customize behavior: strict: 'true' # Fail on warnings too (default: false) post_comment: 'true' # Post review as PR comment (default: true) fail_on_critical: 'true' # Fail action on critical issues (default: true) + inline_comments: 'true' # Post inline comments on code lines (default: true) - name: Check results if: always() @@ -314,6 +317,7 @@ Add optional inputs to customize behavior: | `strict` | Fail on warnings | No | `false` | | `post_comment` | Post PR comment | No | `true` | | `fail_on_critical` | Fail on critical issues | No | `true` | +| `inline_comments` | Post inline review comments on code lines | No | `true` | ### Action Outputs @@ -336,6 +340,9 @@ llm-code-review --test-connection # Verbose output llm-code-review --mode staged --verbose +# Offline static analysis (no API key needed) +llm-code-review --mode staged --offline + # Health check python monitor.py health ``` diff --git a/action.yml b/action.yml index 6dcff65..95eafd7 100644 --- a/action.yml +++ b/action.yml @@ -37,6 +37,10 @@ inputs: description: 'Fail the action if critical issues found (default: true)' required: false default: 'true' + inline_comments: + description: 'Post inline review comments on specific code lines (default: true)' + required: false + default: 'true' outputs: status: @@ -262,6 +266,104 @@ runs: console.error('Error managing PR comment:', error); } + - name: Post Inline Review Comments + if: inputs.inline_comments == 'true' && inputs.post_comment == 'true' && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let reviewData = {}; + try { + const resultFile = '${{ steps.review.outputs.result_file }}'; + const reviewResult = fs.readFileSync(resultFile, 'utf8'); + reviewData = JSON.parse(reviewResult); + } catch (error) { + console.log('Could not read review results for inline comments:', error.message); + return; + } + + const linePattern = /^(.+?):(\d+):\s*(.+)$/; + const comments = []; + + const categories = [ + { key: 'critical_issues', icon: '🚨' }, + { key: 'warnings', icon: '⚠️' }, + { key: 'suggestions', icon: '💡' }, + ]; + + for (const { key, icon } of categories) { + const issues = reviewData[key] || []; + for (const issue of issues) { + const match = issue.match(linePattern); + if (match) { + comments.push({ + path: match[1], + line: parseInt(match[2], 10), + body: `${icon} ${match[3]}`, + }); + } + } + } + + if (comments.length === 0) { + console.log('No inline comments to post (no issues with file:line format)'); + return; + } + + const commitId = '${{ github.event.pull_request.head.sha }}'; + const prNumber = context.issue.number; + const owner = context.repo.owner; + const repo = context.repo.repo; + + // Post individual inline comments + let posted = 0; + let skipped = 0; + + for (const comment of comments) { + try { + await github.rest.pulls.createReviewComment({ + owner, + repo, + pull_number: prNumber, + commit_id: commitId, + path: comment.path, + line: comment.line, + body: comment.body, + }); + posted++; + } catch (err) { + skipped++; + console.log(`Skipped comment on ${comment.path}:${comment.line} - ${err.message}`); + } + } + + console.log(`Inline comments: ${posted} posted, ${skipped} skipped`); + + // Create a review to mark the PR as reviewed + try { + const criticalCount = ${{ steps.review.outputs.critical_count }}; + const warningCount = ${{ steps.review.outputs.warning_count }}; + const event = criticalCount > 0 ? 'REQUEST_CHANGES' : 'COMMENT'; + const body = criticalCount > 0 + ? `LLM Code Review found ${criticalCount} critical issue(s) and ${warningCount} warning(s).` + : warningCount > 0 + ? `LLM Code Review found ${warningCount} warning(s).` + : 'LLM Code Review passed.'; + + await github.rest.pulls.createReview({ + owner, + repo, + pull_number: prNumber, + commit_id: commitId, + event: event, + body: body, + }); + console.log(`Created review with event: ${event}`); + } catch (reviewError) { + console.log('Failed to create review:', reviewError.message); + } + - name: Check for failures if: inputs.fail_on_critical == 'true' shell: bash diff --git a/config.py b/config.py index 4e6546c..3a65844 100644 --- a/config.py +++ b/config.py @@ -47,6 +47,8 @@ class ReviewConfig: "missing_error_handling", "documentation_gaps", ], + "check_docstrings": True, + "docstring_min_lines": 0, "file_extensions": [ ".py", ".js", diff --git a/requirements.txt b/requirements.txt index c0fe6c0..5f7d9c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ anyio==4.12.1 python-dotenv==1.0.0 rich==13.7.1 argparse -json5==0.9.14 \ No newline at end of file +json5==0.9.14 +pytest>=7.0 \ No newline at end of file diff --git a/review.py b/review.py index b6bfb05..55520fc 100755 --- a/review.py +++ b/review.py @@ -49,6 +49,14 @@ def run(self, args=None): self.reviewer.trace = self.trace self.reviewer.trace_llm = self.trace_llm + # Handle mutually exclusive --offline and --test-connection + if parsed_args.offline and parsed_args.test_connection: + print( + "Error: --offline and --test-connection are mutually exclusive.", + file=sys.stderr, + ) + return 4 + # Handle test-connection before validation if parsed_args.test_connection: if not self._validate_config(): @@ -61,8 +69,14 @@ def run(self, args=None): print("❌ Connection to LLM failed") return 1 - # Validate configuration for other commands - if not self._validate_config(): + # Validate configuration + if parsed_args.offline: + try: + self.parser._run_git(["rev-parse", "--git-dir"]) + except RuntimeError: + print("Error: Not in a git repository.", file=sys.stderr) + return 4 + elif not self._validate_config(): return 4 # Configuration error # Validate argument combinations @@ -82,11 +96,12 @@ def run(self, args=None): return 2 try: - # Get diff content - diff_content = self._get_diff_content(parsed_args) - # Perform review - result = self.reviewer.review_diff(diff_content) + if parsed_args.offline: + result = self._run_offline_review(parsed_args) + else: + diff_content = self._get_diff_content(parsed_args) + result = self.reviewer.review_diff(diff_content) # Output results self._output_results(result, parsed_args) @@ -109,6 +124,7 @@ def _create_parser(self) -> argparse.ArgumentParser: python review.py --mode unstaged --format json # Review unstaged in JSON python review.py --mode all --strict # Review all with strict mode python review.py --base main --head feature # Review between branches + python review.py --mode staged --offline # Offline static analysis only """, ) @@ -154,6 +170,11 @@ def _create_parser(self) -> argparse.ArgumentParser: action="store_true", help="Dump full LLM prompts and responses to stderr", ) + parser.add_argument( + "--offline", + action="store_true", + help="Run static analysis only, without LLM (forces docstring checks)", + ) # Utility commands parser.add_argument( @@ -295,6 +316,38 @@ def _format_text_output(self, result: ReviewResult, verbose: bool = False) -> st return "\n".join(lines) + def _run_offline_review(self, args) -> ReviewResult: + """Run static analysis only, without LLM. Forces docstring checks.""" + from static_analyzer import StaticAnalyzer + + # Get raw git diff (StaticAnalyzer expects raw format with +++ headers) + if args.base and args.head: + raw_diff = self.parser.get_diff("range", args.base, args.head) + elif args.mode: + raw_diff = self.parser.get_diff(args.mode) + else: + raw_diff = self.parser.get_diff("staged") + + if args.verbose: + parsed_files = self.parser.parse_diff(raw_diff) + if parsed_files: + print(f"Analyzing {len(parsed_files)} file(s):", file=sys.stderr) + for file_data in parsed_files: + print( + f" - {file_data['path']} ({file_data['type']})", + file=sys.stderr, + ) + print("", file=sys.stderr) + + original_value = self.config.get("review.check_docstrings", True) + self.config.config.setdefault("review", {})["check_docstrings"] = True + + analyzer = StaticAnalyzer(self.config) + result = analyzer.analyze_diff(raw_diff) + + self.config.config["review"]["check_docstrings"] = original_value + return result + def _get_exit_code(self, result: ReviewResult, args=None) -> int: """Get appropriate exit code based on results.""" if result.status == "skipped": diff --git a/review_config.json b/review_config.json index b9cb4e9..044e72f 100644 --- a/review_config.json +++ b/review_config.json @@ -32,6 +32,8 @@ "missing_error_handling", "documentation_gaps" ], + "check_docstrings": true, + "docstring_min_lines": 0, "file_extensions": [".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h", ".go"], "exclude_patterns": [ "node_modules/", diff --git a/review_config_example.json b/review_config_example.json index a1c3f06..47c5a1f 100644 --- a/review_config_example.json +++ b/review_config_example.json @@ -22,6 +22,8 @@ "missing_error_handling", "documentation_gaps" ], + "check_docstrings": true, + "docstring_min_lines": 0, "file_extensions": [".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h", ".go"], "exclude_patterns": [ "node_modules/", diff --git a/review_core.py b/review_core.py index 19ab925..2477c1f 100644 --- a/review_core.py +++ b/review_core.py @@ -38,46 +38,41 @@ def review_outcome(self) -> str: class LLMReviewer: """Handles LLM interactions for code review.""" - DEFAULT_PROMPT = """You are a security-focused code reviewer. Analyze the following git diff changes for: + DEFAULT_PROMPT = """You are a strict, security-focused code reviewer. Analyze ONLY the added/changed lines in the git diff below. + +Rules: +- Report ONLY issues you can see in the actual diff. Do NOT give generic advice. +- Each issue MUST reference a specific file and line, using format: file.py:42: description +- If no issues found, respond "NONE" for that category. +- Be concise. Do not repeat yourself. CRITICAL ISSUES (block commit): - Hardcoded credentials, API keys, secrets - SQL injection, XSS vulnerabilities - Unsafe functions (eval(), exec(), system()) -- Direct file system operations without validation -- Network requests to external endpoints without proper validation -- Buffer overflow risks - Command injection vulnerabilities +- Buffer overflow risks {custom_critical_rules} WARNINGS (allow commit but flag): -- Code style violations -- Potential bugs and edge cases -- Performance issues -- Missing error handling -- Input validation gaps -- Documentation gaps +- Actual bugs or logic errors visible in the diff +- Missing error handling for operations that can fail +- Security-relevant input validation gaps {custom_warnings} -SUGGESTIONS (improvements): -- Best practices recommendations -- Code organization improvements -- Security enhancements +SUGGESTIONS (only if clearly beneficial): +- Concrete improvements to the changed code {custom_suggestions} {additional_instructions} -Format your response as: -CRITICAL: [issue description] -WARNING: [issue description] -SUGGESTION: [suggestion] - -If no issues found for a category, respond "NONE". +Format: +CRITICAL: file.py:42: issue description +WARNING: file.py:15: issue description +SUGGESTION: file.py:30: suggestion Changes to review: -{diff_content} - -Focus on security vulnerabilities first, then code quality.""" +{diff_content}""" def __init__(self, config, trace: bool = False, trace_llm: bool = False): self.config = config @@ -107,6 +102,15 @@ def _build_prompt(self, diff_content: str) -> str: if not isinstance(additional, str): additional = "" + if self.config.get("review.check_docstrings", True): + docstring_rule = ( + "Flag missing docstrings/documentation comments on functions, methods, " + "and classes (especially large ones). Suggest language-appropriate format: " + "Python docstrings, JSDoc, Javadoc, Doxygen, or godoc" + ) + if docstring_rule not in custom_suggestions: + custom_suggestions.append(docstring_rule) + critical_str = "\n".join( f"- {rule}" for rule in custom_critical if isinstance(rule, str) ) diff --git a/static_analyzer.py b/static_analyzer.py index e4d3ffa..718b641 100644 --- a/static_analyzer.py +++ b/static_analyzer.py @@ -10,6 +10,72 @@ class StaticAnalyzer: """Static code analysis for basic security checks.""" + # Patterns for detecting function/class definitions and their docstrings + # Each entry: (definition_regex, docstring_opener_regex, name_group_index) + DOCSTRING_PATTERNS = { + ".py": { + "definition": re.compile(r"^\s*(?:async\s+)?(?:def|class)\s+(\w+)"), + "docstring": re.compile(r'^\s*(?:"""|\'\'\'|r"""|r\'\'\')'), + "position": "after", + }, + ".js": { + "definition": re.compile( + r"^\s*(?:export\s+(?:default\s+)?)?(?:async\s+)?(?:" + r"function\s+(\w+)" + r"|class\s+(\w+)" + r"|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:function\s*[\s(]|\([^)]*\)\s*=>|[a-zA-Z_$]\w*\s*=>)" + r"|(?!if|else|for|while|switch|catch|do|return|throw|new|typeof|instanceof|void|delete|await|yield)(\w+)\s*\([^)]*\)\s*\{" + r")" + ), + "docstring": re.compile(r"^\s*/\*\*"), + "position": "before", + }, + ".java": { + "definition": re.compile( + r"^\s*(?:public|private|protected|static|final|abstract|synchronized|default|strictfp|\s)*" + r"(?:<[^>]+>\s*)?" + r"(?:class|interface|enum|record|@interface|void|int|String|boolean|long|double|float|char|byte|short|[A-Z]\w*)" + r"(?:\s*\[\])*" + r"\s+(\w+)\s*[\(<{]" + ), + "docstring": re.compile(r"^\s*/\*\*"), + "position": "before", + }, + ".c": { + "definition": re.compile( + r"^\s*(?:(?:static|inline|const|volatile|extern|unsigned|signed|virtual|explicit)\s+)*" + r"(?:void|bool|int|char|float|double|long|short|size_t|ssize_t|struct\s+\w+|enum\s+\w+|union\s+\w+|class\s+\w+|\w+_t|\w+::\w+)" + r"(?:\s*\*+|\s*&+|\s+)*" + r"\s*(\w+)\s*\(" + ), + "docstring": re.compile(r"^\s*(?:/\*\*|///)"), + "position": "before", + }, + ".go": { + "definition": re.compile( + r"^\s*func\s+(?:\(\s*\w+\s+\*?[\w.]+\)\s+)?(\w+)\s*(?:\[[^\]]*\])?\s*\(" + ), + "docstring": re.compile(r"^\s*//"), + "position": "before", + }, + ".rs": { + "definition": re.compile( + r"^\s*(?:pub\s*(?:\([^)]*\)\s*)?)?(?:(?:async|unsafe|const|default)\s+)*(?:extern\s+\"[^\"]*\"\s+)?(?:fn|struct|enum|trait|impl|type|mod)\s+(\w+)" + ), + "docstring": re.compile(r"^\s*///"), + "position": "before", + }, + } + + # Extension aliases mapping to canonical patterns + _EXT_ALIASES = { + ".ts": ".js", + ".jsx": ".js", + ".tsx": ".js", + ".cpp": ".c", + ".h": ".c", + } + def __init__(self, config): self.config = config @@ -27,15 +93,17 @@ def analyze_diff(self, diff_content: str) -> Any: for line_info in added_lines: line = line_info["content"] file_path = line_info["file"] + line_num = line_info.get("line", 0) + file_ref = f"{file_path}:{line_num}" if line_num else file_path # Security checks - convert to warnings for static analysis # In static analysis mode, we treat everything as warnings to avoid blocking commits # when LLM is unavailable critical_from_static = [] - critical_from_static.extend(self._check_credentials(line, file_path)) - critical_from_static.extend(self._check_sql_injection(line, file_path)) - critical_from_static.extend(self._check_unsafe_functions(line, file_path)) - critical_from_static.extend(self._check_file_operations(line, file_path)) + critical_from_static.extend(self._check_credentials(line, file_ref)) + critical_from_static.extend(self._check_sql_injection(line, file_ref)) + critical_from_static.extend(self._check_unsafe_functions(line, file_ref)) + critical_from_static.extend(self._check_file_operations(line, file_ref)) # Convert critical issues to warnings for static analysis fallback warnings.extend( @@ -43,11 +111,15 @@ def analyze_diff(self, diff_content: str) -> Any: ) # Code quality checks - warnings.extend(self._check_hardcoded_urls(line, file_path)) - warnings.extend(self._check_debug_code(line, file_path)) + warnings.extend(self._check_hardcoded_urls(line, file_ref)) + warnings.extend(self._check_debug_code(line, file_ref)) # Suggestions - suggestions.extend(self._suggest_improvements(line, file_path)) + suggestions.extend(self._suggest_improvements(line, file_ref)) + + # Docstring checks + if self.config.get("review.check_docstrings", True): + suggestions.extend(self._check_docstrings(added_lines)) return ReviewResult( status="success", @@ -58,9 +130,10 @@ def analyze_diff(self, diff_content: str) -> Any: ) def _extract_added_lines(self, diff_content: str) -> List[Dict[str, str]]: - """Extract added lines from diff with file context.""" + """Extract added lines from diff with file context and line numbers.""" lines = [] current_file = "unknown" + current_line = 0 for line in diff_content.split("\n"): if line.startswith("+++"): @@ -68,13 +141,23 @@ def _extract_added_lines(self, diff_content: str) -> List[Dict[str, str]]: match = re.search(r"b/(.+)$", line) if match: current_file = match.group(1) + elif line.startswith("@@"): + # Parse hunk header for new file line number + match = re.search(r"\+(\d+)", line) + if match: + current_line = int(match.group(1)) elif line.startswith("+") and not line.startswith("+++"): lines.append( { "content": line[1:], # Remove '+' prefix "file": current_file, + "line": current_line, } ) + current_line += 1 + elif not line.startswith("-"): + # Context line — increment new file line counter + current_line += 1 return lines @@ -179,21 +262,57 @@ def _check_hardcoded_urls(self, line: str, file_path: str) -> List[str]: return warnings + # Language-specific debug patterns. + # print() is legitimate UI output in Python CLIs, so only flag + # Python-specific debugger calls. For JS/TS, console.log is debug. + _DEBUG_PATTERNS_BY_EXT = { + ".py": [ + (r"breakpoint\s*\(", "breakpoint()"), + (r"pdb\.set_trace", "pdb.set_trace()"), + (r"import\s+pdb", "import pdb"), + (r"import\s+ipdb", "import ipdb"), + (r"ic\s*\(", "ic()"), + ], + ".js": [ + (r"console\.log\s*\(", "console.log()"), + (r"console\.debug\s*\(", "console.debug()"), + (r"debugger", "debugger"), + ], + ".java": [ + (r"System\.out\.print", "System.out.print"), + (r"\.printStackTrace\s*\(", "printStackTrace()"), + ], + ".c": [ + (r"printf\s*\(", "printf()"), + (r"fprintf\s*\(\s*stderr", "fprintf(stderr)"), + ], + ".go": [ + (r"fmt\.Print", "fmt.Print"), + (r"log\.Print", "log.Print"), + ], + ".rs": [ + (r"dbg!\s*\(", "dbg!()"), + (r"println!\s*\(", "println!()"), + ], + } + def _check_debug_code(self, line: str, file_path: str) -> List[str]: """Check for debug code that shouldn't be in production.""" warnings = [] - debug_patterns = [ - r"print\s*\(", - r"console\.log\s*\(", - r"debugger", - r"pdb\.set_trace", - r"import\s+pdb", - ] + # Extract extension from file_path (which may include :line suffix) + path_part = file_path.split(":")[0] + ext = "" + dot_idx = path_part.rfind(".") + if dot_idx != -1: + ext = path_part[dot_idx:] + + canonical = self._EXT_ALIASES.get(ext, ext) + patterns = self._DEBUG_PATTERNS_BY_EXT.get(canonical, []) - for pattern in debug_patterns: + for pattern, name in patterns: if re.search(pattern, line): - warnings.append(f"{file_path}: Debug code detected") + warnings.append(f"{file_path}: Debug code detected: {name}") break return warnings @@ -223,3 +342,110 @@ def _suggest_improvements(self, line: str, file_path: str) -> List[str]: ) return suggestions + + def _get_docstring_patterns(self, ext: str) -> dict | None: + """Get docstring patterns for a file extension, resolving aliases.""" + canonical = self._EXT_ALIASES.get(ext, ext) + return self.DOCSTRING_PATTERNS.get(canonical) + + def _count_body_lines(self, lines: List[str], start_index: int, ext: str) -> int: + """Count the number of body lines for a definition starting at start_index.""" + canonical = self._EXT_ALIASES.get(ext, ext) + + if canonical == ".py": + # Indentation-based: count subsequent non-blank lines with strictly + # greater indentation than the definition line. + def_line = lines[start_index] + def_indent = len(def_line) - len(def_line.lstrip()) + count = 0 + for j in range(start_index + 1, len(lines)): + stripped = lines[j].strip() + if not stripped: + continue + line_indent = len(lines[j]) - len(lines[j].lstrip()) + if line_indent <= def_indent: + break + count += 1 + return count + else: + # Brace languages: track { } depth. + depth = 0 + started = False + count = 0 + for j in range(start_index, len(lines)): + line = lines[j] + for ch in line: + if ch == "{": + depth += 1 + started = True + elif ch == "}": + depth -= 1 + if started and j > start_index: + if lines[j].strip(): + count += 1 + if started and depth <= 0: + break + return count + + def _check_docstrings(self, added_lines: List[Dict[str, str]]) -> List[str]: + """Check added lines for functions/classes missing docstrings.""" + suggestions = [] + + # Group added lines by file, preserving order + files: Dict[str, List[str]] = {} + for line_info in added_lines: + fp = line_info["file"] + if fp not in files: + files[fp] = [] + files[fp].append(line_info["content"]) + + for file_path, lines in files.items(): + # Determine file extension + ext = "" + dot_idx = file_path.rfind(".") + if dot_idx != -1: + ext = file_path[dot_idx:] + + patterns = self._get_docstring_patterns(ext) + if not patterns: + continue + + def_re = patterns["definition"] + doc_re = patterns["docstring"] + position = patterns.get("position", "after") + + for i, line in enumerate(lines): + match = def_re.match(line) + if not match: + continue + + # Extract function/class name from first non-None group + name = next((g for g in match.groups() if g is not None), "unknown") + + has_docstring = False + if position == "after": + # Python: docstring on the line(s) after the definition + for j in range(i + 1, min(i + 3, len(lines))): + if not lines[j].strip(): + continue + if doc_re.match(lines[j]): + has_docstring = True + break + else: + # Other languages: doc comment on the line(s) before the definition + for j in range(i - 1, max(i - 4, -1), -1): + if not lines[j].strip(): + continue + if doc_re.match(lines[j]): + has_docstring = True + break + + if not has_docstring: + min_lines = self.config.get("review.docstring_min_lines", 0) + if min_lines > 0: + body_lines = self._count_body_lines(lines, i, ext) + if body_lines < min_lines: + continue + suggestions.append(f"{file_path}: Missing docstring for '{name}'") + + return suggestions diff --git a/test_docstrings.py b/test_docstrings.py new file mode 100644 index 0000000..6d07c08 --- /dev/null +++ b/test_docstrings.py @@ -0,0 +1,561 @@ +"""Tests for docstring detection in StaticAnalyzer.""" + +from unittest.mock import MagicMock + +import pytest + +from static_analyzer import StaticAnalyzer + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_added_lines(file: str, lines: list[str]) -> list[dict[str, str]]: + """Build the added_lines structure expected by _check_docstrings.""" + return [{"file": file, "content": line} for line in lines] + + +def _check(analyzer: StaticAnalyzer, file: str, lines: list[str]) -> list[str]: + """Shortcut: run _check_docstrings and return the suggestions list.""" + return analyzer._check_docstrings(_make_added_lines(file, lines)) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def analyzer(): + cfg = MagicMock() + + def _get(path, default=None): + if path == "review.check_docstrings": + return True + if path == "review.docstring_min_lines": + return 0 + return default + + cfg.get.side_effect = _get + return StaticAnalyzer(cfg) + + +@pytest.fixture() +def analyzer_disabled(): + cfg = MagicMock() + cfg.get.return_value = False # check_docstrings disabled + return StaticAnalyzer(cfg) + + +# =================================================================== +# A. Basic detection — undocumented definition → flag +# =================================================================== + + +class TestBasicDetectionPython: + def test_function(self, analyzer): + result = _check(analyzer, "app.py", ["def foo():", " pass"]) + assert any("foo" in s for s in result) + + def test_async_function(self, analyzer): + result = _check(analyzer, "app.py", ["async def bar():", " pass"]) + assert any("bar" in s for s in result) + + def test_class(self, analyzer): + result = _check(analyzer, "app.py", ["class MyClass:", " pass"]) + assert any("MyClass" in s for s in result) + + +class TestBasicDetectionJS: + def test_function_declaration(self, analyzer): + result = _check(analyzer, "app.js", ["function greet(name) {"]) + assert any("greet" in s for s in result) + + def test_arrow_function(self, analyzer): + result = _check(analyzer, "app.js", ["const add = (a, b) => {"]) + assert any("add" in s for s in result) + + def test_class(self, analyzer): + result = _check(analyzer, "app.js", ["class Widget {"]) + assert any("Widget" in s for s in result) + + def test_async_function(self, analyzer): + result = _check(analyzer, "app.js", ["async function fetchData() {"]) + assert any("fetchData" in s for s in result) + + +class TestBasicDetectionJava: + def test_method(self, analyzer): + result = _check(analyzer, "Main.java", [" public void run() {"]) + assert any("run" in s for s in result) + + def test_class(self, analyzer): + result = _check(analyzer, "Main.java", ["public class Main {"]) + assert any("Main" in s for s in result) + + +class TestBasicDetectionC: + def test_function(self, analyzer): + result = _check(analyzer, "main.c", ["int main(int argc, char **argv) {"]) + assert any("main" in s for s in result) + + def test_void_function(self, analyzer): + result = _check(analyzer, "util.c", ["void helper(void) {"]) + assert any("helper" in s for s in result) + + +class TestBasicDetectionGo: + def test_function(self, analyzer): + result = _check(analyzer, "main.go", ["func main() {"]) + assert any("main" in s for s in result) + + def test_method(self, analyzer): + result = _check(analyzer, "svc.go", ["func (s *Service) Start() {"]) + assert any("Start" in s for s in result) + + +class TestBasicDetectionRust: + def test_function(self, analyzer): + result = _check(analyzer, "main.rs", ["fn main() {"]) + assert any("main" in s for s in result) + + def test_pub_function(self, analyzer): + result = _check(analyzer, "lib.rs", ["pub fn new() -> Self {"]) + assert any("new" in s for s in result) + + +# =================================================================== +# B. Documented definitions → no flag +# =================================================================== + + +class TestDocumentedPython: + def test_function_with_docstring(self, analyzer): + result = _check( + analyzer, "app.py", ["def foo():", ' """Docstring."""', " pass"] + ) + assert not any("foo" in s for s in result) + + def test_class_with_docstring(self, analyzer): + result = _check( + analyzer, "app.py", ["class Foo:", ' """A class."""', " pass"] + ) + assert not any("Foo" in s for s in result) + + +class TestDocumentedJS: + def test_function_with_jsdoc(self, analyzer): + result = _check( + analyzer, "app.js", ["/** Does stuff. */", "function greet(name) {"] + ) + assert not any("greet" in s for s in result) + + def test_arrow_with_jsdoc(self, analyzer): + result = _check( + analyzer, "app.js", ["/** Adds two numbers. */", "const add = (a, b) => {"] + ) + assert not any("add" in s for s in result) + + +class TestDocumentedJava: + def test_method_with_javadoc(self, analyzer): + result = _check( + analyzer, + "Main.java", + [" /** Runs the app. */", " public void run() {"], + ) + assert not any("run" in s for s in result) + + +class TestDocumentedC: + def test_function_with_doxygen(self, analyzer): + result = _check( + analyzer, + "main.c", + ["/** Entry point. */", "int main(int argc, char **argv) {"], + ) + assert not any("main" in s for s in result) + + def test_function_with_triple_slash(self, analyzer): + result = _check( + analyzer, "util.c", ["/// Helper function.", "void helper(void) {"] + ) + assert not any("helper" in s for s in result) + + +class TestDocumentedGo: + def test_function_with_comment(self, analyzer): + result = _check( + analyzer, "main.go", ["// main is the entry point.", "func main() {"] + ) + assert not any("main" in s for s in result) + + +class TestDocumentedRust: + def test_function_with_doc_comment(self, analyzer): + result = _check(analyzer, "main.rs", ["/// Entry point.", "fn main() {"]) + assert not any("main" in s for s in result) + + +# =================================================================== +# C. Edge cases — per-language improved patterns +# =================================================================== + + +class TestEdgeCasesJS: + def test_export_default_function(self, analyzer): + result = _check(analyzer, "app.js", ["export default function handler(req) {"]) + assert any("handler" in s for s in result) + + def test_export_default_class(self, analyzer): + result = _check(analyzer, "app.js", ["export default class App {"]) + assert any("App" in s for s in result) + + def test_class_method_shorthand(self, analyzer): + result = _check(analyzer, "app.js", [" render() {"]) + assert any("render" in s for s in result) + + def test_single_param_arrow(self, analyzer): + result = _check(analyzer, "app.js", ["const square = x => {"]) + assert any("square" in s for s in result) + + def test_function_expression(self, analyzer): + result = _check(analyzer, "app.js", ["const greet = function(name) {"]) + assert any("greet" in s for s in result) + + def test_if_not_detected(self, analyzer): + """Keywords like if/for/while should NOT be detected as functions.""" + result = _check(analyzer, "app.js", ["if (x) {", " return;", "}"]) + assert not any("if" in s for s in result) + + def test_for_not_detected(self, analyzer): + result = _check(analyzer, "app.js", ["for (let i = 0; i < 10; i++) {", "}"]) + assert not result + + def test_while_not_detected(self, analyzer): + result = _check(analyzer, "app.js", ["while (true) {", "}"]) + assert not result + + +class TestEdgeCasesJava: + def test_synchronized_method(self, analyzer): + result = _check( + analyzer, + "App.java", + [" public synchronized void process() {"], + ) + assert any("process" in s for s in result) + + def test_generic_method(self, analyzer): + result = _check( + analyzer, "App.java", [" public List convert(T input) {"] + ) + assert any("convert" in s for s in result) + + def test_enum(self, analyzer): + result = _check(analyzer, "Color.java", ["public enum Color {"]) + assert any("Color" in s for s in result) + + def test_record(self, analyzer): + result = _check(analyzer, "Point.java", ["public record Point(int x, int y) {"]) + assert any("Point" in s for s in result) + + def test_array_return_type(self, analyzer): + result = _check(analyzer, "Util.java", [" public int[] getValues() {"]) + assert any("getValues" in s for s in result) + + +class TestEdgeCasesC: + def test_bool_return(self, analyzer): + result = _check(analyzer, "check.c", ["bool is_valid(int x) {"]) + assert any("is_valid" in s for s in result) + + def test_double_pointer(self, analyzer): + result = _check(analyzer, "mem.c", ["void** allocate(size_t n) {"]) + assert any("allocate" in s for s in result) + + def test_size_t_param(self, analyzer): + result = _check(analyzer, "buf.c", ["size_t buffer_len(void) {"]) + assert any("buffer_len" in s for s in result) + + def test_virtual_method(self, analyzer): + result = _check(analyzer, "base.h", ["virtual void update(int dt) {"]) + assert any("update" in s for s in result) + + def test_namespace_return(self, analyzer): + result = _check(analyzer, "app.cpp", ["std::string getName(void) {"]) + assert any("getName" in s for s in result) + + def test_reference_return(self, analyzer): + result = _check(analyzer, "vec.cpp", ["int& at(size_t idx) {"]) + assert any("at" in s for s in result) + + +class TestEdgeCasesGo: + def test_generic_function(self, analyzer): + result = _check(analyzer, "util.go", ["func Map[T any](items []T) []T {"]) + assert any("Map" in s for s in result) + + def test_dotted_receiver(self, analyzer): + result = _check(analyzer, "svc.go", ["func (s *pkg.Service) Run() {"]) + assert any("Run" in s for s in result) + + +class TestEdgeCasesRust: + def test_unsafe_fn(self, analyzer): + result = _check(analyzer, "ffi.rs", ["pub unsafe fn transmute(ptr: *mut u8) {"]) + assert any("transmute" in s for s in result) + + def test_pub_super(self, analyzer): + result = _check(analyzer, "inner.rs", ["pub(super) fn helper() {"]) + assert any("helper" in s for s in result) + + def test_pub_in_path(self, analyzer): + result = _check(analyzer, "lib.rs", ["pub(in crate::utils) fn internal() {"]) + assert any("internal" in s for s in result) + + def test_const_fn(self, analyzer): + result = _check(analyzer, "lib.rs", ["const fn max_size() -> usize {"]) + assert any("max_size" in s for s in result) + + def test_extern_c_fn(self, analyzer): + result = _check(analyzer, "ffi.rs", ['extern "C" fn callback(x: i32) -> i32 {']) + assert any("callback" in s for s in result) + + def test_type_alias(self, analyzer): + result = _check( + analyzer, + "types.rs", + ["pub type Result = std::result::Result;"], + ) + assert any("Result" in s for s in result) + + def test_mod(self, analyzer): + result = _check(analyzer, "lib.rs", ["pub mod utils {"]) + assert any("utils" in s for s in result) + + +# =================================================================== +# D. Extension aliases +# =================================================================== + + +class TestExtensionAliases: + def test_ts_uses_js_patterns(self, analyzer): + result = _check(analyzer, "app.ts", ["function greet(name: string) {"]) + assert any("greet" in s for s in result) + + def test_tsx_uses_js_patterns(self, analyzer): + result = _check(analyzer, "App.tsx", ["export default function App() {"]) + assert any("App" in s for s in result) + + def test_jsx_uses_js_patterns(self, analyzer): + result = _check(analyzer, "Button.jsx", ["const Button = (props) => {"]) + assert any("Button" in s for s in result) + + def test_cpp_uses_c_patterns(self, analyzer): + result = _check(analyzer, "util.cpp", ["void process(int n) {"]) + assert any("process" in s for s in result) + + def test_h_uses_c_patterns(self, analyzer): + result = _check(analyzer, "util.h", ["int calculate(double x) {"]) + assert any("calculate" in s for s in result) + + +# =================================================================== +# E. Disabled config +# =================================================================== + + +class TestDisabledConfig: + def test_no_suggestions_when_disabled(self, analyzer_disabled): + """When check_docstrings is False, analyze_diff should skip docstring checks.""" + # We test through analyze_diff because the config gate lives there + result = analyzer_disabled.analyze_diff( + "--- a/app.py\n+++ b/app.py\n+def foo():\n+ pass\n" + ) + assert not any("docstring" in s.lower() for s in result.suggestions) + + +# =================================================================== +# F. Edge cases — general +# =================================================================== + + +class TestGeneralEdgeCases: + def test_empty_input(self, analyzer): + result = _check(analyzer, "app.py", []) + assert result == [] + + def test_unknown_extension(self, analyzer): + result = _check(analyzer, "script.rb", ["def hello", "end"]) + assert result == [] + + def test_definition_on_first_line(self, analyzer): + result = _check(analyzer, "app.js", ["function first() {"]) + assert any("first" in s for s in result) + + def test_definition_on_last_line_python(self, analyzer): + result = _check(analyzer, "app.py", ["def last():"]) + assert any("last" in s for s in result) + + def test_doc_comment_too_far_away(self, analyzer): + """Doc comment 4+ lines before definition should not count.""" + result = _check( + analyzer, + "app.js", + [ + "/** This is a doc comment. */", + "", + "", + "", + "function farAway() {", + ], + ) + assert any("farAway" in s for s in result) + + def test_multiple_definitions_mixed(self, analyzer): + """Mix of documented and undocumented in one file.""" + result = _check( + analyzer, + "app.py", + [ + "def no_docs():", + " pass", + "", + "def has_docs():", + ' """Has a docstring."""', + " pass", + ], + ) + assert any("no_docs" in s for s in result) + assert not any("has_docs" in s for s in result) + + +# =================================================================== +# G. Minimum body lines threshold (docstring_min_lines) +# =================================================================== + + +@pytest.fixture() +def analyzer_min5(): + """Analyzer with docstring_min_lines=5.""" + cfg = MagicMock() + + def _get(path, default=None): + if path == "review.check_docstrings": + return True + if path == "review.docstring_min_lines": + return 5 + return default + + cfg.get.side_effect = _get + return StaticAnalyzer(cfg) + + +@pytest.fixture() +def analyzer_min1(): + """Analyzer with docstring_min_lines=1.""" + cfg = MagicMock() + + def _get(path, default=None): + if path == "review.check_docstrings": + return True + if path == "review.docstring_min_lines": + return 1 + return default + + cfg.get.side_effect = _get + return StaticAnalyzer(cfg) + + +class TestMinLines: + def test_python_short_function_no_flag(self, analyzer_min5): + """Python function with body < 5 lines should not be flagged.""" + result = _check( + analyzer_min5, + "app.py", + ["def short():", " x = 1", " return x"], + ) + assert not any("short" in s for s in result) + + def test_python_long_function_flagged(self, analyzer_min5): + """Python function with body >= 5 lines should be flagged.""" + result = _check( + analyzer_min5, + "app.py", + [ + "def long_func():", + " a = 1", + " b = 2", + " c = 3", + " d = 4", + " return a + b + c + d", + ], + ) + assert any("long_func" in s for s in result) + + def test_js_short_arrow_no_flag(self, analyzer_min5): + """Short JS arrow function should not be flagged.""" + result = _check( + analyzer_min5, + "app.js", + ["const add = (a, b) => {", " return a + b;", "}"], + ) + assert not any("add" in s for s in result) + + def test_js_long_function_flagged(self, analyzer_min5): + """Long JS function should be flagged.""" + result = _check( + analyzer_min5, + "app.js", + [ + "function process(data) {", + " const a = data.a;", + " const b = data.b;", + " const c = a + b;", + " const d = c * 2;", + " return d;", + "}", + ], + ) + assert any("process" in s for s in result) + + def test_go_short_func_no_flag(self, analyzer_min5): + """Short Go function should not be flagged.""" + result = _check( + analyzer_min5, + "main.go", + ["func add(a, b int) int {", " return a + b", "}"], + ) + assert not any("add" in s for s in result) + + def test_threshold_zero_always_flags(self, analyzer): + """Default threshold 0 means all functions are checked.""" + result = _check( + analyzer, + "app.py", + ["def tiny():", " pass"], + ) + assert any("tiny" in s for s in result) + + def test_threshold_one_skips_single_line(self, analyzer_min1): + """Threshold 1 should skip functions with 0 body lines but flag those with >= 1.""" + # Function with no body lines in diff (definition only) + result_empty = _check(analyzer_min1, "app.py", ["def empty():"]) + assert not any("empty" in s for s in result_empty) + + # Function with 1 body line should be flagged + result_one = _check(analyzer_min1, "app.py", ["def one_liner():", " pass"]) + assert any("one_liner" in s for s in result_one) + + def test_class_short_body_no_flag(self, analyzer_min5): + """Class with short body should not be flagged.""" + result = _check( + analyzer_min5, + "app.py", + ["class Small:", " x = 1"], + ) + assert not any("Small" in s for s in result)