From a7b7359bbc794941ca11e266524352997a88861b Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Thu, 2 Jul 2026 18:31:59 +0000 Subject: [PATCH] feat(orient): 10-second codebase orientation brief (closes #160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `codelens orient` command that produces a structured repo orientation brief answering the first 4 questions a developer asks when entering an unfamiliar repo: 1. What framework/stack is this? (framework_db.detect_frameworks_brief) 2. How do I run, build, and test it? (manifest_parser.extract_commands) 3. Where do I start reading? (file_ranker.rank_start_here_files) 4. What CI/Docker infrastructure exists? (inline detection) Pure-filesystem: no subprocess, no network, no LSP. Reads manifest files, walks the source tree once, emits a structured brief. Architecture (per issue #160 spec, with one deviation — see below): - scripts/commands/orient.py — command module + entry point/CI detection - scripts/orient_lib/ — analyzer sub-package - framework_db.py — data-driven framework lookup table (5 ecosystems, 60+ frameworks) - manifest_parser.py — run/build/test command extraction - file_ranker.py — Start Here scoring logic Deviation from issue spec: package named `orient_lib` not `orient` to avoid a Python import collision — `commands/orient.py` and a top-level `orient/` package both claim the `orient` module name, triggering a circular import. Documented in PR description. Output formats: - --format json (default): structured JSON matching issue #160 schema - --format compact: single-line brief for LLM context (<=300 tokens) - --format text: human-readable multi-line brief Framework detection covers (DoD minimum): Next.js, React, Express, FastAPI, Django, Flask, Gin, Axum, Spring Boot — plus 50+ more across Node.js, Python, Go, Rust, Java. sync_command_count.py --apply run: command count 70 -> 71 across README, SKILL.md, SKILL-QUICK.md, pyproject.toml, skill.json, graph_model.py. Tests: 31 new tests in tests/test_orient.py covering all 5 sub-features + output schema + compact rendering. All pass. Test suite: 1405 passed, 76 skipped, 1 failed (pre-existing test_codelensignore failure, fails identically on main, unrelated). --- README.md | 10 +- SKILL-QUICK.md | 10 +- SKILL.md | 4 +- pyproject.toml | 2 +- scripts/codelens.py | 4 +- scripts/commands/orient.py | 400 +++++++++++++++++++++ scripts/graph_model.py | 2 +- scripts/orient_lib/__init__.py | 27 ++ scripts/orient_lib/file_ranker.py | 276 ++++++++++++++ scripts/orient_lib/framework_db.py | 493 ++++++++++++++++++++++++++ scripts/orient_lib/manifest_parser.py | 301 ++++++++++++++++ skill.json | 2 +- tests/test_orient.py | 389 ++++++++++++++++++++ 13 files changed, 1903 insertions(+), 17 deletions(-) create mode 100644 scripts/commands/orient.py create mode 100644 scripts/orient_lib/__init__.py create mode 100644 scripts/orient_lib/file_ranker.py create mode 100644 scripts/orient_lib/framework_db.py create mode 100644 scripts/orient_lib/manifest_parser.py create mode 100644 tests/test_orient.py diff --git a/README.md b/README.md index e1efd347..504033d1 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 75 CLI commands, an MCP server with 73 tools (56 static + 17 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 71 CLI commands, an MCP server with 69 tools (54 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). ## Features -- **75 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 (73 Tools)** — Native AI agent integration via Model Context Protocol (JSON-RPC over stdio), 56 statically-defined tools + 17 dynamically discovered, every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`) +- **71 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 (69 Tools)** — Native AI agent integration via Model Context Protocol (JSON-RPC over stdio), 54 statically-defined tools + 15 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 (75 commands registered) -│ ├── mcp_server.py # MCP JSON-RPC server (73 tools) +│ ├── codelens.py # CLI entry point (71 commands registered) +│ ├── mcp_server.py # MCP JSON-RPC server (69 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 46ce9b56..fc8a5a2e 100755 --- a/SKILL-QUICK.md +++ b/SKILL-QUICK.md @@ -116,7 +116,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 75 Commands +## All 71 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) @@ -148,9 +148,9 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co ### Tooling (1) `plugin ` -**Total: 75 commands** (auto-registered via `commands/__init__.py`; rerun `python3 scripts/sync_command_count.py --apply` after adding/removing a command) +**Total: 71 commands** (auto-registered via `commands/__init__.py`; rerun `python3 scripts/sync_command_count.py --apply` after adding/removing a command) -## MCP Server (73 Tools) +## MCP Server (69 Tools) Start the MCP server for AI agent integration: @@ -158,9 +158,9 @@ Start the MCP server for AI agent integration: python3 scripts/codelens.py serve ``` -Exposes 73 tools as `codelens_` (e.g., `codelens_query`, `codelens_taint`, `codelens_graph_schema`, `codelens_architecture`, `codelens_resolve_types`, `codelens_git_status`): +Exposes 69 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`) -- 17 dynamically-discovered tools (auto-discovered from `COMMAND_REGISTRY`; long-running `watch` and `serve` are excluded) +- 15 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 b8ad8594..50c98307 100755 --- a/SKILL.md +++ b/SKILL.md @@ -1,10 +1,10 @@ --- name: codelens description: > - CodeLens — AI-Native Code Intelligence. 75 commands for AI-powered code analysis, + CodeLens — AI-Native Code Intelligence. 71 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 73 tools for AI agent integration. + fallback parsing. MCP server exposes 69 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 8a15e522..63dfd97d 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 — 75 commands for AI-powered code analysis, security auditing, and quality scoring" +description = "Live Codebase Reference Intelligence — 71 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 88fdd707..72cd0d2d 100755 --- a/scripts/codelens.py +++ b/scripts/codelens.py @@ -1414,9 +1414,9 @@ def main(): # and the normal formatter handles the JSON serialization. _already_printed = ( isinstance(result, dict) - and (result.get("_doctor_printed_text") or result.get("_sessions_printed_text")) + and (result.get("_doctor_printed_text") or result.get("_sessions_printed_text") or result.get("_orient_printed_text")) ) - if not (args.command in ("doctor", "sessions") and _already_printed): + if not (args.command in ("doctor", "sessions", "orient") and _already_printed): print(format_output(result, args.format, format_command, workspace)) # ─── Exit codes for CI-quality-gate commands ── diff --git a/scripts/commands/orient.py b/scripts/commands/orient.py new file mode 100644 index 00000000..6a46f6d8 --- /dev/null +++ b/scripts/commands/orient.py @@ -0,0 +1,400 @@ +# @WHO: scripts/commands/orient.py +# @WHAT: orient command — 10-second codebase orientation brief +# @PART: commands +# @ENTRY: cmd_orient() + +""" +orient command — Produce a 10-second codebase orientation brief. + +Answers the first four questions a developer asks when entering an +unfamiliar repo: + +1. What framework/stack is this? (framework_db.detect_frameworks_brief) +2. How do I run, build, and test it? (manifest_parser.extract_commands) +3. Where do I start reading? (file_ranker.rank_start_here_files) +4. What CI/Docker infrastructure exists? (inline detection in this module) + +The command is pure-filesystem: no subprocess, no network, no LSP. It +reads manifest files, walks the source tree once, and emits a single +structured brief. ``--format compact`` produces a single-line brief +suitable for LLM context generation (target: <=300 tokens). + +Reference: issue #160. Ported from codeglance (TypeScript, MIT) — +rewritten in Python idioms, no code copied. +""" + +from __future__ import annotations + +import os +import sys +from typing import Any, Dict, List, Optional + +from commands import register_command +from orient_lib import ( + detect_frameworks_brief, + extract_commands, + rank_start_here_files, +) + +__all__ = ["cmd_orient", "add_args", "execute"] + + +# ─── Entry point detection ───────────────────────────────────── +# +# Per-ecosystem candidate file lists + manifest "main"/"bin" lookup. +# Returns up to 8 files with a semantic ``type`` label. + +_ENTRY_POINT_CANDIDATES: Dict[str, List[tuple]] = { + "Node.js": [ + ("src/index.ts", "entry"), ("src/index.js", "entry"), + ("src/main.ts", "entry"), ("src/main.js", "entry"), + ("src/app.ts", "entry"), ("src/app.js", "entry"), + ("server.ts", "server"), ("server.js", "server"), + ("app.ts", "entry"), ("app.js", "entry"), + ("next.config.js", "config"), ("next.config.mjs", "config"), + ("next.config.ts", "config"), + ], + "Python": [ + ("main.py", "entry"), ("app.py", "entry"), + ("run.py", "entry"), ("manage.py", "entry"), + ("wsgi.py", "server"), ("asgi.py", "server"), + ("__main__.py", "entry"), ("cli.py", "cli"), + ], + "Go": [ + ("main.go", "entry"), ("cmd/main.go", "entry"), + ("cmd/main/main.go", "entry"), + ("server.go", "server"), ("app.go", "entry"), + ], + "Rust": [ + ("src/main.rs", "entry"), ("src/lib.rs", "library"), + ("main.rs", "entry"), ("src/bin/main.rs", "entry"), + ], + "Java": [ + ("src/main/java/Main.java", "entry"), + ("src/main/java/Application.java", "entry"), + ("src/main/java/App.java", "entry"), + ], +} + + +def _detect_entry_points(workspace: str, ecosystem: str) -> List[Dict[str, str]]: + """Detect entry point files for the given ecosystem.""" + candidates = _ENTRY_POINT_CANDIDATES.get(ecosystem, []) + found: List[Dict[str, str]] = [] + seen_paths: set = set() + for rel_path, entry_type in candidates: + abs_path = os.path.join(workspace, rel_path) + if os.path.isfile(abs_path) and rel_path not in seen_paths: + found.append({"path": rel_path, "type": entry_type}) + seen_paths.add(rel_path) + if len(found) >= 8: + break + + # Augment with package.json "main"/"bin" fields for Node.js. + if ecosystem == "Node.js" and len(found) < 8: + pkg_main = _read_package_json_main(workspace) + for entry_path, entry_type in pkg_main: + if entry_path not in seen_paths: + found.append({"path": entry_path, "type": entry_type}) + seen_paths.add(entry_path) + if len(found) >= 8: + break + + return found + + +def _read_package_json_main(workspace: str) -> List[tuple]: + """Extract ``main`` and ``bin`` entry points from package.json.""" + import json + path = os.path.join(workspace, "package.json") + if not os.path.isfile(path): + return [] + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + data = json.load(f) + except (OSError, ValueError): + return [] + entries: List[tuple] = [] + main_field = data.get("main") + if isinstance(main_field, str) and main_field: + entries.append((main_field, "entry")) + bin_field = data.get("bin") + if isinstance(bin_field, str) and bin_field: + entries.append((bin_field, "cli")) + elif isinstance(bin_field, dict): + for bin_name, bin_path in bin_field.items(): + if isinstance(bin_path, str): + entries.append((bin_path, "cli")) + return entries + + +# ─── CI / Docker / Env detection ─────────────────────────────── + + +_CI_DIRS = (".github/workflows", ".gitlab-ci", ".circleci") +_CI_FILES = (".travis.yml", "azure-pipelines.yml", "Jenkinsfile", + "bitbucket-pipelines.yml", ".drone.yml") + + +def _detect_ci(workspace: str) -> Dict[str, Any]: + """Detect CI configuration files (filesystem only, no subprocess).""" + ci_count = 0 + for ci_dir in _CI_DIRS: + full = os.path.join(workspace, ci_dir) + if os.path.isdir(full): + try: + ci_count += sum( + 1 for f in os.listdir(full) + if f.endswith((".yml", ".yaml")) and not f.startswith(".") + ) + except OSError: + pass + for ci_file in _CI_FILES: + if os.path.isfile(os.path.join(workspace, ci_file)): + ci_count += 1 + return {"ci": ci_count > 0, "ci_count": ci_count} + + +def _detect_docker(workspace: str) -> bool: + """Detect Dockerfile / docker-compose presence.""" + for name in ("Dockerfile", "Dockerfile.dev", "Dockerfile.prod", + "docker-compose.yml", "docker-compose.yaml", + "compose.yml", "compose.yaml"): + if os.path.isfile(os.path.join(workspace, name)): + return True + return False + + +def _detect_env_file(workspace: str) -> bool: + """Detect ``.env`` or ``.env.example`` presence.""" + for name in (".env", ".env.example", ".env.local", ".env.template"): + if os.path.isfile(os.path.join(workspace, name)): + return True + return False + + +def _detect_test_framework_and_linter(workspace: str, ecosystem: str) -> Dict[str, Optional[str]]: + """Detect test framework + linter from config files (heuristic).""" + result: Dict[str, Optional[str]] = {"test_framework": None, "linter": None} + files_present: Dict[str, bool] = {} + try: + files_present = { + f: True for f in os.listdir(workspace) + if os.path.isfile(os.path.join(workspace, f)) + } + except OSError: + pass + + if ecosystem == "Node.js": + if "jest.config.js" in files_present or "jest.config.ts" in files_present: + result["test_framework"] = "jest" + elif "vitest.config.ts" in files_present or "vitest.config.js" in files_present: + result["test_framework"] = "vitest" + elif os.path.isfile(os.path.join(workspace, "playwright.config.ts")): + result["test_framework"] = "playwright" + if ".eslintrc.js" in files_present or ".eslintrc.json" in files_present \ + or ".eslintrc.cjs" in files_present or "eslint.config.js" in files_present: + result["linter"] = "eslint" + elif "biome.json" in files_present: + result["linter"] = "biome" + elif ecosystem == "Python": + if any(f.startswith("pytest") for f in files_present) or \ + os.path.isfile(os.path.join(workspace, "pytest.ini")) or \ + os.path.isfile(os.path.join(workspace, "tox.ini")): + result["test_framework"] = "pytest" + if os.path.isfile(os.path.join(workspace, ".flake8")) or \ + os.path.isfile(os.path.join(workspace, "setup.cfg")): + result["linter"] = "flake8" + elif os.path.isfile(os.path.join(workspace, "ruff.toml")) or \ + os.path.isfile(os.path.join(workspace, ".ruff.toml")): + result["linter"] = "ruff" + elif ecosystem == "Go": + # Go has built-in testing; no config file needed. + result["test_framework"] = "go test" + elif ecosystem == "Rust": + # Cargo has built-in testing. + result["test_framework"] = "cargo test" + elif ecosystem == "Java": + if os.path.isfile(os.path.join(workspace, "pom.xml")): + result["test_framework"] = "junit (maven)" + elif os.path.isfile(os.path.join(workspace, "build.gradle")) or \ + os.path.isfile(os.path.join(workspace, "build.gradle.kts")): + result["test_framework"] = "junit (gradle)" + return result + + +# ─── Output rendering ────────────────────────────────────────── + + +def _render_compact(brief: Dict[str, Any]) -> str: + """Render the brief as a single-line summary (target <=300 tokens).""" + fw = brief.get("framework", {}) + eco = fw.get("ecosystem", "Unknown") + primary = fw.get("primary") or "no primary framework" + secondary = fw.get("secondary") or [] + sec_str = f" + {', '.join(secondary[:3])}" if secondary else "" + commands = brief.get("commands", []) + cmd_str = "; ".join( + f"{c['command']}({c['kind']})" for c in commands[:5] + ) if commands else "no commands detected" + infra = brief.get("infra", {}) + ci = "CI" if infra.get("ci") else "no-CI" + docker = "Docker" if infra.get("docker") else "no-Docker" + env = ".env" if infra.get("env_file") else "no-.env" + start_here = brief.get("start_here", []) + start_str = ", ".join(f["path"] for f in start_here[:3]) if start_here else "none" + return ( + f"{eco}/{primary}{sec_str} | {cmd_str} | {ci}/{docker}/{env} | " + f"start: {start_str}" + ) + + +# ─── Command entry ───────────────────────────────────────────── + + +def add_args(parser): + """Register orient-specific arguments. + + ``workspace`` is an optional positional — if omitted, the CLI + dispatcher auto-detects from cwd (same pattern as ``scan``, + ``query``, etc.). ``--format`` overrides the global format flag + with orient-specific choices (text/json/compact). + """ + parser.add_argument( + "workspace", nargs="?", default=None, + help="Path to workspace root (auto-detected if omitted)", + ) + parser.add_argument( + "--format", + choices=["json", "text", "compact"], + default="json", + help="Output format (default: json). 'compact' = single-line brief " + "for LLM context (<=300 tokens). 'text' = human-readable.", + ) + parser.add_argument( + "--top", type=int, default=8, + help="Maximum number of Start Here files to return (default: 8)", + ) + + +# @FLOW: ORIENT +# @CALLS: detect_frameworks_brief() -> {ecosystem, primary, secondary, summary} +# extract_commands() -> [{kind, command, description, raw}] +# _detect_entry_points() -> [{path, type}] +# rank_start_here_files() -> [{path, score, reason}] +# _detect_ci()/_detect_docker()/_detect_env_file() -> infra block +# @MUTATES: (none — pure read; emits structured brief to stdout) + + +def cmd_orient(workspace: str, top: int = 8) -> Dict[str, Any]: + """Produce a structured codebase orientation brief. + + Args: + workspace: Absolute path to the project root. + top: Maximum number of Start Here files to return. + + Returns: + Dict matching the orient output schema:: + + { + "status": "ok", + "workspace": "/path/to/repo", + "framework": {...}, + "commands": [...], + "entry_points": [...], + "start_here": [...], + "infra": {...} + } + """ + workspace = os.path.abspath(workspace) + + framework = detect_frameworks_brief(workspace) + ecosystem = framework.get("ecosystem", "Unknown") + commands = extract_commands(workspace) + entry_points = _detect_entry_points(workspace, ecosystem) + start_here = rank_start_here_files(workspace, top_n=top) + + infra: Dict[str, Any] = {} + infra.update(_detect_ci(workspace)) + infra["docker"] = _detect_docker(workspace) + infra["env_file"] = _detect_env_file(workspace) + infra.update(_detect_test_framework_and_linter(workspace, ecosystem)) + + return { + "status": "ok", + "workspace": workspace, + "framework": framework, + "commands": commands, + "entry_points": entry_points, + "start_here": start_here, + "infra": infra, + } + + +def execute(args, workspace): + """Run the orient command and handle output format.""" + top = getattr(args, "top", 8) or 8 + brief = cmd_orient(workspace, top=top) + + fmt = getattr(args, "format", None) + if fmt not in ("json", "text", "compact"): + fmt = "json" + + if fmt == "compact": + print(_render_compact(brief)) + brief["_orient_printed_text"] = True + elif fmt == "text": + _render_text(brief) + brief["_orient_printed_text"] = True + # If json: return the dict and let the global formatter handle it. + return brief + + +def _render_text(brief: Dict[str, Any]) -> None: + """Print a human-readable multi-line orientation brief.""" + fw = brief.get("framework", {}) + print(f"== Codebase Orientation: {brief.get('workspace', '?')} ==") + print() + print(f"Framework: {fw.get('ecosystem', 'Unknown')} / {fw.get('primary', 'none')}") + if fw.get("secondary"): + print(f" Secondary: {', '.join(fw['secondary'])}") + print(f" Summary: {fw.get('summary', '')}") + print() + + commands = brief.get("commands", []) + if commands: + print("Commands:") + for c in commands[:10]: + print(f" [{c['kind']}] {c['command']} — {c.get('description', '')}") + print() + + entries = brief.get("entry_points", []) + if entries: + print("Entry points:") + for e in entries: + print(f" {e['path']} ({e['type']})") + print() + + start = brief.get("start_here", []) + if start: + print("Start here (top files to read first):") + for s in start: + print(f" [{s['score']:>3}] {s['path']} — {s['reason']}") + print() + + infra = brief.get("infra", {}) + print("Infrastructure:") + print(f" CI: {infra.get('ci', False)} ({infra.get('ci_count', 0)} file(s))") + print(f" Docker: {infra.get('docker', False)}") + print(f" .env file: {infra.get('env_file', False)}") + print(f" Test framework: {infra.get('test_framework', 'none')}") + print(f" Linter: {infra.get('linter', 'none')}") + + +register_command( + "orient", + "10-second codebase orientation brief (framework, commands, entry points, " + "start-here files, CI/Docker)", + add_args, + execute, +) diff --git a/scripts/graph_model.py b/scripts/graph_model.py index 6a14b4dd..8b2e8cd5 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 75 existing CLI commands continue to work unchanged. +- All 71 existing CLI commands continue to work unchanged. Schema: graph_nodes( diff --git a/scripts/orient_lib/__init__.py b/scripts/orient_lib/__init__.py new file mode 100644 index 00000000..ba67d902 --- /dev/null +++ b/scripts/orient_lib/__init__.py @@ -0,0 +1,27 @@ +# @WHO: scripts/orient_lib/__init__.py +# @WHAT: Orient analyzers sub-package — repo orientation brief +# @PART: orient_lib +# @ENTRY: (package marker only) + +"""Orient analyzers sub-package: helpers for the ``codelens orient`` command. + +Named ``orient_lib`` (not ``orient``) to avoid a Python import collision +with ``scripts/commands/orient.py`` — both would claim the top-level +``orient`` module name. See issue #160 PR description for details. + +Modules: + framework_db — data-driven framework lookup table (ecosystem -> packages) + manifest_parser — run/build/test command extraction from manifest files + file_ranker — Start Here scoring logic for source files +""" + +from .framework_db import detect_frameworks_brief, ECOSYSTEM_FRAMEWORKS +from .manifest_parser import extract_commands +from .file_ranker import rank_start_here_files + +__all__ = [ + "detect_frameworks_brief", + "ECOSYSTEM_FRAMEWORKS", + "extract_commands", + "rank_start_here_files", +] diff --git a/scripts/orient_lib/file_ranker.py b/scripts/orient_lib/file_ranker.py new file mode 100644 index 00000000..34fde956 --- /dev/null +++ b/scripts/orient_lib/file_ranker.py @@ -0,0 +1,276 @@ +# @WHO: scripts/orient_lib/file_ranker.py +# @WHAT: Start Here file ranking — score source files by reading priority +# @PART: orient +# @ENTRY: rank_start_here_files() + +""" +Start Here file ranker for the ``codelens orient`` command. + +Scores every source file in the workspace and returns the top N files +that a developer should read first when entering an unfamiliar repo. +Scoring criteria (each contributes to a 0–100 score): + +- **Filename pattern** — ``main``, ``app``, ``index``, ``server`` score + high; test/migration/generated files are skipped entirely. +- **Directory depth** — shallower files score higher (root-level files + are usually entry points or key configs). +- **Line count** — 50–500 lines is the sweet spot; very small or very + large files get penalized. +- **Directory context** — files in ``src/``, ``lib/``, ``app/``, + ``cmd/``, ``internal/`` score higher than ``vendor/`` or ``dist/``. + +Skip rules (file is excluded entirely): +- test files (``test_*``, ``*_test.py``, ``*.spec.ts``, ``__tests__``) +- migrations (``migrations/``, ``alembic/``) +- generated code (``*.generated.*``, ``dist/``, ``build/``) +- config-only files (``*.config.js``, ``*.json``, ``*.lock`` — except + ``package.json`` which is handled by entry point detection) + +Reference: issue #160. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List, Tuple + +__all__ = ["rank_start_here_files"] + +_logger = logging.getLogger("codelens.orient.file_ranker") + + +# ─── Source file extensions (by ecosystem) ───────────────────── +# +# Only source files are scored — configs, lockfiles, and binaries are +# excluded so the ranker focuses on code a developer reads. + +_SOURCE_EXTENSIONS = frozenset({ + # Python + ".py", + # JavaScript / TypeScript + ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", + # Go + ".go", + # Rust + ".rs", + # Java / Kotlin + ".java", ".kt", ".kts", + # C / C++ + ".c", ".h", ".cpp", ".hpp", ".cc", ".cxx", + # C# + ".cs", + # Ruby + ".rb", + # PHP + ".php", + # Swift + ".swift", + # Scala + ".scala", + # Dart + ".dart", + # Lua + ".lua", + # Shell (only top-level scripts, not nested) + ".sh", + # Vue / Svelte + ".vue", ".svelte", +}) + + +# ─── Skip rules ──────────────────────────────────────────────── +# +# A file is skipped if any rule matches. Rules are checked in order; +# the first match wins. + +_SKIP_FILENAME_PATTERNS = ( + "test_", "_test.", ".test.", ".spec.", "_spec.", + "conftest.", "fixtures.", "__tests__", + ".generated.", ".gen.", ".min.", ".bundle.", +) + +_SKIP_DIR_PATTERNS = ( + "test", "tests", "__tests__", "__specs__", "spec", "specs", + "migrations", "alembic", "versions", + "dist", "build", "out", "target", "node_modules", "vendor", + ".next", ".nuxt", ".output", ".turbo", ".cache", + ".git", ".hg", ".svn", "__pycache__", ".venv", "venv", "env", + "coverage", ".nyc_output", ".pytest_cache", ".mypy_cache", ".ruff_cache", +) + +# Directory context bonuses — files in these dirs get extra points. +_DIR_CONTEXT_BONUS = { + "src": 15, "lib": 12, "app": 12, "cmd": 12, "internal": 10, + "server": 10, "api": 8, "core": 8, "main": 6, + "pages": 5, "routes": 5, "controllers": 5, "handlers": 5, +} + +# Filename pattern bonuses — applied to the basename (without ext). +_FILENAME_BONUS = { + "main": 25, "app": 22, "index": 20, "server": 18, + "cli": 15, "run": 15, "start": 15, "entry": 12, + "init": 8, "setup": 6, "config": 5, +} + + +def _should_skip(rel_path: str, basename: str) -> bool: + """Return True if the file should be excluded from ranking.""" + lower_base = basename.lower() + for pat in _SKIP_FILENAME_PATTERNS: + if pat in lower_base: + return True + # Check directory components in the relative path. + parts = rel_path.replace("\\", "/").lower().split("/") + for part in parts[:-1]: # exclude the filename itself + for skip_dir in _SKIP_DIR_PATTERNS: + if part == skip_dir: + return True + return False + + +def _count_lines(path: str, max_bytes: int = 512 * 1024) -> int: + """Count lines in a file, capped at max_bytes to avoid huge files.""" + try: + with open(path, "rb") as f: + data = f.read(max_bytes) + return data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0) + except OSError: + return 0 + + +def _line_count_score(lines: int) -> float: + """Score line count on a 0–20 scale. + + Sweet spot: 50–500 lines (full 20 points). Below 50: too small to + be informative. Above 500: too long for a first read. + """ + if lines == 0: + return 0.0 + if 50 <= lines <= 500: + return 20.0 + if lines < 50: + return (lines / 50.0) * 20.0 + # Above 500: decay gradually, floor at 5 points. + excess = min(lines - 500, 2000) + return max(5.0, 20.0 - (excess / 2000.0) * 15.0) + + +def _depth_score(rel_path: str) -> float: + """Score directory depth on a 0–15 scale (shallower = higher).""" + depth = rel_path.replace("\\", "/").count("/") + if depth == 0: + return 15.0 + if depth == 1: + return 12.0 + if depth == 2: + return 8.0 + if depth == 3: + return 4.0 + return 1.0 + + +def _filename_bonus(basename: str) -> float: + """Return bonus points for high-priority filenames (0–25).""" + stem = os.path.splitext(basename)[0].lower() + for pattern, bonus in _FILENAME_BONUS.items(): + if stem == pattern or stem.startswith(pattern + ".") or stem.startswith(pattern + "_"): + return float(bonus) + return 0.0 + + +def _dir_context_bonus(rel_path: str) -> float: + """Return bonus points for files in high-value directories (0–15).""" + parts = rel_path.replace("\\", "/").lower().split("/") + bonus = 0.0 + for part in parts[:-1]: + bonus = max(bonus, _DIR_CONTEXT_BONUS.get(part, 0.0)) + return bonus + + +def _score_file(rel_path: str, abs_path: str) -> Tuple[float, str]: + """Score a single file and return ``(score, reason_string)``.""" + basename = os.path.basename(rel_path) + lines = _count_lines(abs_path) + + fb = _filename_bonus(basename) + dc = _dir_context_bonus(rel_path) + dp = _depth_score(rel_path) + lc = _line_count_score(lines) + score = fb + dc + dp + lc + + parts: List[str] = [] + if fb: + parts.append(f"filename={basename.split('.')[0]}") + if dc: + parts.append(f"dir={rel_path.replace(chr(92),'/').split('/')[0]}") + if dp >= 12: + parts.append("shallow depth") + if 50 <= lines <= 500: + parts.append(f"{lines} lines") + elif lines: + parts.append(f"{lines} lines") + reason = ", ".join(parts) if parts else "general source file" + return score, reason + + +# @FLOW: ORIENT_START_HERE +# @CALLS: _should_skip() -> exclude tests/migrations/generated +# _score_file() -> weighted score + reason string +# @MUTATES: (none — pure read) + + +def rank_start_here_files( + workspace: str, top_n: int = 8, max_files: int = 5000 +) -> List[Dict[str, Any]]: + """Rank source files by reading priority and return the top N. + + Args: + workspace: Absolute path to the project root. + top_n: Maximum number of files to return (default 8). + max_files: Safety cap on total files scanned (default 5000). + + Returns: + List of dicts, highest score first, each:: + + { + "path": "src/app/page.tsx", + "score": 87, + "reason": "filename=page, dir=src, shallow depth, 120 lines" + } + """ + workspace = os.path.abspath(workspace) + scored: List[Dict[str, Any]] = [] + scanned = 0 + + for root, dirs, files in os.walk(workspace): + # Prune skip directories in-place so os.walk doesn't descend. + dirs[:] = [ + d for d in dirs + if d.lower() not in _SKIP_DIR_PATTERNS and not d.startswith(".") + ] + for fname in files: + if scanned >= max_files: + _logger.debug( + "[orient/file_ranker] max_files=%d reached, stopping", max_files + ) + break + ext = os.path.splitext(fname)[1].lower() + if ext not in _SOURCE_EXTENSIONS: + continue + abs_path = os.path.join(root, fname) + rel_path = os.path.relpath(abs_path, workspace) + if _should_skip(rel_path, fname): + continue + score, reason = _score_file(rel_path, abs_path) + scored.append({ + "path": rel_path.replace("\\", "/"), + "score": int(round(score)), + "reason": reason, + }) + scanned += 1 + if scanned >= max_files: + break + + scored.sort(key=lambda x: x["score"], reverse=True) + return scored[:top_n] diff --git a/scripts/orient_lib/framework_db.py b/scripts/orient_lib/framework_db.py new file mode 100644 index 00000000..ad81836a --- /dev/null +++ b/scripts/orient_lib/framework_db.py @@ -0,0 +1,493 @@ +# @WHO: scripts/orient_lib/framework_db.py +# @WHAT: Data-driven framework detection — ecosystem lookup table +# @PART: orient +# @ENTRY: detect_frameworks_brief() + +""" +Framework detection for the ``codelens orient`` command. + +Lives in the ``orient_lib`` sub-package (not ``orient``) to avoid a Python +import collision with ``scripts/commands/orient.py``. + +Data-driven lookup table: each framework is defined by the package keys +that signal its presence in a manifest file (``package.json``, +``pyproject.toml``, ``requirements.txt``, ``go.mod``, ``Cargo.toml``, +``pom.xml`` / ``build.gradle``). Adding a new framework = one entry in +the ``ECOSYSTEM_FRAMEWORKS`` table — no code changes elsewhere. + +The detector reads manifest files from the filesystem only (no +subprocess, no network). It returns a structured brief matching the +``orient`` output schema: + + { + "ecosystem": "Node.js", + "primary": "Next.js", + "secondary": ["Prisma", "TailwindCSS", "Jest"], + "summary": "Full-stack React app with ORM and CSS framework" + } + +Reference: issue #160, ported from codeglance's framework-detector.ts +(rewritten in Python idioms, no code copied). +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from typing import Any, Dict, List, Optional, Tuple + +__all__ = ["ECOSYSTEM_FRAMEWORKS", "detect_frameworks_brief"] + +_logger = logging.getLogger("codelens.orient.framework_db") + + +# ─── Framework Lookup Table ──────────────────────────────────── +# +# Each entry maps a framework name to the package keys that signal it. +# The ``ecosystem`` groups frameworks so the detector can report a +# single primary ecosystem. ``priority`` controls which framework is +# reported as ``primary`` when multiple are present (higher = primary). +# +# Adding a new framework: add one entry here. No other code changes. + +ECOSYSTEM_FRAMEWORKS: Dict[str, List[Dict[str, Any]]] = { + "Node.js": [ + {"name": "Next.js", "packages": ["next"], "priority": 100, + "summary": "Full-stack React framework with SSR/SSG"}, + {"name": "Remix", "packages": ["@remix-run/react"], "priority": 100, + "summary": "Full-stack React framework with nested routing"}, + {"name": "Nuxt", "packages": ["nuxt"], "priority": 100, + "summary": "Full-stack Vue framework"}, + {"name": "Gatsby", "packages": ["gatsby"], "priority": 95, + "summary": "Static site generator (React)"}, + {"name": "React", "packages": ["react", "react-dom"], "priority": 80, + "summary": "UI library (React)"}, + {"name": "Vue", "packages": ["vue"], "priority": 80, + "summary": "UI library (Vue)"}, + {"name": "Svelte", "packages": ["svelte"], "priority": 80, + "summary": "UI compiler (Svelte)"}, + {"name": "Angular", "packages": ["@angular/core"], "priority": 90, + "summary": "Full-stack SPA framework (Angular)"}, + {"name": "Express", "packages": ["express"], "priority": 70, + "summary": "Node.js web server"}, + {"name": "Fastify", "packages": ["fastify"], "priority": 70, + "summary": "Node.js web server (Fastify)"}, + {"name": "NestJS", "packages": ["@nestjs/core"], "priority": 85, + "summary": "Node.js enterprise framework (NestJS)"}, + {"name": "Koa", "packages": ["koa"], "priority": 65, + "summary": "Node.js web server (Koa)"}, + {"name": "Hono", "packages": ["hono"], "priority": 65, + "summary": "Edge-first web framework (Hono)"}, + {"name": "Prisma", "packages": ["prisma", "@prisma/client"], "priority": 50, + "summary": "TypeScript ORM (Prisma)"}, + {"name": "TypeORM", "packages": ["typeorm"], "priority": 45, + "summary": "TypeScript ORM (TypeORM)"}, + {"name": "TailwindCSS", "packages": ["tailwindcss"], "priority": 40, + "summary": "Utility-first CSS framework"}, + {"name": "Jest", "packages": ["jest"], "priority": 30, + "summary": "JavaScript test runner"}, + {"name": "Vitest", "packages": ["vitest"], "priority": 30, + "summary": "Vite-native test runner"}, + {"name": "Playwright", "packages": ["@playwright/test"], "priority": 30, + "summary": "E2E browser test framework"}, + {"name": "Vite", "packages": ["vite"], "priority": 35, + "summary": "Frontend build tool / dev server"}, + {"name": "Webpack", "packages": ["webpack"], "priority": 25, + "summary": "Module bundler"}, + {"name": "ESLint", "packages": ["eslint"], "priority": 20, + "summary": "JavaScript linter"}, + {"name": "Electron", "packages": ["electron"], "priority": 75, + "summary": "Desktop app framework (Electron)"}, + ], + "Python": [ + {"name": "FastAPI", "packages": ["fastapi"], "priority": 90, + "summary": "Async web API framework (FastAPI)"}, + {"name": "Flask", "packages": ["flask"], "priority": 80, + "summary": "Lightweight web framework (Flask)"}, + {"name": "Django", "packages": ["django"], "priority": 95, + "summary": "Batteries-included web framework (Django)"}, + {"name": "Pyramid", "packages": ["pyramid"], "priority": 70, + "summary": "Web framework (Pyramid)"}, + {"name": "Tornado", "packages": ["tornado"], "priority": 70, + "summary": "Async web framework (Tornado)"}, + {"name": "Sanic", "packages": ["sanic"], "priority": 75, + "summary": "Fast async web framework (Sanic)"}, + {"name": "Starlette", "packages": ["starlette"], "priority": 60, + "summary": "Lightweight ASGI framework (Starlette)"}, + {"name": "Aiohttp", "packages": ["aiohttp"], "priority": 65, + "summary": "Async HTTP client/server (aiohttp)"}, + {"name": "Pydantic", "packages": ["pydantic"], "priority": 40, + "summary": "Data validation (Pydantic)"}, + {"name": "SQLAlchemy", "packages": ["sqlalchemy"], "priority": 45, + "summary": "Python ORM (SQLAlchemy)"}, + {"name": "Tortoise ORM", "packages": ["tortoise-orm"], "priority": 40, + "summary": "Async ORM (Tortoise)"}, + {"name": "Celery", "packages": ["celery"], "priority": 55, + "summary": "Distributed task queue (Celery)"}, + {"name": "pytest", "packages": ["pytest"], "priority": 30, + "summary": "Python test runner"}, + {"name": "NumPy", "packages": ["numpy"], "priority": 35, + "summary": "Numerical computing (NumPy)"}, + {"name": "Pandas", "packages": ["pandas"], "priority": 35, + "summary": "Data analysis (Pandas)"}, + {"name": "Scikit-learn", "packages": ["scikit-learn"], "priority": 50, + "summary": "Machine learning (scikit-learn)"}, + {"name": "PyTorch", "packages": ["torch"], "priority": 55, + "summary": "Deep learning (PyTorch)"}, + {"name": "TensorFlow", "packages": ["tensorflow"], "priority": 55, + "summary": "Deep learning (TensorFlow)"}, + {"name": "LangChain", "packages": ["langchain"], "priority": 60, + "summary": "LLM application framework (LangChain)"}, + ], + "Go": [ + {"name": "Gin", "packages": ["github.com/gin-gonic/gin"], "priority": 85, + "summary": "HTTP web framework (Gin)"}, + {"name": "Echo", "packages": ["github.com/labstack/echo"], "priority": 80, + "summary": "HTTP web framework (Echo)"}, + {"name": "Fiber", "packages": ["github.com/gofiber/fiber"], "priority": 80, + "summary": "Express-inspired web framework (Fiber)"}, + {"name": "Chi", "packages": ["github.com/go-chi/chi"], "priority": 75, + "summary": "Lightweight HTTP router (Chi)"}, + {"name": "GORM", "packages": ["gorm.io/gorm"], "priority": 50, + "summary": "Go ORM (GORM)"}, + {"name": "sqlx", "packages": ["github.com/jmoiron/sqlx"], "priority": 40, + "summary": "SQL extensions (sqlx)"}, + {"name": "Cobra", "packages": ["github.com/spf13/cobra"], "priority": 60, + "summary": "CLI framework (Cobra)"}, + {"name": "Wire", "packages": ["github.com/google/wire"], "priority": 35, + "summary": "Dependency injection (Wire)"}, + ], + "Rust": [ + {"name": "Axum", "packages": ["axum"], "priority": 90, + "summary": "Web framework (Axum)"}, + {"name": "Actix Web", "packages": ["actix-web"], "priority": 90, + "summary": "Web framework (Actix)"}, + {"name": "Rocket", "packages": ["rocket"], "priority": 85, + "summary": "Web framework (Rocket)"}, + {"name": "Warp", "packages": ["warp"], "priority": 80, + "summary": "Web framework (Warp)"}, + {"name": "Tokio", "packages": ["tokio"], "priority": 70, + "summary": "Async runtime (Tokio)"}, + {"name": "Serde", "packages": ["serde"], "priority": 40, + "summary": "Serialization (Serde)"}, + {"name": "Diesel", "packages": ["diesel"], "priority": 50, + "summary": "ORM (Diesel)"}, + {"name": "Sqlx", "packages": ["sqlx"], "priority": 45, + "summary": "Async SQL (Sqlx)"}, + {"name": "Clap", "packages": ["clap"], "priority": 55, + "summary": "CLI parser (Clap)"}, + ], + "Java": [ + {"name": "Spring Boot", "packages": [ + "org.springframework.boot:spring-boot", + "org.springframework.boot", + ], "priority": 100, + "summary": "Enterprise application framework (Spring Boot)"}, + {"name": "Spring", "packages": ["org.springframework:spring-core"], + "priority": 90, + "summary": "Application framework (Spring)"}, + {"name": "Quarkus", "packages": ["io.quarkus"], "priority": 95, + "summary": "Cloud-native Java (Quarkus)"}, + {"name": "Micronaut", "packages": ["io.micronaut"], "priority": 95, + "summary": "Microservice framework (Micronaut)"}, + {"name": "Vert.x", "packages": ["io.vertx"], "priority": 80, + "summary": "Reactive toolkit (Vert.x)"}, + {"name": "Javalin", "packages": ["io.javalin"], "priority": 75, + "summary": "Lightweight web framework (Javalin)"}, + {"name": "Hibernate", "packages": ["org.hibernate"], "priority": 50, + "summary": "ORM (Hibernate)"}, + {"name": "JUnit", "packages": ["junit:junit", "org.junit.jupiter"], + "priority": 30, + "summary": "Test framework (JUnit)"}, + ], +} + + +# ─── Manifest Parsers ────────────────────────────────────────── + + +def _read_text(path: str, max_bytes: int = 256 * 1024) -> Optional[str]: + """Read a file as UTF-8 text, return None on any I/O error.""" + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read(max_bytes) + except OSError: + return None + + +def _parse_package_json(workspace: str) -> Dict[str, str]: + """Extract merged dependencies from package.json (deps + devDeps).""" + path = os.path.join(workspace, "package.json") + text = _read_text(path) + if not text: + return {} + try: + data = json.loads(text) + except json.JSONDecodeError: + _logger.warning("[orient/framework_db] invalid package.json at %s", path) + return {} + deps: Dict[str, str] = {} + deps.update(data.get("dependencies", {}) or {}) + deps.update(data.get("devDependencies", {}) or {}) + deps.update(data.get("peerDependencies", {}) or {}) + return deps + + +def _parse_pyproject(workspace: str) -> Dict[str, str]: + """Extract dependencies from pyproject.toml ([project.dependencies] + and [tool.poetry.dependencies]).""" + path = os.path.join(workspace, "pyproject.toml") + text = _read_text(path) + if not text: + return {} + deps: Dict[str, str] = {} + # PEP 621: [project.dependencies] is a list of "name==version" strings. + # The list may span multiple lines, so we search for ``dependencies = [`` + # and capture up to the closing ``]`` on its own line (DOTALL). + pep621_block = re.search( + r"^\s*\[project\]\s*$(.*?)(?=^\s*\[|\Z)", + text, re.MULTILINE | re.DOTALL, + ) + if pep621_block: + block_text = pep621_block.group(1) + m = re.search( + r'^\s*dependencies\s*=\s*\[(.*?)\]', + block_text, re.MULTILINE | re.DOTALL, + ) + if m: + # Match valid PEP 508 package names: starts with alnum/underscore, + # followed by alnum/underscore/dot/hyphen. Stops at version + # specifiers (>=, ==, ~=, !=), extras ([extra]), or separators. + for item in re.findall(r'["\']([A-Za-z0-9_][A-Za-z0-9_.\-]*)', m.group(1)): + name = item.strip().lower() + if name: + deps[name] = "" + # Poetry: [tool.poetry.dependencies] table. + poetry_block = re.search( + r"^\s*\[tool\.poetry\.dependencies\]\s*$(.*?)(?=^\s*\[|\Z)", + text, re.MULTILINE | re.DOTALL, + ) + if poetry_block: + for line in poetry_block.group(1).splitlines(): + m = re.match(r'^\s*([A-Za-z0-9_.\-]+)\s*=', line) + if m and m.group(1).lower() != "python": + deps[m.group(1).lower().replace("_", "-")] = "" + return deps + + +def _parse_requirements(workspace: str) -> Dict[str, str]: + """Extract package names from requirements.txt (best-effort).""" + path = os.path.join(workspace, "requirements.txt") + text = _read_text(path) + if not text: + return {} + deps: Dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + # Strip environment markers and version specifiers. + name = re.split(r"[=<>!~\[; ]", line, maxsplit=1)[0].strip() + if name: + deps[name.lower().replace("_", "-")] = "" + return deps + + +def _parse_go_mod(workspace: str) -> Dict[str, str]: + """Extract module paths from go.mod require block.""" + path = os.path.join(workspace, "go.mod") + text = _read_text(path) + if not text: + return {} + deps: Dict[str, str] = {} + in_require = False + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("require"): + # Block require: ``require (`` — must check this BEFORE the + # single-line match, because ``require (`` also matches + # ``^require\s+\S+`` with group = "(", which is not a dep. + if stripped.endswith("("): + in_require = True + continue + # Single-line require: ``require foo v1.0.0`` + m = re.match(r'^require\s+("[^"]+"|[A-Za-z0-9./_-]+)', stripped) + if m: + dep = m.group(1).strip('"') + if dep and dep != "(": + deps[dep] = "" + continue + if in_require: + if stripped.startswith(")"): + in_require = False + continue + # ``github.com/gin-gonic/gin v1.9.0`` — take the first token + # (the module path). Version is the second token. + m = re.match(r'^("[^"]+"|[A-Za-z0-9._/-]+)', stripped) + if m: + dep = m.group(1).strip('"') + if dep and dep != "(": + deps[dep] = "" + return deps + + +def _parse_cargo_toml(workspace: str) -> Dict[str, str]: + """Extract crate names from Cargo.toml [dependencies].""" + path = os.path.join(workspace, "Cargo.toml") + text = _read_text(path) + if not text: + return {} + deps: Dict[str, str] = {} + block = re.search( + r"^\s*\[dependencies\]\s*$(.*?)(?=^\s*\[|\Z)", + text, re.MULTILINE | re.DOTALL, + ) + if block: + for line in block.group(1).splitlines(): + m = re.match(r'^\s*([A-Za-z0-9_\-]+)\s*=', line) + if m: + deps[m.group(1).lower()] = "" + return deps + + +def _parse_maven_or_gradle(workspace: str) -> Dict[str, str]: + """Extract Java dependency coordinates from pom.xml or build.gradle.""" + deps: Dict[str, str] = {} + # pom.xml — foo... + pom_path = os.path.join(workspace, "pom.xml") + pom_text = _read_text(pom_path) + if pom_text: + for m in re.finditer( + r"\s*([^<]+?)\s*\s*" + r"\s*([^<]+?)\s*", + pom_text, + ): + coords = f"{m.group(1)}:{m.group(2)}" + deps[coords] = "" + # Also index the bare artifactId so partial matches work. + deps[m.group(2)] = "" + # build.gradle — 'group:artifact:version' or implementation 'group:artifact' + gradle_path = os.path.join(workspace, "build.gradle") + gradle_text = _read_text(gradle_path) + if not gradle_text: + gradle_path = os.path.join(workspace, "build.gradle.kts") + gradle_text = _read_text(gradle_path) + if gradle_text: + for m in re.finditer(r"([A-Za-z0-9._\-]+):([A-Za-z0-9._\-]+)", gradle_text): + coords = f"{m.group(1)}:{m.group(2)}" + deps[coords] = "" + deps[m.group(2)] = "" + return deps + + +# ─── Detection ───────────────────────────────────────────────── + + +def _collect_dependencies(workspace: str) -> Tuple[Dict[str, Dict[str, str]], str]: + """Collect dependencies per ecosystem and pick the primary ecosystem. + + Returns ``(per_ecosystem_deps, primary_ecosystem)`` where + ``primary_ecosystem`` is the ecosystem with the most detected + framework hits (ties broken by the order in ECOSYSTEM_FRAMEWORKS). + """ + parsers = { + "Node.js": _parse_package_json, + "Python": lambda w: {**_parse_pyproject(w), **_parse_requirements(w)}, + "Go": _parse_go_mod, + "Rust": _parse_cargo_toml, + "Java": _parse_maven_or_gradle, + } + per_ecosystem: Dict[str, Dict[str, str]] = {} + hit_counts: Dict[str, int] = {} + for ecosystem, parser in parsers.items(): + deps = parser(workspace) + per_ecosystem[ecosystem] = deps + # Count how many known framework packages appear in this ecosystem. + hits = 0 + for fw in ECOSYSTEM_FRAMEWORKS.get(ecosystem, []): + for pkg in fw["packages"]: + if pkg in deps or pkg.lower() in deps: + hits += 1 + break + if hits: + hit_counts[ecosystem] = hits + if not hit_counts: + # Fall back: pick whichever ecosystem had any manifest file. + for ecosystem, deps in per_ecosystem.items(): + if deps: + return per_ecosystem, ecosystem + return per_ecosystem, "Unknown" + primary_ecosystem = max( + hit_counts, key=lambda e: (hit_counts[e], -list(ECOSYSTEM_FRAMEWORKS).index(e)) + ) + return per_ecosystem, primary_ecosystem + + +def _match_frameworks( + ecosystem: str, deps: Dict[str, str] +) -> List[Dict[str, Any]]: + """Return framework entries whose packages appear in ``deps``.""" + matched: List[Dict[str, Any]] = [] + for fw in ECOSYSTEM_FRAMEWORKS.get(ecosystem, []): + for pkg in fw["packages"]: + if pkg in deps or pkg.lower() in deps: + matched.append(fw) + break + matched.sort(key=lambda f: f.get("priority", 0), reverse=True) + return matched + + +def _build_summary( + ecosystem: str, primary: Optional[str], secondary: List[str] +) -> str: + """Human-readable one-line summary of the detected stack.""" + parts: List[str] = [] + if primary: + parts.append(primary) + if secondary: + parts.append(f"with {', '.join(secondary[:3])}") + if not parts: + return f"{ecosystem} project (no recognized frameworks)" + return f"{ecosystem} project using " + " ".join(parts) + + +# @FLOW: ORIENT_FRAMEWORK +# @CALLS: _collect_dependencies() -> per-ecosystem deps + primary ecosystem +# _match_frameworks() -> framework list for primary ecosystem +# _build_summary() -> one-line stack summary +# @MUTATES: (none — pure read) + + +def detect_frameworks_brief(workspace: str) -> Dict[str, Any]: + """Detect frameworks and return the orient ``framework`` block. + + Args: + workspace: Absolute path to the project root. + + Returns: + Dict matching the orient output schema:: + + { + "ecosystem": "Node.js", + "primary": "Next.js" | null, + "secondary": ["Prisma", "Jest", ...], + "summary": "Node.js project using Next.js with Prisma, Jest" + } + """ + workspace = os.path.abspath(workspace) + per_ecosystem, primary_ecosystem = _collect_dependencies(workspace) + deps = per_ecosystem.get(primary_ecosystem, {}) + matched = _match_frameworks(primary_ecosystem, deps) + + primary = matched[0]["name"] if matched else None + secondary = [fw["name"] for fw in matched[1:]] + summary = _build_summary(primary_ecosystem, primary, secondary) + + return { + "ecosystem": primary_ecosystem, + "primary": primary, + "secondary": secondary, + "summary": summary, + } diff --git a/scripts/orient_lib/manifest_parser.py b/scripts/orient_lib/manifest_parser.py new file mode 100644 index 00000000..1efdedb8 --- /dev/null +++ b/scripts/orient_lib/manifest_parser.py @@ -0,0 +1,301 @@ +# @WHO: scripts/orient_lib/manifest_parser.py +# @WHAT: Run/build/test command extraction from manifest files +# @PART: orient +# @ENTRY: extract_commands() + +""" +Manifest parser for the ``codelens orient`` command. + +Extracts run/build/test/dev commands from the project's manifest files +(``package.json`` scripts, ``Makefile``, ``pyproject.toml``, +``Cargo.toml``, ``go.mod``, ``pom.xml`` / ``build.gradle``). Each +extracted command is classified into a semantic ``kind``:: + + dev — start dev server / watch mode + build — compile / bundle the project + test — run the test suite + lint — run linter + deploy — deploy / publish + run — run the app (production) + other — anything else + +No subprocess is used — purely filesystem reads + regex parsing. + +Reference: issue #160. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from typing import Any, Dict, List, Optional + +__all__ = ["extract_commands"] + +_logger = logging.getLogger("codelens.orient.manifest_parser") + + +# ─── Classification rules ────────────────────────────────────── +# +# A script name maps to a ``kind`` based on the first matching keyword. +# Order matters — more specific kinds first (e.g. "test:e2e" → test +# before "e2e" → other). + +_KIND_RULES: List[tuple] = [ + ("dev", ("dev", "start", "serve", "watch", "nodemon")), + ("build", ("build", "compile", "bundle", "dist", "webpack", "vite")), + ("test", ("test", "spec", "coverage", "pytest", "jest", "vitest")), + ("lint", ("lint", "eslint", "ruff", "flake8", "mypy", "pylint", "check")), + ("deploy", ("deploy", "publish", "release", "ship")), + ("run", ("run", "exec", "start:prod", "prod")), +] + + +def _classify_script(name: str) -> str: + """Classify a script name into a semantic kind.""" + lower = name.lower() + for kind, keywords in _KIND_RULES: + for kw in keywords: + if kw in lower: + return kind + return "other" + + +def _read_text(path: str, max_bytes: int = 256 * 1024) -> Optional[str]: + """Read a file as UTF-8 text, return None on any I/O error.""" + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read(max_bytes) + except OSError: + return None + + +# ─── Per-manifest parsers ────────────────────────────────────── + + +def _parse_package_json_scripts(workspace: str) -> List[Dict[str, Any]]: + """Extract commands from package.json ``scripts`` block.""" + path = os.path.join(workspace, "package.json") + text = _read_text(path) + if not text: + return [] + try: + data = json.loads(text) + except json.JSONDecodeError: + _logger.warning("[orient/manifest_parser] invalid package.json at %s", path) + return [] + scripts = data.get("scripts", {}) or {} + commands: List[Dict[str, Any]] = [] + for name, cmd in scripts.items(): + if not isinstance(cmd, str) or not cmd.strip(): + continue + kind = _classify_script(name) + commands.append({ + "kind": kind, + "command": f"npm run {name}", + "description": f"package.json script: {name}", + "raw": cmd, + }) + return commands + + +def _parse_makefile(workspace: str) -> List[Dict[str, Any]]: + """Extract targets from Makefile (or makefile, GNUmakefile).""" + commands: List[Dict[str, Any]] = [] + for fname in ("Makefile", "makefile", "GNUmakefile"): + path = os.path.join(workspace, fname) + text = _read_text(path) + if not text: + continue + seen: set = set() + for line in text.splitlines(): + # Target line: ``name: deps`` — skip if it's a recipe line + # (starts with tab) or a variable assignment (``VAR =``). + if line.startswith("\t") or line.startswith(" "): + continue + m = re.match(r"^([A-Za-z0-9_.\-]+)\s*:\s*", line) + if not m: + continue + target = m.group(1) + if target in seen or target.startswith("."): + # Skip phony/automatic targets like .PHONY, .DEFAULT. + continue + seen.add(target) + kind = _classify_script(target) + commands.append({ + "kind": kind, + "command": f"make {target}", + "description": f"Makefile target: {target}", + "raw": "", + }) + break # Only parse the first Makefile found. + return commands + + +def _parse_pyproject_scripts(workspace: str) -> List[Dict[str, Any]]: + """Extract ``[project.scripts]`` console entry points from pyproject.toml.""" + path = os.path.join(workspace, "pyproject.toml") + text = _read_text(path) + if not text: + return [] + commands: List[Dict[str, Any]] = [] + # [project.scripts] block — name = "module:func" + block = re.search( + r"^\s*\[project\.scripts\]\s*$(.*?)(?=^\s*\[|\Z)", + text, re.MULTILINE | re.DOTALL, + ) + if block: + for line in block.group(1).splitlines(): + m = re.match(r'^\s*([A-Za-z0-9_.\-]+)\s*=\s*"([^"]+)"', line) + if m: + name, target = m.group(1), m.group(2) + commands.append({ + "kind": "run", + "command": name, + "description": f"console script -> {target}", + "raw": target, + }) + # Look for common dev/test commands in optional-dependencies or + # tool config — best-effort: if pytest is a dep, suggest pytest. + if "pytest" in text.lower(): + commands.append({ + "kind": "test", + "command": "pytest", + "description": "pytest test runner (detected in pyproject.toml)", + "raw": "", + }) + if "ruff" in text.lower(): + commands.append({ + "kind": "lint", + "command": "ruff check .", + "description": "ruff linter (detected in pyproject.toml)", + "raw": "", + }) + return commands + + +def _parse_cargo_alias(workspace: str) -> List[Dict[str, Any]]: + """Extract ``[alias]`` entries from Cargo.toml ``[cargo-aliases]``.""" + path = os.path.join(workspace, "Cargo.toml") + text = _read_text(path) + if not text: + return [] + commands: List[Dict[str, Any]] = [] + block = re.search( + r"^\s*\[alias\]\s*$(.*?)(?=^\s*\[|\Z)", + text, re.MULTILINE | re.DOTALL, + ) + if block: + for line in block.group(1).splitlines(): + m = re.match(r'^\s*([A-Za-z0-9_\-]+)\s*=\s*"([^"]+)"', line) + if m: + name = m.group(1) + kind = _classify_script(name) + commands.append({ + "kind": kind, + "command": f"cargo {name}", + "description": f"cargo alias: {name}", + "raw": m.group(2), + }) + # Standard cargo commands are always available — suggest the common ones. + commands.append({ + "kind": "build", "command": "cargo build", + "description": "Compile the project", "raw": "", + }) + commands.append({ + "kind": "test", "command": "cargo test", + "description": "Run tests", "raw": "", + }) + return commands + + +def _parse_go_directives(workspace: str) -> List[Dict[str, Any]]: + """Suggest standard ``go`` commands when a go.mod is present.""" + if not os.path.isfile(os.path.join(workspace, "go.mod")): + return [] + return [ + {"kind": "build", "command": "go build ./...", + "description": "Compile all packages", "raw": ""}, + {"kind": "test", "command": "go test ./...", + "description": "Run all tests", "raw": ""}, + {"kind": "run", "command": "go run .", + "description": "Run the main package", "raw": ""}, + ] + + +def _parse_maven_gradle(workspace: str) -> List[Dict[str, Any]]: + """Suggest Maven/Gradle build commands when a pom.xml/build.gradle exists.""" + commands: List[Dict[str, Any]] = [] + if os.path.isfile(os.path.join(workspace, "pom.xml")): + commands.extend([ + {"kind": "build", "command": "mvn package", + "description": "Maven build + package", "raw": ""}, + {"kind": "test", "command": "mvn test", + "description": "Maven test", "raw": ""}, + ]) + for gname in ("build.gradle", "build.gradle.kts"): + if os.path.isfile(os.path.join(workspace, gname)): + commands.extend([ + {"kind": "build", "command": "./gradlew build", + "description": "Gradle build", "raw": ""}, + {"kind": "test", "command": "./gradlew test", + "description": "Gradle test", "raw": ""}, + ]) + break + return commands + + +# ─── Public entry ────────────────────────────────────────────── + + +# @FLOW: ORIENT_COMMANDS +# @CALLS: _parse_package_json_scripts() -> npm run +# _parse_makefile() -> make +# _parse_pyproject_scripts() -> console scripts + pytest/ruff +# _parse_cargo_alias() -> cargo + cargo build/test +# _parse_go_directives() -> go build/test/run +# _parse_maven_gradle() -> mvn/gradlew build/test +# @MUTATES: (none — pure read) + + +def extract_commands(workspace: str) -> List[Dict[str, Any]]: + """Extract run/build/test commands from all manifest files. + + Args: + workspace: Absolute path to the project root. + + Returns: + List of command dicts, each:: + + { + "kind": "dev" | "build" | "test" | "lint" | "deploy" | "run" | "other", + "command": "npm run dev", + "description": "package.json script: dev", + "raw": "next dev" + } + + The ``raw`` field holds the underlying script string when + available (empty for inferred commands). Commands are + de-duplicated by ``(kind, command)``. + """ + workspace = os.path.abspath(workspace) + all_commands: List[Dict[str, Any]] = [] + all_commands.extend(_parse_package_json_scripts(workspace)) + all_commands.extend(_parse_makefile(workspace)) + all_commands.extend(_parse_pyproject_scripts(workspace)) + all_commands.extend(_parse_cargo_alias(workspace)) + all_commands.extend(_parse_go_directives(workspace)) + all_commands.extend(_parse_maven_gradle(workspace)) + + # De-duplicate by (kind, command) — keep the first occurrence. + seen: set = set() + deduped: List[Dict[str, Any]] = [] + for cmd in all_commands: + key = (cmd["kind"], cmd["command"]) + if key in seen: + continue + seen.add(key) + deduped.append(cmd) + return deduped diff --git a/skill.json b/skill.json index 0e6de317..d236d4ae 100755 --- a/skill.json +++ b/skill.json @@ -1,7 +1,7 @@ { "name": "codelens", "version": "8.2.0", - "description": "Live Codebase Reference Intelligence. 75 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. 71 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_orient.py b/tests/test_orient.py new file mode 100644 index 00000000..08d33436 --- /dev/null +++ b/tests/test_orient.py @@ -0,0 +1,389 @@ +"""Tests for the ``codelens orient`` command (issue #160).""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from typing import Dict, Any + +import pytest + +SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'scripts') +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) + + +# ─── Fixtures ────────────────────────────────────────────────── + + +@pytest.fixture +def workspace(): + """Empty temp workspace directory.""" + d = tempfile.mkdtemp(prefix='codelens_orient_test_') + yield d + import shutil + shutil.rmtree(d, ignore_errors=True) + + +def _write(path: str, content: str) -> None: + """Write a file, creating parent directories as needed.""" + os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + + +@pytest.fixture +def node_workspace(workspace): + """Node.js project with Next.js + React + Prisma + Jest + Tailwind.""" + _write(os.path.join(workspace, 'package.json'), json.dumps({ + "name": "test-node-app", + "main": "src/index.ts", + "scripts": { + "dev": "next dev", + "build": "next build", + "test": "jest", + "lint": "eslint .", + }, + "dependencies": { + "next": "14.0.0", + "react": "18.0.0", + "react-dom": "18.0.0", + "@prisma/client": "5.0.0", + }, + "devDependencies": { + "prisma": "5.0.0", + "jest": "29.0.0", + "tailwindcss": "3.0.0", + "eslint": "8.0.0", + }, + })) + _write(os.path.join(workspace, 'src', 'index.ts'), + 'export default function App() { return null; }\n') + _write(os.path.join(workspace, 'src', 'app', 'page.tsx'), + 'export default function Page() { return
Hello
; }\n') + _write(os.path.join(workspace, 'jest.config.js'), 'module.exports = {};\n') + _write(os.path.join(workspace, '.eslintrc.json'), '{}\n') + _write(os.path.join(workspace, 'Dockerfile'), 'FROM node:18\n') + _write(os.path.join(workspace, '.env.example'), 'DATABASE_URL=\n') + os.makedirs(os.path.join(workspace, '.github', 'workflows'), exist_ok=True) + _write(os.path.join(workspace, '.github', 'workflows', 'ci.yml'), 'name: CI\n') + return workspace + + +@pytest.fixture +def python_workspace(workspace): + """Python project with FastAPI + SQLAlchemy + pytest + ruff.""" + _write(os.path.join(workspace, 'pyproject.toml'), """\ +[project] +name = "test-py-app" +version = "1.0.0" +dependencies = [ + "fastapi", + "uvicorn", + "sqlalchemy", +] + +[project.optional-dependencies] +dev = ["pytest", "ruff"] +""") + _write(os.path.join(workspace, 'main.py'), + 'from fastapi import FastAPI\napp = FastAPI()\n') + _write(os.path.join(workspace, 'pytest.ini'), '[pytest]\ntestpaths = tests\n') + _write(os.path.join(workspace, 'ruff.toml'), 'line-length = 120\n') + return workspace + + +@pytest.fixture +def go_workspace(workspace): + """Go project with Gin + GORM.""" + _write(os.path.join(workspace, 'go.mod'), """\ +module github.com/test/myapp + +go 1.21 + +require ( +\tgithub.com/gin-gonic/gin v1.9.0 +\tgorm.io/gorm v1.25.0 +) +""") + _write(os.path.join(workspace, 'main.go'), + 'package main\n\nimport "github.com/gin-gonic/gin"\n\nfunc main() { gin.Default() }\n') + return workspace + + +# ─── Tests: Framework Detection ──────────────────────────────── + + +class TestFrameworkDetection: + """Sub-feature A: framework detection across ecosystems.""" + + def test_nodejs_detects_nextjs_primary(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + fw = result['framework'] + assert fw['ecosystem'] == 'Node.js' + assert fw['primary'] == 'Next.js' + assert 'React' in fw['secondary'] + assert 'Prisma' in fw['secondary'] + assert 'TailwindCSS' in fw['secondary'] + + def test_python_detects_fastapi_primary(self, python_workspace): + from commands.orient import cmd_orient + result = cmd_orient(python_workspace) + fw = result['framework'] + assert fw['ecosystem'] == 'Python' + assert fw['primary'] == 'FastAPI' + assert 'SQLAlchemy' in fw['secondary'] + + def test_go_detects_gin_primary(self, go_workspace): + from commands.orient import cmd_orient + result = cmd_orient(go_workspace) + fw = result['framework'] + assert fw['ecosystem'] == 'Go' + assert fw['primary'] == 'Gin' + assert 'GORM' in fw['secondary'] + + def test_unknown_ecosystem_when_no_manifest(self, workspace): + from commands.orient import cmd_orient + result = cmd_orient(workspace) + assert result['framework']['ecosystem'] == 'Unknown' + assert result['framework']['primary'] is None + + +# ─── Tests: Command Extraction ───────────────────────────────── + + +class TestCommandExtraction: + """Sub-feature B: run/build/test command extraction.""" + + def test_nodejs_scripts_extracted(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + cmds = {c['kind']: c['command'] for c in result['commands']} + assert cmds.get('dev') == 'npm run dev' + assert cmds.get('build') == 'npm run build' + assert cmds.get('test') == 'npm run test' + assert cmds.get('lint') == 'npm run lint' + + def test_python_pytest_and_ruff_suggested(self, python_workspace): + from commands.orient import cmd_orient + result = cmd_orient(python_workspace) + cmds = {c['kind']: c['command'] for c in result['commands']} + assert cmds.get('test') == 'pytest' + assert cmds.get('lint') == 'ruff check .' + + def test_go_standard_commands(self, go_workspace): + from commands.orient import cmd_orient + result = cmd_orient(go_workspace) + cmds = {c['kind']: c['command'] for c in result['commands']} + assert cmds.get('build') == 'go build ./...' + assert cmds.get('test') == 'go test ./...' + assert cmds.get('run') == 'go run .' + + +# ─── Tests: Entry Point Detection ────────────────────────────── + + +class TestEntryPointDetection: + """Sub-feature C: entry point detection.""" + + def test_nodejs_entry_points(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + paths = [e['path'] for e in result['entry_points']] + assert 'src/index.ts' in paths + + def test_python_main_py_detected(self, python_workspace): + from commands.orient import cmd_orient + result = cmd_orient(python_workspace) + paths = [e['path'] for e in result['entry_points']] + assert 'main.py' in paths + + def test_go_main_go_detected(self, go_workspace): + from commands.orient import cmd_orient + result = cmd_orient(go_workspace) + paths = [e['path'] for e in result['entry_points']] + assert 'main.go' in paths + + def test_entry_points_max_eight(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + assert len(result['entry_points']) <= 8 + + +# ─── Tests: Start Here File Ranking ──────────────────────────── + + +class TestStartHereRanking: + """Sub-feature D: Start Here file ranking.""" + + def test_returns_at_most_top_n(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace, top=5) + assert len(result['start_here']) <= 5 + + def test_default_top_eight(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + assert len(result['start_here']) <= 8 + + def test_skips_test_files(self, workspace): + from commands.orient import cmd_orient + _write(os.path.join(workspace, 'main.py'), 'def main(): pass\n' * 20) + _write(os.path.join(workspace, 'test_main.py'), + 'def test_main(): assert True\n' * 20) + result = cmd_orient(workspace) + paths = [s['path'] for s in result['start_here']] + assert 'main.py' in paths + assert 'test_main.py' not in paths + + def test_skips_migrations(self, workspace): + from commands.orient import cmd_orient + _write(os.path.join(workspace, 'app.py'), 'def app(): pass\n' * 20) + _write(os.path.join(workspace, 'migrations', '001_init.py'), + 'def upgrade(): pass\n' * 20) + result = cmd_orient(workspace) + paths = [s['path'] for s in result['start_here']] + assert 'app.py' in paths + assert not any('migrations/' in p for p in paths) + + def test_score_is_integer(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + for item in result['start_here']: + assert isinstance(item['score'], int) + assert 0 <= item['score'] <= 100 + assert 'reason' in item + assert isinstance(item['reason'], str) + + +# ─── Tests: CI / Docker / Env Detection ──────────────────────── + + +class TestInfraDetection: + """Sub-feature E: CI/Docker/env detection.""" + + def test_ci_detected(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + assert result['infra']['ci'] is True + assert result['infra']['ci_count'] >= 1 + + def test_docker_detected(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + assert result['infra']['docker'] is True + + def test_env_file_detected(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + assert result['infra']['env_file'] is True + + def test_no_ci_when_absent(self, workspace): + from commands.orient import cmd_orient + result = cmd_orient(workspace) + assert result['infra']['ci'] is False + assert result['infra']['ci_count'] == 0 + assert result['infra']['docker'] is False + assert result['infra']['env_file'] is False + + def test_test_framework_jest(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + assert result['infra']['test_framework'] == 'jest' + + def test_test_framework_pytest(self, python_workspace): + from commands.orient import cmd_orient + result = cmd_orient(python_workspace) + assert result['infra']['test_framework'] == 'pytest' + + def test_linter_eslint(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + assert result['infra']['linter'] == 'eslint' + + def test_linter_ruff(self, python_workspace): + from commands.orient import cmd_orient + result = cmd_orient(python_workspace) + assert result['infra']['linter'] == 'ruff' + + +# ─── Tests: Output Schema ────────────────────────────────────── + + +class TestOutputSchema: + """Verify the output matches the schema in issue #160.""" + + def test_top_level_keys(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + assert result['status'] == 'ok' + assert 'workspace' in result + assert 'framework' in result + assert 'commands' in result + assert 'entry_points' in result + assert 'start_here' in result + assert 'infra' in result + + def test_framework_block_shape(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + fw = result['framework'] + assert 'ecosystem' in fw + assert 'primary' in fw + assert 'secondary' in fw + assert 'summary' in fw + assert isinstance(fw['secondary'], list) + + def test_command_entry_shape(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + for c in result['commands']: + assert 'kind' in c + assert 'command' in c + assert 'description' in c + assert c['kind'] in {'dev', 'build', 'test', 'lint', 'deploy', 'run', 'other'} + + def test_entry_point_shape(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + for e in result['entry_points']: + assert 'path' in e + assert 'type' in e + + def test_start_here_shape(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + for s in result['start_here']: + assert 'path' in s + assert 'score' in s + assert 'reason' in s + + def test_infra_block_shape(self, node_workspace): + from commands.orient import cmd_orient + result = cmd_orient(node_workspace) + infra = result['infra'] + assert 'ci' in infra + assert 'ci_count' in infra + assert 'docker' in infra + assert 'env_file' in infra + assert 'test_framework' in infra + assert 'linter' in infra + + +# ─── Tests: Compact rendering ────────────────────────────────── + + +class TestCompactFormat: + """``--format compact`` produces a single-line brief.""" + + def test_compact_render_is_single_line(self, node_workspace): + from commands.orient import _render_compact + from commands.orient import cmd_orient + brief = cmd_orient(node_workspace) + rendered = _render_compact(brief) + assert isinstance(rendered, str) + assert '\n' not in rendered + assert len(rendered) > 0