diff --git a/CLAUDE.md b/CLAUDE.md index 00a6653..fda15dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,11 @@ aidocs export-pdf docs/page.md # Watch mode (auto-sync on file changes) aidocs watch # Watch docs/ and auto-chunk on changes aidocs watch --with-vectors # Also generate embeddings + +# Documentation coverage analysis +aidocs coverage # Show coverage report +aidocs coverage --format json # Machine-readable output +aidocs coverage --ci # Exit code 1 if below 80% ``` ## Architecture @@ -41,9 +46,10 @@ aidocs watch --with-vectors # Also generate embeddings ``` src/aidocs_cli/ ├── __init__.py # Version and entry point -├── cli.py # Typer CLI commands (init, check, serve, rag-*, export-pdf, watch) +├── cli.py # Typer CLI commands (init, check, serve, rag-*, export-pdf, watch, coverage) ├── installer.py # Copies templates to target project (.claude/commands/, .claude/workflows/) ├── chunker.py # Splits markdown at ## headings for RAG +├── coverage.py # Documentation coverage analysis (routes, components, models detection) ├── embeddings.py # OpenAI embeddings + SQL generation for pgvector ├── server.py # MkDocs config generation and nav discovery ├── pdf_exporter.py # Markdown→HTML→PDF with Chrome/Playwright @@ -59,6 +65,7 @@ src/aidocs_cli/ - **CLI (cli.py)**: Uses Typer with Rich for terminal UI. Entry point is `app()`. - **Installer**: Copies command/workflow templates to target project's `.claude/` directory (or `.cursor/` for Cursor). - **Chunker**: Creates `.chunks.json` files alongside markdown, tracks changes via `docs/.chunks/manifest.json`. +- **Coverage**: Analyzes codebase for routes/components/models, matches against docs, reports coverage with visual progress bars. - **Embeddings**: Calls OpenAI API (text-embedding-3-small, 1536 dimensions), outputs `docs/.chunks/sync.sql` for pgvector import. - **Server**: Auto-discovers nav structure from folder hierarchy, generates ephemeral `mkdocs.yml`. diff --git a/README.md b/README.md index f7ec0ac..c6f85a8 100644 --- a/README.md +++ b/README.md @@ -548,6 +548,69 @@ aidocs serve # Edit docs in your editor - changes auto-sync! ``` +### `aidocs coverage` + +Analyze documentation coverage for your codebase. Scans for routes, components, and models, then checks which items are mentioned in your documentation. + +```bash +aidocs coverage # Show coverage summary +aidocs coverage --format json # Machine-readable output +aidocs coverage --format csv # CSV export +aidocs coverage --ci # Exit code 1 if below 80% +aidocs coverage --threshold 70 # Custom threshold +aidocs coverage -c ./src # Specify codebase path +aidocs coverage --all # Show all items +``` + +**Options:** +| Option | Description | +|--------|-------------| +| `--codebase, -c` | Path to codebase root (default: parent of docs dir) | +| `--format, -f` | Output format: `summary`, `json`, or `csv` | +| `--threshold, -t` | Minimum coverage percentage (exit 1 if below) | +| `--ci` | CI mode: exit 1 if coverage below 80% | +| `--save/--no-save` | Save report to `.chunks/coverage.json` (default: save) | +| `--all, -a` | Show all items (documented and undocumented) | + +**Example output:** +``` +╭───────────────────────────────────────────────╮ +│ Documentation Coverage Report │ +│ ───────────────────────────────────────────── │ +│ │ +│ Routes: 12/15 ( 80%) █████████░░░ │ +│ Components: 8/20 ( 40%) ████░░░░░░░░ │ +│ Models: 5/5 (100%) ████████████ │ +│ │ +│ Overall: 25/40 (63%) ███████░░░░░ │ +╰───────────────────────────────────────────────╯ + +Missing documentation: + Routes: + ✗ POST /api/webhooks/stripe + src/app/api/webhooks/stripe/route.ts:1 + Components: + ✗ PaymentForm + src/components/PaymentForm.tsx:1 +``` + +**Supported frameworks:** +- **Next.js** - App Router routes and pages +- **React** - Function and class components +- **Vue/Svelte** - Single-file components +- **Express** - Route handlers +- **FastAPI/Flask** - Python API routes +- **Laravel** - PHP routes +- **Prisma** - Database models +- **TypeScript** - Interfaces and types + +**CI/CD integration:** +```yaml +# GitHub Actions example +- name: Check documentation coverage + run: aidocs coverage --ci +``` + ## Slash Commands After running `aidocs init`, these commands are available in Claude Code: diff --git a/pyproject.toml b/pyproject.toml index e4fa8ed..3bf75ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aidocs" -version = "0.17.0" +version = "0.18.0" description = "AI-powered documentation generator for web applications. Install docs commands into your Claude Code project." readme = "README.md" license = { text = "MIT" } diff --git a/src/aidocs_cli/cli.py b/src/aidocs_cli/cli.py index 38dec26..5655bc1 100644 --- a/src/aidocs_cli/cli.py +++ b/src/aidocs_cli/cli.py @@ -12,6 +12,7 @@ from . import __version__ from .chunker import chunk_directory +from .coverage import analyze_coverage, save_coverage_report from .embeddings import generate_sync_sql, get_openai_api_key from .installer import check_tools, install_docs_module from .pdf_exporter import export_markdown_to_pdf @@ -906,5 +907,293 @@ def watch( raise typer.Exit(1) +def _render_progress_bar(percent: float, width: int = 12) -> str: + """Render a text-based progress bar.""" + filled = int(width * percent / 100) + empty = width - filled + return "█" * filled + "░" * empty + + +def _get_coverage_color(percent: float) -> str: + """Get color based on coverage percentage.""" + if percent >= 80: + return "green" + elif percent >= 50: + return "yellow" + else: + return "red" + + +@app.command("coverage") +def coverage( + docs_dir: Optional[str] = typer.Argument( + "docs", + help="Directory containing documentation.", + ), + codebase: Optional[str] = typer.Option( + None, + "--codebase", + "-c", + help="Path to codebase root (default: parent of docs dir).", + ), + format_output: str = typer.Option( + "summary", + "--format", + "-f", + help="Output format: summary, json, or csv.", + ), + threshold: Optional[float] = typer.Option( + None, + "--threshold", + "-t", + help="Minimum coverage percentage (exit 1 if below).", + ), + ci: bool = typer.Option( + False, + "--ci", + help="CI mode: exit 1 if coverage below 80%% (shorthand for --threshold 80).", + ), + save: bool = typer.Option( + True, + "--save/--no-save", + help="Save report to .chunks/coverage.json.", + ), + show_all: bool = typer.Option( + False, + "--all", + "-a", + help="Show all items (documented and undocumented).", + ), +) -> None: + """Analyze documentation coverage for your codebase. + + Scans your codebase for routes, components, and models, then checks + which items are mentioned in your documentation. + + Examples: + aidocs coverage # Show coverage summary + aidocs coverage --format json # Machine-readable output + aidocs coverage --ci # Exit 1 if below 80% + aidocs coverage --threshold 70 # Custom threshold + aidocs coverage -c ./src # Specify codebase path + aidocs coverage --all # Show all items + """ + import json as json_module + + target_docs = Path(docs_dir) + + if not target_docs.exists(): + console.print(f"[red]Error: Documentation directory not found: {docs_dir}[/red]") + raise typer.Exit(1) + + if not target_docs.is_dir(): + console.print(f"[red]Error: Not a directory: {docs_dir}[/red]") + raise typer.Exit(1) + + # Determine codebase directory + if codebase: + codebase_dir = Path(codebase) + else: + # Default: parent of docs dir (or current dir if docs is at root) + codebase_dir = target_docs.parent + if codebase_dir == target_docs: + codebase_dir = Path.cwd() + + if not codebase_dir.exists(): + console.print(f"[red]Error: Codebase directory not found: {codebase_dir}[/red]") + raise typer.Exit(1) + + # Set threshold for CI mode + effective_threshold = threshold + if ci and threshold is None: + effective_threshold = 80.0 + + # JSON format - minimal output + if format_output == "json": + def on_status(msg: str) -> None: + pass # Suppress status messages for JSON output + + try: + report = analyze_coverage(codebase_dir, target_docs, on_status=on_status) + + if save: + save_coverage_report(report, target_docs) + + console.print(json_module.dumps(report.to_dict(), indent=2)) + + # Check threshold + if effective_threshold is not None: + if report.overall_coverage < effective_threshold: + raise typer.Exit(1) + + except typer.Exit: + raise + except Exception as e: + error_output = {"success": False, "error": str(e)} + console.print(json_module.dumps(error_output)) + raise typer.Exit(1) + + return + + # CSV format + if format_output == "csv": + import csv + import io + + def on_status(msg: str) -> None: + pass + + try: + report = analyze_coverage(codebase_dir, target_docs, on_status=on_status) + + if save: + save_coverage_report(report, target_docs) + + # Use csv module for proper RFC 4180 escaping + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow(["category", "name", "file", "line", "documented"]) + + for cat_name, category in report.categories.items(): + for item in category.items: + doc_status = "yes" if item.documented else "no" + writer.writerow([cat_name, item.name, item.file_path, item.line_number, doc_status]) + + console.print(output.getvalue().rstrip()) + + # Check threshold + if effective_threshold is not None: + if report.overall_coverage < effective_threshold: + raise typer.Exit(1) + + except typer.Exit: + raise + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + return + + # Summary format (default) - rich output + console.print("[blue]Analyzing documentation coverage...[/blue]") + console.print() + + def on_status(msg: str) -> None: + console.print(f" [dim]{msg}[/dim]") + + try: + report = analyze_coverage(codebase_dir, target_docs, on_status=on_status) + console.print() + + if save: + report_path = save_coverage_report(report, target_docs) + + # Check if we found anything + if not report.categories: + console.print(Panel.fit( + "[yellow]No documentable items found in codebase[/yellow]\n\n" + f"Searched: {codebase_dir}\n\n" + "[dim]Ensure the codebase path is correct and contains\n" + "routes, components, or models.[/dim]", + title="Coverage Analysis", + border_style="yellow", + )) + return + + # Build summary output + summary_lines = [] + overall_color = _get_coverage_color(report.overall_coverage) + + for cat_name, category in report.categories.items(): + color = _get_coverage_color(category.coverage_percent) + bar = _render_progress_bar(category.coverage_percent) + percent = f"{category.coverage_percent:.0f}%" + ratio = f"{category.documented}/{category.total}" + + # Pad category name for alignment + display_name = category.name + ":" + summary_lines.append( + f" [{color}]{display_name:<12}[/{color}] {ratio:>6} ({percent:>4}) {bar}" + ) + + summary_text = "\n".join(summary_lines) + + # Overall coverage line + overall_bar = _render_progress_bar(report.overall_coverage) + overall_text = ( + f"\n [bold]Overall:[/bold] " + f"{report.total_documented}/{report.total_items} " + f"([{overall_color}]{report.overall_coverage:.0f}%[/{overall_color}]) {overall_bar}" + ) + + console.print(Panel.fit( + f"[bold]Documentation Coverage Report[/bold]\n" + f"{'─' * 45}\n\n" + f"{summary_text}\n" + f"{overall_text}", + border_style=overall_color, + )) + + # Show undocumented items + has_undocumented = any(cat.undocumented for cat in report.categories.values()) + + if has_undocumented: + console.print() + console.print("[bold]Missing documentation:[/bold]") + + for cat_name, category in report.categories.items(): + if category.undocumented: + console.print(f"\n [dim]{category.name}:[/dim]") + # Show up to 10 items per category + items_to_show = category.undocumented[:10] + for item in items_to_show: + console.print(f" [red]✗[/red] {item.name}") + console.print(f" [dim]{item.file_path}:{item.line_number}[/dim]") + + remaining = len(category.undocumented) - 10 + if remaining > 0: + console.print(f" [dim]... and {remaining} more[/dim]") + + # Show all items if requested + if show_all: + console.print() + console.print("[bold]All items:[/bold]") + + for cat_name, category in report.categories.items(): + console.print(f"\n [dim]{category.name}:[/dim]") + for item in category.items: + icon = "[green]✓[/green]" if item.documented else "[red]✗[/red]" + console.print(f" {icon} {item.name}") + console.print(f" [dim]{item.file_path}:{item.line_number}[/dim]") + + # Show where report was saved + if save: + console.print() + console.print(f"[dim]Report saved to: {report_path}[/dim]") + + # Check threshold + if effective_threshold is not None: + console.print() + if report.overall_coverage >= effective_threshold: + console.print( + f"[green]✓ Coverage {report.overall_coverage:.1f}% " + f"meets threshold of {effective_threshold}%[/green]" + ) + else: + console.print( + f"[red]✗ Coverage {report.overall_coverage:.1f}% " + f"is below threshold of {effective_threshold}%[/red]" + ) + raise typer.Exit(1) + + except typer.Exit: + raise + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + if __name__ == "__main__": app() diff --git a/src/aidocs_cli/coverage.py b/src/aidocs_cli/coverage.py new file mode 100644 index 0000000..bf001ac --- /dev/null +++ b/src/aidocs_cli/coverage.py @@ -0,0 +1,787 @@ +"""Documentation coverage analysis for codebases.""" + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional, Callable + + +@dataclass +class CoverageItem: + """Represents a documentable item in the codebase.""" + category: str # routes, components, models, functions, etc. + name: str + file_path: str + line_number: int + documented: bool = False + doc_file: Optional[str] = None + + +@dataclass +class CoverageCategory: + """Coverage statistics for a category.""" + name: str + total: int = 0 + documented: int = 0 + items: list[CoverageItem] = field(default_factory=list) + + @property + def coverage_percent(self) -> float: + if self.total == 0: + return 100.0 + return (self.documented / self.total) * 100 + + @property + def undocumented(self) -> list[CoverageItem]: + return [item for item in self.items if not item.documented] + + +@dataclass +class CoverageReport: + """Complete coverage report.""" + categories: dict[str, CoverageCategory] = field(default_factory=dict) + analyzed_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + codebase_dir: str = "" + docs_dir: str = "" + + @property + def total_items(self) -> int: + return sum(cat.total for cat in self.categories.values()) + + @property + def total_documented(self) -> int: + return sum(cat.documented for cat in self.categories.values()) + + @property + def overall_coverage(self) -> float: + if self.total_items == 0: + return 100.0 + return (self.total_documented / self.total_items) * 100 + + def to_dict(self) -> dict: + """Convert to dictionary for JSON export.""" + return { + "analyzed_at": self.analyzed_at, + "codebase_dir": self.codebase_dir, + "docs_dir": self.docs_dir, + "summary": { + "total_items": self.total_items, + "documented": self.total_documented, + "coverage_percent": round(self.overall_coverage, 1), + }, + "categories": { + name: { + "total": cat.total, + "documented": cat.documented, + "coverage_percent": round(cat.coverage_percent, 1), + "undocumented": [ + { + "name": item.name, + "file": item.file_path, + "line": item.line_number, + } + for item in cat.undocumented + ] + } + for name, cat in self.categories.items() + } + } + + +# === Route Detection Patterns === + +# Next.js App Router patterns +NEXTJS_APP_ROUTE_PATTERNS = [ + # route.ts/js handlers + (r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)", "method"), + # page.tsx exports + (r"export\s+default\s+(?:async\s+)?function\s+(\w+Page|\w+)", "page"), +] + +# Next.js Pages Router patterns +NEXTJS_PAGES_ROUTE_PATTERNS = [ + (r"export\s+default\s+(?:async\s+)?function\s+(\w+)", "page"), + (r"export\s+(?:const|async\s+function)\s+(getServerSideProps|getStaticProps)", "data"), +] + +# Express/Node.js patterns +EXPRESS_ROUTE_PATTERNS = [ + (r"(?:app|router)\.(get|post|put|patch|delete|all)\s*\(\s*['\"`]([^'\"`]+)['\"`]", "route"), + (r"@(Get|Post|Put|Patch|Delete|All)\s*\(\s*['\"`]?([^'\"`\)]*)['\"`]?\s*\)", "decorator"), +] + +# Python FastAPI/Flask patterns +PYTHON_ROUTE_PATTERNS = [ + (r"@(?:app|router|api)\.(get|post|put|patch|delete)\s*\(\s*['\"`]([^'\"`]+)['\"`]", "route"), + (r"@(?:app|router)\.route\s*\(\s*['\"`]([^'\"`]+)['\"`]", "route"), +] + +# Laravel patterns +LARAVEL_ROUTE_PATTERNS = [ + (r"Route::(get|post|put|patch|delete|any)\s*\(\s*['\"`]([^'\"`]+)['\"`]", "route"), +] + + +# === Component Detection Patterns === + +REACT_COMPONENT_PATTERNS = [ + # Function components + (r"export\s+(?:default\s+)?function\s+([A-Z]\w+)", "function"), + # Arrow function components + (r"export\s+(?:default\s+)?(?:const|let)\s+([A-Z]\w+)\s*[=:]\s*(?:\([^)]*\)|[^=])*\s*=>", "arrow"), + # Class components + (r"export\s+(?:default\s+)?class\s+([A-Z]\w+)\s+extends\s+(?:React\.)?(?:Component|PureComponent)", "class"), +] + +VUE_COMPONENT_PATTERNS = [ + (r"export\s+default\s+(?:defineComponent\s*\(\s*)?{", "options"), + (r"]*setup[^>]*>", "setup"), +] + +SVELTE_COMPONENT_PATTERNS = [ + (r"]*>", "script"), +] + + +# === Model Detection Patterns === + +# TypeScript/JavaScript +TS_MODEL_PATTERNS = [ + (r"(?:export\s+)?(?:interface|type)\s+([A-Z]\w+)", "type"), + (r"(?:export\s+)?class\s+([A-Z]\w+)(?:\s+extends|\s+implements|\s*{)", "class"), +] + +# Python +PYTHON_MODEL_PATTERNS = [ + (r"class\s+([A-Z]\w+)\s*\([^)]*(?:Model|Base|Schema|BaseModel)[^)]*\)", "model"), + (r"class\s+([A-Z]\w+)\s*\(.*\):", "class"), +] + +# Prisma +PRISMA_MODEL_PATTERNS = [ + (r"model\s+([A-Z]\w+)\s*{", "model"), +] + + +def detect_framework(codebase_dir: Path) -> dict[str, bool]: + """Detect which frameworks are used in the codebase.""" + frameworks = { + "nextjs": False, + "react": False, + "vue": False, + "svelte": False, + "express": False, + "fastapi": False, + "flask": False, + "laravel": False, + "prisma": False, + } + + # Check package.json + pkg_json = codebase_dir / "package.json" + if pkg_json.exists(): + try: + pkg = json.loads(pkg_json.read_text()) + deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + + if "next" in deps: + frameworks["nextjs"] = True + frameworks["react"] = True + elif "react" in deps: + frameworks["react"] = True + if "vue" in deps: + frameworks["vue"] = True + if "svelte" in deps: + frameworks["svelte"] = True + if "express" in deps: + frameworks["express"] = True + except Exception: + pass + + # Check pyproject.toml or requirements.txt + pyproject = codebase_dir / "pyproject.toml" + requirements = codebase_dir / "requirements.txt" + + py_deps = "" + if pyproject.exists(): + py_deps = pyproject.read_text().lower() + if requirements.exists(): + py_deps += requirements.read_text().lower() + + if "fastapi" in py_deps: + frameworks["fastapi"] = True + if "flask" in py_deps: + frameworks["flask"] = True + + # Check composer.json for Laravel + composer = codebase_dir / "composer.json" + if composer.exists(): + try: + comp = json.loads(composer.read_text()) + if "laravel/framework" in comp.get("require", {}): + frameworks["laravel"] = True + except Exception: + pass + + # Check for Prisma + if (codebase_dir / "prisma" / "schema.prisma").exists(): + frameworks["prisma"] = True + + return frameworks + + +def find_routes(codebase_dir: Path, frameworks: dict[str, bool]) -> list[CoverageItem]: + """Find all routes/endpoints in the codebase.""" + routes = [] + + # Next.js App Router + if frameworks.get("nextjs"): + app_dir = codebase_dir / "app" + if not app_dir.exists(): + app_dir = codebase_dir / "src" / "app" + + if app_dir.exists(): + # Find route.ts/js files + route_extensions = ["ts", "tsx", "js", "jsx"] + for ext in route_extensions: + for route_file in app_dir.rglob(f"route.{ext}"): + try: + content = route_file.read_text(encoding="utf-8") + except Exception: + continue + + rel_path = route_file.relative_to(app_dir) + + # Extract route path from file location (parent excludes filename) + route_path = "/" + str(rel_path.parent).replace("\\", "/") + route_path = re.sub(r"\([^)]+\)/", "", route_path) # Remove route groups + # Normalize root path + if route_path in ("", "/.", "/."): + route_path = "/" + + for pattern, _ in NEXTJS_APP_ROUTE_PATTERNS: + for match in re.finditer(pattern, content): + method = match.group(1) + line_num = content[:match.start()].count("\n") + 1 + routes.append(CoverageItem( + category="routes", + name=f"{method} {route_path}", + file_path=str(route_file.relative_to(codebase_dir)), + line_number=line_num, + )) + + # Find page.tsx files (GET routes implicitly) + for ext in route_extensions: + for page_file in app_dir.rglob(f"page.{ext}"): + rel_path = page_file.relative_to(app_dir) + # Extract route path from file location (parent excludes filename) + route_path = "/" + str(rel_path.parent).replace("\\", "/") + route_path = re.sub(r"\([^)]+\)/", "", route_path) # Remove route groups + # Normalize root path + if route_path in ("", "/", "/."): + route_path = "/" + + routes.append(CoverageItem( + category="routes", + name=f"PAGE {route_path}", + file_path=str(page_file.relative_to(codebase_dir)), + line_number=1, + )) + + # Express routes + if frameworks.get("express"): + for ext in ["ts", "js"]: + for file in codebase_dir.rglob(f"*.{ext}"): + if "node_modules" in str(file): + continue + try: + content = file.read_text() + for pattern, _ in EXPRESS_ROUTE_PATTERNS: + for match in re.finditer(pattern, content): + method = match.group(1).upper() + path = match.group(2) + line_num = content[:match.start()].count("\n") + 1 + routes.append(CoverageItem( + category="routes", + name=f"{method} {path}", + file_path=str(file.relative_to(codebase_dir)), + line_number=line_num, + )) + except Exception: + continue + + # Python routes (FastAPI/Flask) + if frameworks.get("fastapi") or frameworks.get("flask"): + for file in codebase_dir.rglob("*.py"): + if "__pycache__" in str(file) or ".venv" in str(file): + continue + try: + content = file.read_text() + for pattern, _ in PYTHON_ROUTE_PATTERNS: + for match in re.finditer(pattern, content): + if len(match.groups()) == 2: + method = match.group(1).upper() + path = match.group(2) + else: + method = "ANY" + path = match.group(1) + line_num = content[:match.start()].count("\n") + 1 + routes.append(CoverageItem( + category="routes", + name=f"{method} {path}", + file_path=str(file.relative_to(codebase_dir)), + line_number=line_num, + )) + except Exception: + continue + + # Laravel routes + if frameworks.get("laravel"): + routes_dir = codebase_dir / "routes" + if routes_dir.exists(): + for file in routes_dir.rglob("*.php"): + try: + content = file.read_text() + for pattern, _ in LARAVEL_ROUTE_PATTERNS: + for match in re.finditer(pattern, content): + method = match.group(1).upper() + path = match.group(2) + line_num = content[:match.start()].count("\n") + 1 + routes.append(CoverageItem( + category="routes", + name=f"{method} {path}", + file_path=str(file.relative_to(codebase_dir)), + line_number=line_num, + )) + except Exception: + continue + + return routes + + +def find_components(codebase_dir: Path, frameworks: dict[str, bool]) -> list[CoverageItem]: + """Find all UI components in the codebase.""" + components = [] + + # Directories to search for components + component_dirs = [ + "components", "src/components", "app/components", + "lib/components", "src/lib/components", + "ui", "src/ui", + ] + + search_dirs = [] + for dir_name in component_dirs: + comp_dir = codebase_dir / dir_name + if comp_dir.exists(): + search_dirs.append(comp_dir) + + # If no component directories found, search more broadly + if not search_dirs: + search_dirs = [codebase_dir] + + # React/Next.js components + if frameworks.get("react") or frameworks.get("nextjs"): + for search_dir in search_dirs: + for ext in ["tsx", "jsx", "ts", "js"]: + for file in search_dir.rglob(f"*.{ext}"): + if "node_modules" in str(file) or ".next" in str(file): + continue + + # Skip test files + if ".test." in str(file) or ".spec." in str(file) or "__tests__" in str(file): + continue + + try: + content = file.read_text() + for pattern, comp_type in REACT_COMPONENT_PATTERNS: + for match in re.finditer(pattern, content): + name = match.group(1) + line_num = content[:match.start()].count("\n") + 1 + components.append(CoverageItem( + category="components", + name=name, + file_path=str(file.relative_to(codebase_dir)), + line_number=line_num, + )) + except Exception: + continue + + # Vue components + if frameworks.get("vue"): + for search_dir in search_dirs: + for file in search_dir.rglob("*.vue"): + if "node_modules" in str(file): + continue + components.append(CoverageItem( + category="components", + name=file.stem, + file_path=str(file.relative_to(codebase_dir)), + line_number=1, + )) + + # Svelte components + if frameworks.get("svelte"): + for search_dir in search_dirs: + for file in search_dir.rglob("*.svelte"): + if "node_modules" in str(file): + continue + components.append(CoverageItem( + category="components", + name=file.stem, + file_path=str(file.relative_to(codebase_dir)), + line_number=1, + )) + + return components + + +def find_models(codebase_dir: Path, frameworks: dict[str, bool]) -> list[CoverageItem]: + """Find all data models in the codebase.""" + models = [] + + # Prisma models + if frameworks.get("prisma"): + schema_path = codebase_dir / "prisma" / "schema.prisma" + if schema_path.exists(): + content = schema_path.read_text() + for pattern, _ in PRISMA_MODEL_PATTERNS: + for match in re.finditer(pattern, content): + name = match.group(1) + line_num = content[:match.start()].count("\n") + 1 + models.append(CoverageItem( + category="models", + name=name, + file_path="prisma/schema.prisma", + line_number=line_num, + )) + + # TypeScript types/interfaces + type_dirs = [ + "types", "src/types", "lib/types", + "models", "src/models", "lib/models", + "schemas", "src/schemas", + ] + + for dir_name in type_dirs: + type_dir = codebase_dir / dir_name + if type_dir.exists(): + for file in type_dir.rglob("*.ts"): + if "node_modules" in str(file): + continue + try: + content = file.read_text() + for pattern, _ in TS_MODEL_PATTERNS: + for match in re.finditer(pattern, content): + name = match.group(1) + # Skip common utility types + if name in ["Props", "State", "Context", "Config"]: + continue + line_num = content[:match.start()].count("\n") + 1 + models.append(CoverageItem( + category="models", + name=name, + file_path=str(file.relative_to(codebase_dir)), + line_number=line_num, + )) + except Exception: + continue + + # Python models + if frameworks.get("fastapi") or frameworks.get("flask"): + model_dirs = ["models", "src/models", "app/models", "schemas", "src/schemas"] + for dir_name in model_dirs: + model_dir = codebase_dir / dir_name + if model_dir.exists(): + for file in model_dir.rglob("*.py"): + if "__pycache__" in str(file): + continue + try: + content = file.read_text() + for pattern, _ in PYTHON_MODEL_PATTERNS: + for match in re.finditer(pattern, content): + name = match.group(1) + line_num = content[:match.start()].count("\n") + 1 + models.append(CoverageItem( + category="models", + name=name, + file_path=str(file.relative_to(codebase_dir)), + line_number=line_num, + )) + except Exception: + continue + + return models + + +def extract_doc_references(docs_dir: Path) -> set[str]: + """Extract all codebase references from documentation. + + Looks for patterns like: + - Route mentions: GET /api/users, POST /users + - Component mentions: PaymentForm, UserDashboard + - File path mentions: components/Button.tsx + - Code blocks with file references + """ + references = set() + + # Common English words to skip (not component names) + SKIP_WORDS = { + "The", "This", "That", "These", "Those", "There", "Here", + "What", "When", "Where", "Which", "Who", "Why", "How", + "And", "But", "For", "Not", "With", "You", "Your", + "All", "Any", "Can", "May", "Will", "Should", "Would", "Could", + "API", "URL", "HTTP", "HTML", "CSS", "JSON", "XML", "SQL", + "TODO", "FIXME", "NOTE", "WARNING", "ERROR", "INFO", "DEBUG", + "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", + "New", "Old", "See", "Use", "Run", "Set", "Add", "Try", + "Example", "Examples", "Documentation", "Overview", "Introduction", + "Setup", "Install", "Usage", "Configuration", "Settings", + "Returns", "Creates", "Updates", "Deletes", "Displays", + } + + if not docs_dir.exists(): + return references + + for md_file in docs_dir.rglob("*.md"): + # Skip hidden dirs and chunks + parts = md_file.relative_to(docs_dir).parts + if any(p.startswith(".") for p in parts): + continue + + try: + content = md_file.read_text() + + # Find route mentions + route_patterns = [ + r"(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(/[^\s\)`]+)", + r"`(GET|POST|PUT|PATCH|DELETE)\s+(/[^`]+)`", + ] + for pattern in route_patterns: + for match in re.finditer(pattern, content, re.IGNORECASE): + method = match.group(1).upper() + path = match.group(2).rstrip(")").rstrip("`") + references.add(f"{method} {path}") + + # Find page mentions in Next.js style + page_patterns = [ + r"(?:PAGE|page|route)\s+[`'\"]?(/[^\s`'\"]+)[`'\"]?", + ] + for pattern in page_patterns: + for match in re.finditer(pattern, content): + references.add(f"PAGE {match.group(1)}") + + # Find JSX component references: or + jsx_pattern = r"<([A-Z][a-zA-Z0-9]+)(?:\s|/|>)" + for match in re.finditer(jsx_pattern, content): + name = match.group(1) + if name not in SKIP_WORDS: + references.add(name) + + # Find backtick component references: `ComponentName` + backtick_pattern = r"`([A-Z][a-zA-Z0-9]+)`" + for match in re.finditer(backtick_pattern, content): + name = match.group(1) + if name not in SKIP_WORDS: + references.add(name) + + # Find PascalCase compound names (2+ capital letters = likely component) + # e.g., UserProfile, PaymentForm, DataTable + pascal_pattern = r"\b([A-Z][a-z]+[A-Z][a-zA-Z0-9]*)\b" + for match in re.finditer(pascal_pattern, content): + name = match.group(1) + if name not in SKIP_WORDS: + references.add(name) + + # Find explicit component mentions: "the UserProfile component" + explicit_pattern = r"(?:the\s+)?([A-Z][a-zA-Z0-9]+)\s+(?:component|widget|form|button|modal|dialog)" + for match in re.finditer(explicit_pattern, content, re.IGNORECASE): + name = match.group(1) + if name not in SKIP_WORDS: + references.add(name) + + # Find section headers that name components: "## Dashboard" or "### UserProfile" + header_pattern = r"^#{1,6}\s+([A-Z][a-zA-Z0-9]+)(?:\s+Component)?(?:\s|$)" + for match in re.finditer(header_pattern, content, re.MULTILINE): + name = match.group(1) + if name not in SKIP_WORDS: + references.add(name) + + # Find model/type mentions + model_patterns = [ + r"(?:model|type|interface|schema|entity)\s+[`'\"]?([A-Z][a-zA-Z0-9]+)[`'\"]?", + ] + for pattern in model_patterns: + for match in re.finditer(pattern, content, re.IGNORECASE): + name = match.group(1) + if name not in SKIP_WORDS: + references.add(name) + + # Find file path mentions + file_pattern = r"(?:^|\s|`)[a-zA-Z0-9_\-/]+\.(tsx?|jsx?|vue|svelte|py|php)(?:\s|`|$)" + for match in re.finditer(file_pattern, content): + # Extract just the filename for matching + file_path = match.group(0).strip().strip("`") + references.add(file_path) + + except Exception: + continue + + return references + + +def match_documentation( + items: list[CoverageItem], + doc_references: set[str], +) -> list[CoverageItem]: + """Match codebase items against documentation references.""" + for item in items: + # Normalize item name for matching + item_name = item.name + + # Check direct match + if item_name in doc_references: + item.documented = True + continue + + # Check partial match for routes (ignore dynamic segments) + if item.category == "routes": + # Normalize route for comparison + normalized = re.sub(r"\[[^\]]+\]", "*", item_name) # [id] -> * + normalized = re.sub(r":\w+", "*", normalized) # :id -> * + + for ref in doc_references: + ref_normalized = re.sub(r"\[[^\]]+\]", "*", ref) + ref_normalized = re.sub(r":\w+", "*", ref_normalized) + ref_normalized = re.sub(r"\{[^}]+\}", "*", ref_normalized) # {id} -> * + + if normalized == ref_normalized: + item.documented = True + break + + # Check component name match (case-insensitive partial) + if item.category == "components": + name_lower = item.name.lower() + for ref in doc_references: + if ref.lower() == name_lower or name_lower in ref.lower(): + item.documented = True + break + + # Check model name match + if item.category == "models": + for ref in doc_references: + if item.name.lower() == ref.lower(): + item.documented = True + break + + return items + + +def analyze_coverage( + codebase_dir: Path, + docs_dir: Path, + on_status: Optional[Callable[[str], None]] = None, +) -> CoverageReport: + """Analyze documentation coverage for a codebase. + + Args: + codebase_dir: Path to the codebase root + docs_dir: Path to the documentation directory + on_status: Optional callback for status messages + + Returns: + CoverageReport with detailed coverage analysis + """ + report = CoverageReport( + codebase_dir=str(codebase_dir), + docs_dir=str(docs_dir), + ) + + def status(msg: str) -> None: + if on_status: + on_status(msg) + + # Detect frameworks + status("Detecting frameworks...") + frameworks = detect_framework(codebase_dir) + detected = [name for name, found in frameworks.items() if found] + if detected: + status(f"Detected: {', '.join(detected)}") + else: + status("No specific frameworks detected, using generic analysis") + + # Extract documentation references + status("Scanning documentation...") + doc_references = extract_doc_references(docs_dir) + status(f"Found {len(doc_references)} documentation references") + + # Find routes + status("Scanning for routes/endpoints...") + routes = find_routes(codebase_dir, frameworks) + routes = match_documentation(routes, doc_references) + + if routes: + routes_cat = CoverageCategory(name="Routes", items=routes) + routes_cat.total = len(routes) + routes_cat.documented = sum(1 for r in routes if r.documented) + report.categories["routes"] = routes_cat + status(f"Found {len(routes)} routes") + + # Find components + status("Scanning for components...") + components = find_components(codebase_dir, frameworks) + components = match_documentation(components, doc_references) + + if components: + comp_cat = CoverageCategory(name="Components", items=components) + comp_cat.total = len(components) + comp_cat.documented = sum(1 for c in components if c.documented) + report.categories["components"] = comp_cat + status(f"Found {len(components)} components") + + # Find models + status("Scanning for models/types...") + models = find_models(codebase_dir, frameworks) + models = match_documentation(models, doc_references) + + if models: + models_cat = CoverageCategory(name="Models", items=models) + models_cat.total = len(models) + models_cat.documented = sum(1 for m in models if m.documented) + report.categories["models"] = models_cat + status(f"Found {len(models)} models/types") + + return report + + +def save_coverage_report(report: CoverageReport, docs_dir: Path) -> Path: + """Save coverage report to .chunks/coverage.json.""" + chunks_dir = docs_dir / ".chunks" + chunks_dir.mkdir(parents=True, exist_ok=True) + + report_path = chunks_dir / "coverage.json" + report_path.write_text( + json.dumps(report.to_dict(), indent=2), + encoding="utf-8", + ) + + return report_path + + +def load_coverage_report(docs_dir: Path) -> Optional[dict]: + """Load previous coverage report if it exists. + + Returns the raw dict from the JSON file. Use this for comparing + coverage between runs or displaying historical data. + """ + report_path = docs_dir / ".chunks" / "coverage.json" + + if not report_path.exists(): + return None + + try: + return json.loads(report_path.read_text(encoding="utf-8")) + except Exception: + return None diff --git a/uv.lock b/uv.lock index 7a805be..9767f3e 100644 --- a/uv.lock +++ b/uv.lock @@ -4,16 +4,18 @@ requires-python = ">=3.11" [[package]] name = "aidocs" -version = "0.15.5" +version = "0.18.0" source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "mcp" }, { name = "mkdocs" }, { name = "mkdocs-material" }, + { name = "python-dotenv" }, { name = "pyyaml" }, { name = "rich" }, { name = "typer" }, + { name = "watchdog" }, ] [package.metadata] @@ -22,9 +24,11 @@ requires-dist = [ { name = "mcp", specifier = ">=1.0.0" }, { name = "mkdocs", specifier = ">=1.6.0" }, { name = "mkdocs-material", specifier = ">=9.5.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "typer", specifier = ">=0.9.0" }, + { name = "watchdog", specifier = ">=4.0.0" }, ] [[package]]