diff --git a/README.md b/README.md index 471d50e6..b90f8c93 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ > **Before an AI writes a new class/id/function, CodeLens must be checked. This is not optional.** -CodeLens is an AI-native code intelligence platform that gives AI agents **full visibility** into a codebase before they write any code. It prevents collision, overwrite of existing logic, security vulnerabilities, and dead code through 67 CLI commands, an MCP server with 65 tools (50 static + 15 dynamic), AST-based taint analysis, live CVE/OSV scanning, a plugin system with OWASP Top 10 + Compliance rule packs, a true graph data model (nodes + edges) for structural code queries, and token-efficient `--format compact` output for high-volume agent workflows (issue #17). +CodeLens is an AI-native code intelligence platform that gives AI agents **full visibility** into a codebase before they write any code. It prevents collision, overwrite of existing logic, security vulnerabilities, and dead code through 68 CLI commands, an MCP server with 66 tools (54 static + 12 dynamic), AST-based taint analysis, live CVE/OSV scanning, a plugin system with OWASP Top 10 + Compliance rule packs, a true graph data model (nodes + edges) for structural code queries, and token-efficient `--format compact` output for high-volume agent workflows (issue #17). ## Features -- **67 CLI Commands** — From basic scan/query to AST taint analysis, CVE scanning, plugin management, auto-fix, dashboards, CI/CD quality gates, and `graph-schema` for cheap graph-shape introspection -- **MCP Server (65 Tools)** — Native AI agent integration via Model Context Protocol (JSON-RPC over stdio), 50 statically-defined tools + 15 dynamically discovered, every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`) +- **68 CLI Commands** — From basic scan/query to AST taint analysis, CVE scanning, plugin management, auto-fix, dashboards, CI/CD quality gates, and `graph-schema` for cheap graph-shape introspection +- **MCP Server (66 Tools)** — Native AI agent integration via Model Context Protocol (JSON-RPC over stdio), 54 statically-defined tools + 12 dynamically discovered, every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`) - **Token-Efficient Compact Output (v8.2, issue #17)** — `--format compact` produces single-char-key JSON with abbreviated types, omitted null fields, and relative paths — ~50% smaller than `json` on real trace output. Combined with `--limit`/`--offset` pagination, 5 structural queries now cost <5k tokens (down from 30-80k) - **AST Taint Engine** — Tree-sitter based taint analysis with return-value propagation, scope hierarchy, and branch condition refinement - **Live CVE/OSV Scanning** — Real-time vulnerability data from OSV.dev API with SQLite cache, 9 ecosystems (PyPI, npm, crates.io, Go, Maven, NuGet, RubyGems, Pub, Hex) @@ -225,8 +225,8 @@ codelens/ │ ├── changelog.md # Older changelog (per-version highlights) │ └── agent-integration.md # AI agent integration guide ├── scripts/ -│ ├── codelens.py # CLI entry point (67 commands registered) -│ ├── mcp_server.py # MCP JSON-RPC server (65 tools) +│ ├── codelens.py # CLI entry point (68 commands registered) +│ ├── mcp_server.py # MCP JSON-RPC server (66 tools) │ ├── registry.py # Registry read/write/build │ ├── persistent_registry.py # SQLite persistent storage (WAL mode) │ ├── base_parser.py # Base tree-sitter parser diff --git a/SKILL-QUICK.md b/SKILL-QUICK.md index a3afe485..5764ffd7 100755 --- a/SKILL-QUICK.md +++ b/SKILL-QUICK.md @@ -114,7 +114,7 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co | "Cross-file taint" | `dataflow` | `taint` (taint is single-file, AST-deep) | | "Auto-fix issues" | `fix` | `check` (check just gates, doesn't fix) | -## All 67 Commands +## All 68 Commands ### Setup & Lifecycle (8+) `init` · `scan [--incremental] [--max-files N] [--full]` · `registry-validate` · `detect` · `watch [--debounce SECS] [--git-mode] [--interval SECS]` · `git-status` · `migrate` · `serve` · `lsp-status` (issue #33: `codelens --lsp-status` top-level flag is an alias of `codelens lsp-status` — both delegate to `hybrid_engine.get_lsp_status()` and return the identical payload) @@ -146,9 +146,9 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co ### Tooling (1) `plugin ` -**Total: 67 commands** (auto-registered via `commands/__init__.py`; rerun `python3 scripts/sync_command_count.py --apply` after adding/removing a command) +**Total: 68 commands** (auto-registered via `commands/__init__.py`; rerun `python3 scripts/sync_command_count.py --apply` after adding/removing a command) -## MCP Server (65 Tools) +## MCP Server (66 Tools) Start the MCP server for AI agent integration: @@ -156,9 +156,9 @@ Start the MCP server for AI agent integration: python3 scripts/codelens.py serve ``` -Exposes 65 tools as `codelens_` (e.g., `codelens_query`, `codelens_taint`, `codelens_graph_schema`, `codelens_architecture`, `codelens_resolve_types`, `codelens_git_status`): +Exposes 66 tools as `codelens_` (e.g., `codelens_query`, `codelens_taint`, `codelens_graph_schema`, `codelens_architecture`, `codelens_resolve_types`, `codelens_git_status`): - 50 statically-defined tools (full JSON schemas in `mcp_server.py`) -- 15 dynamically-discovered tools (auto-discovered from `COMMAND_REGISTRY`; long-running `watch` and `serve` are excluded) +- 12 dynamically-discovered tools (auto-discovered from `COMMAND_REGISTRY`; long-running `watch` and `serve` are excluded) - Every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`). Use `format: "compact"` for token-efficient responses (~50% smaller than `json`). - `watch` and `serve` itself are excluded (long-running) diff --git a/SKILL.md b/SKILL.md index e914b31c..bd91f902 100755 --- a/SKILL.md +++ b/SKILL.md @@ -1,10 +1,10 @@ --- name: codelens description: > - CodeLens — AI-Native Code Intelligence. 67 commands for AI-powered code analysis, + CodeLens — AI-Native Code Intelligence. 68 commands for AI-powered code analysis, security auditing, quality scoring, AST-based taint analysis, live CVE scanning, and pre-write safety checks. Supports 28+ languages with tree-sitter + regex - fallback parsing. MCP server exposes 65 tools for AI agent integration. + fallback parsing. MCP server exposes 66 tools for AI agent integration. For quick command reference with validated output schemas, see SKILL-QUICK.md. For version history, see CHANGELOG.md. --- diff --git a/pyproject.toml b/pyproject.toml index 0a879294..c7d147ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "codelens" version = "8.2.0" -description = "Live Codebase Reference Intelligence — 67 commands for AI-powered code analysis, security auditing, and quality scoring" +description = "Live Codebase Reference Intelligence — 68 commands for AI-powered code analysis, security auditing, and quality scoring" readme = "README.md" license = {text = "MIT"} requires-python = ">=3.8" diff --git a/scripts/codelens.py b/scripts/codelens.py index 3642c49a..fed1ffd3 100755 --- a/scripts/codelens.py +++ b/scripts/codelens.py @@ -877,9 +877,14 @@ def main(): existing_dests.add(action.dest) _existing_subparser_args[cmd_name] = existing_dests - # Add --format to each subparser (safe: no command defines its own --format) - sub.add_argument("--format", "-f", choices=["json", "markdown", "ai", "sarif", "compact"], default=None, - help="Output format: json, markdown, ai (normalized schema), sarif (GitHub/VS Code), or compact (token-efficient single-char keys)") + # Add --format to each subparser (UNLESS the command defines its own). + # Issue #64: ``doctor`` defines its own ``--format`` with choices + # ``["text", "json"]`` because its default human-readable output is + # a text table, not JSON. Skipping the global add here lets that + # command-specific format work without an argparse conflict. + if "format" not in existing_dests: + sub.add_argument("--format", "-f", choices=["json", "markdown", "ai", "sarif", "compact"], default=None, + help="Output format: json, markdown, ai (normalized schema), sarif (GitHub/VS Code), or compact (token-efficient single-char keys)") # Add AI-optimized flags to subparser ONLY if the command doesn't already have them if "top" not in existing_dests: @@ -1272,12 +1277,26 @@ def main(): logger.warning(f"Suppression processing failed: {e}", exc_info=True) # ─── Format and print output ── - print(format_output(result, args.format, format_command, workspace)) - - # ─── Exit code for check command (CI quality gate) ── + # doctor (issue #64) prints its own human-readable text table when + # ``--format text`` is used, and signals via ``_doctor_printed_text`` + # so the dispatcher skips the generic JSON formatter (which would + # otherwise dump the result dict a second time). In ``--format json`` + # mode doctor does NOT print itself, and the normal formatter handles + # the JSON serialization. + if not (args.command == "doctor" and isinstance(result, dict) + and result.get("_doctor_printed_text")): + print(format_output(result, args.format, format_command, workspace)) + + # ─── Exit codes for CI-quality-gate commands ── if args.command == "check" and isinstance(result, dict): if result.get("gate") == "failed": sys.exit(1) + # doctor (issue #64): exit 0=ok / 1=warning / 2=critical. + # The exit code lets CI pipelines gate on doctor failures. + if args.command == "doctor" and isinstance(result, dict): + exit_code = result.get("exit_code", 0) + if exit_code: + sys.exit(exit_code) except FileNotFoundError as e: error_result = { diff --git a/scripts/commands/doctor.py b/scripts/commands/doctor.py new file mode 100644 index 00000000..1e09804d --- /dev/null +++ b/scripts/commands/doctor.py @@ -0,0 +1,604 @@ +"""CodeLens ``doctor`` command — environment audit + auto-fix (issue #64, Phase 1). + +What this command does +----------------------- +``codelens doctor`` audits the local environment for everything CodeLens +needs to run well, and reports a single-pass table of checks. It's the +"why doesn't this work?" debugging tool — for users, for CI, and for +the setup script (``setup.sh`` calls it at the end of install). + +Each check produces one of three statuses: + +* ``ok`` — the dependency is present and the right version +* ``warning`` — present but old / optional / degraded (exit code 1) +* ``critical``— missing or broken (exit code 2) + +``--fix`` runs ``pip install --user`` for any missing Python deps +(safe — pip is the only side effect). ``--verbose`` adds detail like +resolved versions and install paths. ``--format json`` switches the +output to a machine-readable schema for CI pipelines. + +Why not just use ``pip check``? +------------------------------- +``pip check`` only verifies dependency coherence — it doesn't know +that CodeLens needs six specific tree-sitter grammars, that +``.codelens/`` must be writable, or that the user's Python is too old. +``doctor`` is purpose-built for CodeLens's actual deployment surface. + +What is deliberately NOT checked in Phase 1 +------------------------------------------- +* Network reachability of PyPI / OSV.dev (Phase 2+ — slow, flaky in CI) +* MCP server JSON-RPC health (separate concern, lives in MCP tooling) +* Disk space (OS-level concern; doctor is about CodeLens deps) +""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import subprocess +import sys +from typing import Any, Dict, List, Optional, Tuple + +from commands import register_command + +# ─── Constants ───────────────────────────────────────────────── + +# Minimum Python version CodeLens supports (per pyproject.toml). +_MIN_PYTHON = (3, 8) + +# The six grammars setup.sh installs. Kept in sync with setup.sh — +# if setup.sh adds a grammar, this list MUST be updated too. +_EXPECTED_GRAMMARS = ( + "tree_sitter_html", + "tree_sitter_css", + "tree_sitter_javascript", + "tree_sitter_typescript", + "tree_sitter_rust", + "tree_sitter_python", +) + +# Optional Python deps. Missing these is a warning, not critical. +_OPTIONAL_DEPS: Tuple[Tuple[str, str], ...] = ( + # (import_name, pip_install_name) + ("yaml", "PyYAML"), + ("watchdog", "watchdog"), +) + +# Required Python deps. Missing these is critical. +_REQUIRED_DEPS: Tuple[Tuple[str, str], ...] = ( + ("tree_sitter", "tree-sitter"), +) + +# CLI binaries that CodeLens shells out to (optional except git). +_EXPECTED_BINARIES = ("git",) + +# Status codes returned by ``execute`` and propagated via the +# ``exit_code`` field. The CLI dispatcher in ``codelens.py`` reads +# ``exit_code`` from the result dict and propagates it to ``sys.exit``. +EXIT_OK = 0 +EXIT_WARNING = 1 +EXIT_CRITICAL = 2 + + +# ─── Individual checks ───────────────────────────────────────── + + +def _check_python_version() -> Dict[str, Any]: + """Verify Python >= 3.8.""" + major, minor = sys.version_info.major, sys.version_info.minor + current = (major, minor) + version_str = f"{major}.{minor}.{sys.version_info.micro}" + if current >= _MIN_PYTHON: + return { + "name": "python", + "status": "ok", + "found": version_str, + "required": f">= {_MIN_PYTHON[0]}.{_MIN_PYTHON[1]}", + "detail": sys.version.split()[0], + } + return { + "name": "python", + "status": "critical", + "found": version_str, + "required": f">= {_MIN_PYTHON[0]}.{_MIN_PYTHON[1]}", + "detail": "Python is too old. Install Python 3.8 or newer.", + "fixable": False, + } + + +def _check_import(name: str, pip_name: str, critical: bool) -> Dict[str, Any]: + """Check that a Python module is importable.""" + try: + mod = __import__(name) + version = getattr(mod, "__version__", None) or getattr(mod, "VERSION", None) or "unknown" + return { + "name": f"python.module.{name}", + "status": "ok", + "found": version, + "required": "present", + "detail": f"importable from {getattr(mod, '__file__', '')}", + "pip_name": pip_name, + "fixable": True, + } + except ImportError as exc: + return { + "name": f"python.module.{name}", + "status": "critical" if critical else "warning", + "found": None, + "required": "present", + "detail": f"ImportError: {exc}", + "pip_name": pip_name, + "fixable": True, + } + + +def _check_grammars() -> Dict[str, Any]: + """Check that all 6 expected tree-sitter grammars are importable.""" + missing: List[str] = [] + found: Dict[str, str] = {} + for mod_name in _EXPECTED_GRAMMARS: + try: + mod = __import__(mod_name) + # tree-sitter grammar packages expose a `language()` function + # or a `LANGUAGE` attribute. Just importing is enough for + # the doctor check — actual loading is tested by grammar_loader. + found[mod_name] = getattr(mod, "__version__", "unknown") + except ImportError: + missing.append(mod_name) + + if not missing: + return { + "name": "tree_sitter.grammars", + "status": "ok", + "found": f"{len(found)}/{len(_EXPECTED_GRAMMARS)} grammars", + "required": "all 6 grammars (html, css, js, ts, rust, python)", + "detail": found, + "pip_names": [g.replace("_", "-") for g in _EXPECTED_GRAMMARS], + "fixable": True, + } + return { + "name": "tree_sitter.grammars", + "status": "critical", + "found": f"{len(found)}/{len(_EXPECTED_GRAMMARS)} grammars", + "required": "all 6 grammars (html, css, js, ts, rust, python)", + "detail": {"missing": missing, "found": found}, + "pip_names": [g.replace("_", "-") for g in missing], + "fixable": True, + } + + +def _check_binary(name: str) -> Dict[str, Any]: + """Check that a CLI binary is on PATH.""" + path = shutil.which(name) + if path: + return { + "name": f"binary.{name}", + "status": "ok", + "found": path, + "required": "on PATH", + "detail": f"resolved to {path}", + "fixable": False, # We don't auto-install system binaries. + } + return { + "name": f"binary.{name}", + "status": "warning", + "found": None, + "required": "on PATH", + "detail": f"{name} not found on PATH", + "fixable": False, + } + + +def _check_sqlite() -> Dict[str, Any]: + """Check that the stdlib sqlite3 module works (compiles + opens :memory:).""" + try: + import sqlite3 # noqa: F401 — import side effect is the check + conn = sqlite3.connect(":memory:") + ver = sqlite3.sqlite_version + conn.close() + return { + "name": "python.module.sqlite3", + "status": "ok", + "found": ver, + "required": "present (stdlib)", + "detail": f"sqlite3 module works, SQLite library v{ver}", + "fixable": False, + } + except Exception as exc: + return { + "name": "python.module.sqlite3", + "status": "critical", + "found": None, + "required": "present (stdlib)", + "detail": f"sqlite3 broken: {exc}", + "fixable": False, + } + + +def _check_urllib() -> Dict[str, Any]: + """Check that urllib can be imported (used by vuln-scan / upgrade).""" + try: + import urllib.request # noqa: F401 + return { + "name": "python.module.urllib", + "status": "ok", + "found": "stdlib", + "required": "present (stdlib)", + "detail": "urllib.request importable", + "fixable": False, + } + except Exception as exc: + return { + "name": "python.module.urllib", + "status": "critical", + "found": None, + "required": "present (stdlib)", + "detail": f"urllib broken: {exc}", + "fixable": False, + } + + +def _check_codelens_writable(workspace: str) -> Dict[str, Any]: + """Check that ``/.codelens/`` is writable (or creatable).""" + if not workspace: + return { + "name": "workspace.codelens_writable", + "status": "warning", + "found": None, + "required": "writable .codelens/ dir", + "detail": "no workspace provided (run from a project root, or pass --workspace)", + "fixable": False, + } + codelens_dir = os.path.join(workspace, ".codelens") + try: + os.makedirs(codelens_dir, exist_ok=True) + # Try a write+delete to confirm we actually have write perms, + # not just makedirs success (which can succeed on read-only + # dirs if the dir already exists). + probe = os.path.join(codelens_dir, ".doctor_write_probe") + with open(probe, "w", encoding="utf-8") as f: + f.write("ok") + os.remove(probe) + return { + "name": "workspace.codelens_writable", + "status": "ok", + "found": codelens_dir, + "required": "writable .codelens/ dir", + "detail": f"writable: {codelens_dir}", + "fixable": False, + } + except (OSError, PermissionError) as exc: + return { + "name": "workspace.codelens_writable", + "status": "critical", + "found": codelens_dir, + "required": "writable .codelens/ dir", + "detail": f"not writable: {exc}", + "fixable": False, + } + + +def _check_latest_version() -> Dict[str, Any]: + """Compare installed CodeLens version to the latest GitHub release. + + Network check — failures (offline, rate-limited) are downgraded to + ``warning`` rather than ``critical`` so CI runs without network + access don't fail doctor. + """ + try: + from utils import CODELENS_VERSION + except ImportError: + CODELENS_VERSION = "unknown" + + try: + import urllib.request + import json as _json + # GitHub API requires a User-Agent header or it 403s. + req = urllib.request.Request( + "https://api.github.com/repos/Wolfvin/CodeLens/releases/latest", + headers={"User-Agent": "codelens-doctor", "Accept": "application/vnd.github+json"}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + data = _json.loads(resp.read().decode("utf-8")) + latest = (data.get("tag_name") or "").lstrip("v") + if not latest: + return { + "name": "codelens.latest_version", + "status": "warning", + "found": CODELENS_VERSION, + "required": "able to fetch latest release", + "detail": "GitHub API returned no tag_name field", + "fixable": False, + } + if CODELENS_VERSION == "unknown": + status = "warning" + elif _version_tuple(CODELENS_VERSION) >= _version_tuple(latest): + status = "ok" + else: + status = "warning" + return { + "name": "codelens.latest_version", + "status": status, + "found": CODELENS_VERSION, + "required": f"latest is {latest}", + "detail": ( + f"installed={CODELENS_VERSION}, latest={latest}" + if status == "warning" + else f"installed={CODELENS_VERSION} (up to date)" + ), + "fixable": False, + } + except Exception as exc: + # Network failures are warnings, not critical — doctor must + # still pass in air-gapped CI. + return { + "name": "codelens.latest_version", + "status": "warning", + "found": CODELENS_VERSION, + "required": "able to fetch latest release", + "detail": f"could not reach GitHub API: {exc}", + "fixable": False, + } + + +def _version_tuple(v: str) -> Tuple[int, ...]: + """Best-effort parse of a semver-ish string into a comparison tuple.""" + parts: List[int] = [] + for chunk in v.split("."): + # Strip any pre-release suffix like "1.2.3rc1" → 1.2.3 + num = "" + for ch in chunk: + if ch.isdigit(): + num += ch + else: + break + try: + parts.append(int(num) if num else 0) + except ValueError: + parts.append(0) + return tuple(parts) + + +# ─── Aggregation ─────────────────────────────────────────────── + + +def _run_all_checks(workspace: str) -> List[Dict[str, Any]]: + """Run every check in deterministic order, return list of results.""" + checks: List[Dict[str, Any]] = [] + checks.append(_check_python_version()) + checks.append(_check_import("tree_sitter", "tree-sitter", critical=True)) + checks.append(_check_grammars()) + for mod_name, pip_name in _OPTIONAL_DEPS: + checks.append(_check_import(mod_name, pip_name, critical=False)) + checks.append(_check_sqlite()) + checks.append(_check_urllib()) + for bin_name in _EXPECTED_BINARIES: + checks.append(_check_binary(bin_name)) + checks.append(_check_codelens_writable(workspace)) + checks.append(_check_latest_version()) + return checks + + +def _aggregate_status(checks: List[Dict[str, Any]]) -> Tuple[str, int]: + """Return (overall_status, exit_code) from a list of check results.""" + has_critical = any(c["status"] == "critical" for c in checks) + has_warning = any(c["status"] == "warning" for c in checks) + if has_critical: + return "critical", EXIT_CRITICAL + if has_warning: + return "warning", EXIT_WARNING + return "ok", EXIT_OK + + +def _apply_fixes(checks: List[Dict[str, Any]], verbose: bool) -> List[Dict[str, Any]]: + """Run ``pip install --user`` for every fixable check that's not ok. + + Returns a list of fix outcomes — one per attempted fix. Each + outcome records whether the pip install succeeded, so doctor can + report what was actually changed. + """ + outcomes: List[Dict[str, Any]] = [] + pip_names: List[str] = [] + for c in checks: + if c.get("fixable") and c["status"] in ("critical", "warning"): + names = c.get("pip_names") or ([c["pip_name"]] if c.get("pip_name") else []) + for n in names: + if n and n not in pip_names: + pip_names.append(n) + + if not pip_names: + return [{"action": "noop", "reason": "no fixable checks", "packages": []}] + + # One pip install invocation for all missing packages — faster + # than per-package and gives a single dependency-resolver pass. + cmd = [sys.executable, "-m", "pip", "install", "--user", "--quiet"] + pip_names + if verbose: + # Surface pip's own output in verbose mode. + cmd = [sys.executable, "-m", "pip", "install", "--user"] + pip_names + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, # pip can be slow on cold caches + ) + outcomes.append({ + "action": "pip_install", + "packages": pip_names, + "returncode": proc.returncode, + "stdout": proc.stdout[-2000:] if proc.stdout else "", + "stderr": proc.stderr[-2000:] if proc.stderr else "", + "success": proc.returncode == 0, + }) + except (subprocess.SubprocessError, OSError) as exc: + outcomes.append({ + "action": "pip_install", + "packages": pip_names, + "returncode": -1, + "stdout": "", + "stderr": str(exc), + "success": False, + }) + return outcomes + + +# ─── Output formatting ───────────────────────────────────────── + + +def _format_text(checks: List[Dict[str, Any]], verbose: bool) -> str: + """Human-readable table output. ASCII-only so it pipes cleanly.""" + # Symbols: keep ASCII so the output is grep-friendly and works on + # Windows terminals that don't render Unicode box-drawing. + symbol = {"ok": "[OK] ", "warning": "[WARN]", "critical": "[FAIL]"} + lines: List[str] = [] + lines.append("CodeLens doctor — environment audit") + lines.append("=" * 60) + for c in checks: + sym = symbol.get(c["status"], "[?] ") + lines.append(f"{sym} {c['name']:<32} {c.get('found', '')}") + if verbose and c.get("detail"): + detail = c["detail"] + if isinstance(detail, dict): + # Pretty-print dict details (e.g., grammar versions) + for k, v in detail.items(): + lines.append(f" {k}: {v}") + else: + lines.append(f" {detail}") + lines.append("=" * 60) + overall, _ = _aggregate_status(checks) + lines.append(f"Overall: {overall.upper()}") + return "\n".join(lines) + + +# ─── CLI plumbing ────────────────────────────────────────────── + + +def add_args(parser): + """Register doctor-specific arguments. + + ``workspace`` is an optional positional — if omitted, the CLI + dispatcher auto-detects from cwd (same pattern as ``scan``, + ``query``, etc.). doctor uses it to check ``.codelens/`` + writability. + """ + parser.add_argument( + "workspace", + nargs="?", + default=None, + help="Path to workspace root (auto-detected if omitted)", + ) + parser.add_argument( + "--fix", + action="store_true", + default=False, + help="Auto-install missing Python deps via 'pip install --user'", + ) + parser.add_argument( + "--verbose", + action="store_true", + default=False, + help="Show resolved versions and install paths for every check", + ) + parser.add_argument( + "--format", + choices=["text", "json"], + default="text", + help="Output format (default: text). 'json' for CI parsing.", + ) + # The global --format flag from codelens.py also works; we honor + # whichever the user set. The local one wins if both are present. + + +def execute(args, workspace): + """Run the environment audit, optionally apply fixes, return result dict. + + The result always includes: + + * ``status`` — "ok" | "warning" | "critical" + * ``exit_code`` — 0 | 1 | 2 (consumed by the CLI dispatcher) + * ``checks`` — list of per-check result dicts + * ``fixes`` — list of fix outcomes (empty if --fix not passed) + * ``summary`` — counts by status + * ``platform`` — OS / arch / Python interpreter info + """ + verbose = bool(getattr(args, "verbose", False)) + do_fix = bool(getattr(args, "fix", False)) + + # The local --format argument overrides the global one. + fmt = getattr(args, "format", None) + if fmt not in ("text", "json"): + # Fall back to the global format flag if the local one wasn't set. + fmt = "text" + + checks = _run_all_checks(workspace) + fixes: List[Dict[str, Any]] = [] + if do_fix: + fixes = _apply_fixes(checks, verbose) + # Re-run checks after fix to reflect the new state. The + # reported exit code reflects the post-fix state — that's + # what CI cares about. + checks = _run_all_checks(workspace) + + overall, exit_code = _aggregate_status(checks) + + summary = { + "ok": sum(1 for c in checks if c["status"] == "ok"), + "warning": sum(1 for c in checks if c["status"] == "warning"), + "critical": sum(1 for c in checks if c["status"] == "critical"), + "total": len(checks), + } + + result: Dict[str, Any] = { + "status": overall, + "exit_code": exit_code, + "checks": checks, + "fixes": fixes, + "summary": summary, + "platform": { + "python": sys.version.split()[0], + "platform": platform.platform(), + "machine": platform.machine(), + "executable": sys.executable, + }, + "workspace": workspace, + } + + if fmt == "json": + # In JSON mode, print the result and let codelens.py's + # formatter handle it. But we set args.format to json so + # the dispatcher uses JSON output. + try: + args.format = "json" + except Exception: + pass + else: + # Print the human-readable table to stdout here so the + # dispatcher's JSON formatter doesn't double-encode it. + # The CLI dispatcher checks for ``_doctor_printed_text`` to + # know it should skip its own formatting. + print(_format_text(checks, verbose)) + if fixes: + print("\nFixes applied:") + for f in fixes: + if f.get("action") == "noop": + print(f" (no fixable checks — {f.get('reason', 'noop')})") + else: + status = "OK" if f.get("success") else "FAILED" + pkgs = ", ".join(f.get("packages", [])) + print(f" [{status}] pip install --user {pkgs}") + if verbose and f.get("stderr"): + print(f" stderr: {f['stderr'][:300]}") + result["_doctor_printed_text"] = True + + return result + + +register_command( + "doctor", + "Audit environment for CodeLens dependencies (--fix to auto-install)", + add_args, + execute, +) diff --git a/scripts/graph_model.py b/scripts/graph_model.py index 105d5627..1790fb7a 100644 --- a/scripts/graph_model.py +++ b/scripts/graph_model.py @@ -13,7 +13,7 @@ - New tables `graph_nodes` and `graph_edges` are additive (prefixed `graph_` to avoid colliding with any existing table name). - The flat registry tables and JSON files are untouched. -- All 67 existing CLI commands continue to work unchanged. +- All 68 existing CLI commands continue to work unchanged. Schema: graph_nodes( diff --git a/skill.json b/skill.json index 04f55282..20d1a64d 100755 --- a/skill.json +++ b/skill.json @@ -1,7 +1,7 @@ { "name": "codelens", "version": "8.2.0", - "description": "Live Codebase Reference Intelligence. 67 commands for AI-powered code analysis, security auditing, quality scoring, and pre-write safety checks. Supports 28+ languages with regex+AST hybrid parsing. Must activate before writing/editing/deleting any class, id, or function.", + "description": "Live Codebase Reference Intelligence. 68 commands for AI-powered code analysis, security auditing, quality scoring, and pre-write safety checks. Supports 28+ languages with regex+AST hybrid parsing. Must activate before writing/editing/deleting any class, id, or function.", "author": "codelens", "command_categories": { "setup": [ diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 00000000..49b4524d --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,427 @@ +"""Tests for the ``codelens doctor`` command (issue #64, Phase 1). + +Covers: + +* Every audit check returns a well-formed result dict with the + required keys (``name``, ``status``, ``found``, ``required``, + ``detail``). +* Status aggregation: any ``critical`` → exit 2; any ``warning`` → + exit 1; all ``ok`` → exit 0. +* ``--format json`` produces valid, parseable JSON with the + documented top-level schema. +* ``--format text`` (default) prints a human-readable table. +* ``--fix`` calls ``pip install --user`` for fixable missing deps. +* ``--verbose`` adds detail lines. +* Edge cases: no workspace arg (auto-detect), non-existent workspace + (still runnable — doctor is a env audit, not a workspace audit), + network failure on latest-version check (downgraded to warning). + +These tests deliberately do NOT mock ``sys.version_info``, ``shutil.which``, +or ``importlib`` — doctor's whole job is to probe the real environment. +Instead, we assert on the *structure* of the output (every check has +the required keys, statuses are in the allowed enum, aggregation +matches) rather than on specific version strings that would be +machine-specific. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import textwrap +from unittest import mock + +import pytest + +SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "scripts", +) +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +from commands import COMMAND_REGISTRY # noqa: E402 +from commands import doctor as doctor_module # noqa: E402 + + +# ─── Registration ────────────────────────────────────────────── + + +def test_doctor_is_registered(): + """doctor must be in the runtime COMMAND_REGISTRY.""" + assert "doctor" in COMMAND_REGISTRY + info = COMMAND_REGISTRY["doctor"] + assert "audit" in info["help"].lower() or "doctor" in info["help"].lower() + + +def test_doctor_execute_callable(): + """The registered execute function must be callable.""" + info = COMMAND_REGISTRY["doctor"] + assert callable(info["execute"]) + assert callable(info["add_args"]) + + +# ─── Helper ──────────────────────────────────────────────────── + + +def _run_doctor(workspace=None, fix=False, verbose=False, fmt="json"): + """Invoke doctor.execute() with a synthetic args namespace. + + Returns the raw result dict — NOT the printed text. For text-mode + tests, capture stdout separately. + """ + args = mock.MagicMock() + args.fix = fix + args.verbose = verbose + args.format = fmt + args.workspace = workspace + return doctor_module.execute(args, workspace or "") + + +# ─── Output schema ───────────────────────────────────────────── + + +class TestOutputSchema: + """The result dict must conform to the documented schema.""" + + def test_top_level_keys_present(self, tmp_path): + result = _run_doctor(workspace=str(tmp_path)) + for key in ("status", "exit_code", "checks", "fixes", "summary", "platform", "workspace"): + assert key in result, f"missing top-level key: {key}" + + def test_status_is_in_allowed_enum(self, tmp_path): + result = _run_doctor(workspace=str(tmp_path)) + assert result["status"] in ("ok", "warning", "critical") + + def test_exit_code_matches_status(self, tmp_path): + result = _run_doctor(workspace=str(tmp_path)) + status_to_exit = {"ok": 0, "warning": 1, "critical": 2} + assert result["exit_code"] == status_to_exit[result["status"]] + + def test_summary_counts_match_checks(self, tmp_path): + result = _run_doctor(workspace=str(tmp_path)) + summary = result["summary"] + checks = result["checks"] + assert summary["total"] == len(checks) + for status in ("ok", "warning", "critical"): + expected = sum(1 for c in checks if c["status"] == status) + assert summary[status] == expected, ( + f"summary.{status}={summary[status]} but counted {expected} in checks" + ) + + def test_every_check_has_required_keys(self, tmp_path): + result = _run_doctor(workspace=str(tmp_path)) + required_keys = {"name", "status", "found", "required", "detail"} + for check in result["checks"]: + missing = required_keys - set(check.keys()) + assert not missing, f"check {check.get('name')} missing keys: {missing}" + + def test_every_check_status_in_enum(self, tmp_path): + result = _run_doctor(workspace=str(tmp_path)) + for check in result["checks"]: + assert check["status"] in ("ok", "warning", "critical"), ( + f"check {check['name']} has invalid status: {check['status']}" + ) + + def test_platform_block_has_expected_fields(self, tmp_path): + result = _run_doctor(workspace=str(tmp_path)) + plat = result["platform"] + for key in ("python", "platform", "machine", "executable"): + assert key in plat + + def test_fixes_is_list(self, tmp_path): + result = _run_doctor(workspace=str(tmp_path)) + assert isinstance(result["fixes"], list) + + +# ─── Individual checks ───────────────────────────────────────── + + +class TestIndividualChecks: + """Spot-check that each audit category appears in the result.""" + + EXPECTED_CHECK_NAMES = { + "python", + "python.module.tree_sitter", + "tree_sitter.grammars", + "python.module.yaml", + "python.module.watchdog", + "python.module.sqlite3", + "python.module.urllib", + "binary.git", + "workspace.codelens_writable", + "codelens.latest_version", + } + + def test_all_expected_checks_run(self, tmp_path): + result = _run_doctor(workspace=str(tmp_path)) + actual_names = {c["name"] for c in result["checks"]} + missing = self.EXPECTED_CHECK_NAMES - actual_names + assert not missing, f"missing checks: {missing}" + + +# ─── Aggregation logic ───────────────────────────────────────── + + +class TestAggregation: + """Verify _aggregate_status picks the worst status correctly.""" + + def test_all_ok_returns_zero(self): + checks = [{"status": "ok"}, {"status": "ok"}] + status, code = doctor_module._aggregate_status(checks) + assert status == "ok" + assert code == doctor_module.EXIT_OK + + def test_warning_returns_one(self): + checks = [{"status": "ok"}, {"status": "warning"}] + status, code = doctor_module._aggregate_status(checks) + assert status == "warning" + assert code == doctor_module.EXIT_WARNING + + def test_critical_returns_two(self): + checks = [{"status": "ok"}, {"status": "warning"}, {"status": "critical"}] + status, code = doctor_module._aggregate_status(checks) + assert status == "critical" + assert code == doctor_module.EXIT_CRITICAL + + def test_critical_wins_over_warning(self): + checks = [{"status": "warning"}, {"status": "critical"}] + status, code = doctor_module._aggregate_status(checks) + assert status == "critical" + + +# ─── Version tuple helper ────────────────────────────────────── + + +class TestVersionTuple: + def test_simple_semver(self): + assert doctor_module._version_tuple("8.2.0") == (8, 2, 0) + + def test_two_components(self): + assert doctor_module._version_tuple("3.12") == (3, 12) + + def test_with_prerelease_suffix(self): + # "8.2.0rc1" → (8, 2, 0) — pre-release stripped + assert doctor_module._version_tuple("8.2.0rc1") == (8, 2, 0) + + def test_comparison(self): + assert doctor_module._version_tuple("8.2.0") >= doctor_module._version_tuple("8.1.9") + assert doctor_module._version_tuple("8.2.0") >= doctor_module._version_tuple("8.2.0") + assert not (doctor_module._version_tuple("8.1.0") >= doctor_module._version_tuple("8.2.0")) + + +# ─── Workspace writability check ─────────────────────────────── + + +class TestWorkspaceWritableCheck: + def test_writable_workspace_returns_ok(self, tmp_path): + result = doctor_module._check_codelens_writable(str(tmp_path)) + assert result["status"] == "ok" + assert ".codelens" in result["found"] + + def test_no_workspace_returns_warning(self): + result = doctor_module._check_codelens_writable("") + assert result["status"] == "warning" + assert "no workspace" in result["detail"].lower() + + def test_read_only_workspace_returns_critical(self, tmp_path): + """A read-only workspace dir must be flagged critical. + + We chmod the parent to 0500 (r-x) and try to create a + subdir — should fail. Restore perms in the finally block so + pytest's tmp_path cleanup works. + """ + ro = tmp_path / "readonly" + ro.mkdir() + os.chmod(ro, 0o500) # r-x for owner, no write + try: + result = doctor_module._check_codelens_writable(str(ro)) + # On some CI runners the test runs as root, which can + # write anywhere regardless of perms. Skip the assertion + # in that case rather than fail. + if os.geteuid() == 0: + pytest.skip("running as root — perm check is a no-op") + assert result["status"] == "critical" + finally: + os.chmod(ro, 0o700) # restore so cleanup works + + +# ─── Python version check ────────────────────────────────────── + + +class TestPythonVersionCheck: + def test_returns_ok_on_current_python(self): + # The test runner's Python is whatever it is — but it must + # be >= 3.8 because we use f-strings and other 3.8+ features + # throughout the codebase. So this should always be ok. + result = doctor_module._check_python_version() + assert result["status"] == "ok" + assert result["found"].startswith(f"{sys.version_info.major}.{sys.version_info.minor}.") + + +# ─── CLI smoke test (subprocess) ─────────────────────────────── + + +class TestCLISmoke: + """End-to-end: invoke ``codelens doctor`` as a real subprocess.""" + + def _run_cli(self, *extra_args): + env = os.environ.copy() + env["PYTHONPATH"] = SCRIPTS_DIR + return subprocess.run( + [sys.executable, os.path.join(SCRIPTS_DIR, "codelens.py"), "doctor", *extra_args], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + + def test_doctor_runs_and_exits_nonzero_on_critical(self, tmp_path): + # tree_sitter is likely missing in the test env (it's an + # optional dep), so doctor should exit 2. If it happens to + # be installed, the test still passes because we accept any + # non-crash exit code. + result = self._run_cli(str(tmp_path)) + assert result.returncode in (0, 1, 2), ( + f"unexpected exit code {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + def test_doctor_text_format_contains_header(self, tmp_path): + result = self._run_cli(str(tmp_path)) + assert "CodeLens doctor" in result.stdout + assert "Overall:" in result.stdout + + def test_doctor_json_format_is_valid_json(self, tmp_path): + result = self._run_cli(str(tmp_path), "--format", "json") + # The doctor command's --format json should make the CLI + # print JSON (not the text table). + # Strip any stderr noise from stdout before parsing. + out = result.stdout.strip() + # If _doctor_printed_text was set, the text table was printed + # instead. In JSON mode it should NOT be set. + data = json.loads(out) + assert "status" in data + assert "checks" in data + assert "exit_code" in data + + def test_doctor_verbose_adds_detail(self, tmp_path): + result_normal = self._run_cli(str(tmp_path)) + result_verbose = self._run_cli(str(tmp_path), "--verbose") + # Verbose output should be longer (extra detail lines). + assert len(result_verbose.stdout) > len(result_normal.stdout) + + +# ─── Fix mode (mocked) ───────────────────────────────────────── + + +class TestFixMode: + """``--fix`` should call ``pip install --user`` for fixable missing deps. + + We mock ``subprocess.run`` so no real pip install happens. The + test asserts that pip is invoked AT MOST once with all the + missing packages in a single command. + """ + + def test_fix_calls_pip_when_deps_missing(self, tmp_path): + # Force a fake "missing" state by stubbing _run_all_checks + # to return a critical tree_sitter check. + fake_checks = [ + {"name": "python.module.tree_sitter", "status": "critical", + "found": None, "required": "present", "detail": "ImportError", + "pip_name": "tree-sitter", "fixable": True}, + {"name": "python", "status": "ok", "found": "3.12", "required": ">= 3.8", + "detail": "ok"}, + ] + with mock.patch.object(doctor_module, "_run_all_checks", return_value=fake_checks): + with mock.patch.object(doctor_module.subprocess, "run") as mock_run: + mock_run.return_value = mock.MagicMock(returncode=0, stdout="", stderr="") + # Also stub _run_all_checks for the POST-fix re-run. + # Since _apply_fixes calls _run_all_checks again, we + # need the second call to also return the fake checks. + with mock.patch.object(doctor_module, "_run_all_checks", return_value=fake_checks): + args = mock.MagicMock() + args.fix = True + args.verbose = False + args.format = "json" + args.workspace = str(tmp_path) + result = doctor_module.execute(args, str(tmp_path)) + + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert "pip" in " ".join(call_args) + assert "install" in call_args + assert "--user" in call_args + assert "tree-sitter" in call_args + # The fix outcome must be recorded. + assert len(result["fixes"]) >= 1 + fix = result["fixes"][0] + assert fix["action"] == "pip_install" + assert fix["success"] is True + + def test_fix_noop_when_nothing_fixable(self, tmp_path): + fake_checks = [ + {"name": "python", "status": "ok", "found": "3.12", + "required": ">= 3.8", "detail": "ok"}, + ] + with mock.patch.object(doctor_module, "_run_all_checks", return_value=fake_checks): + with mock.patch.object(doctor_module.subprocess, "run") as mock_run: + args = mock.MagicMock() + args.fix = True + args.verbose = False + args.format = "json" + args.workspace = str(tmp_path) + result = doctor_module.execute(args, str(tmp_path)) + + # pip should NOT have been called. + assert not mock_run.called + assert len(result["fixes"]) == 1 + assert result["fixes"][0]["action"] == "noop" + + +# ─── Latest version check (network-failure tolerant) ─────────── + + +class TestLatestVersionCheck: + """The latest-version check must NEVER fail critically, even offline.""" + + def test_network_failure_returns_warning_not_critical(self): + # Force a network failure by patching urllib.request.urlopen. + with mock.patch("urllib.request.urlopen", side_effect=OSError("network down")): + result = doctor_module._check_latest_version() + assert result["status"] == "warning" + assert "could not reach" in result["detail"].lower() + # Critical would break CI in air-gapped environments. + assert result["status"] != "critical" + + def test_successful_fetch_returns_ok_when_up_to_date(self): + # Mock a successful GitHub API response where latest == installed. + from utils import CODELENS_VERSION + fake_response = mock.MagicMock() + fake_response.__enter__ = mock.MagicMock(return_value=fake_response) + fake_response.__exit__ = mock.MagicMock(return_value=False) + fake_response.read = mock.MagicMock( + return_value=json.dumps({"tag_name": f"v{CODELENS_VERSION}"}).encode() + ) + with mock.patch("urllib.request.urlopen", return_value=fake_response): + result = doctor_module._check_latest_version() + assert result["status"] == "ok" + assert "up to date" in result["detail"] + + def test_outdated_install_returns_warning(self): + # Mock a successful fetch where latest > installed. + from utils import CODELENS_VERSION + # Bump the major version to guarantee latest > installed. + major = int(CODELENS_VERSION.split(".")[0]) + 1 + fake_latest = f"v{major}.0.0" + fake_response = mock.MagicMock() + fake_response.__enter__ = mock.MagicMock(return_value=fake_response) + fake_response.__exit__ = mock.MagicMock(return_value=False) + fake_response.read = mock.MagicMock( + return_value=json.dumps({"tag_name": fake_latest}).encode() + ) + with mock.patch("urllib.request.urlopen", return_value=fake_response): + result = doctor_module._check_latest_version() + assert result["status"] == "warning" + assert "latest" in result["detail"].lower() diff --git a/tests/test_integration.py b/tests/test_integration.py index 9b5af484..c6a31562 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,5 +1,5 @@ """ -Integration smoke tests for all 67 CodeLens commands. +Integration smoke tests for all 68 CodeLens commands. Tests that every command: 1. Runs without crash (valid JSON output) @@ -300,7 +300,8 @@ def test_command_registry_has_all_commands(self): sys.path.insert(0, SCRIPT_DIR) from commands import COMMAND_REGISTRY # Regression sentinel — see docstring above for update procedure. - EXPECTED_COMMAND_COUNT = 67 + # Bumped 67 → 68 for issue #64 Phase 1: added `doctor` command. + EXPECTED_COMMAND_COUNT = 68 actual = len(COMMAND_REGISTRY) assert actual == EXPECTED_COMMAND_COUNT, ( f"Command count drift detected: expected {EXPECTED_COMMAND_COUNT}, "