diff --git a/README.md b/README.md index 4a5e28b..74f8745 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ CodeLens consolidates what used to be ~78 separate commands into **12 umbrella c |---|---|---| | `scan` | scan (default) · rescan | Build/refresh the workspace graph. Everything else depends on this having run once. | | `search` | semantic (default) · symbol · regex · graph | The grep replacement. `pattern` comes **first**, workspace second — opposite of every other command below. See [gotcha](#a-gotcha-worth-memorizing). | -| `context` | orient (default) · outline · trace · context · diagnostics · overview · tags · flow | Orientation, file structure, call-chain tracing, rich symbol context, LSP diagnostics (`--file`), token-efficient symbol map, `@FLOW`/`@ENTRY` doc-tag audit, named-flow collection (`--check flow --name X`). | +| `context` | orient (default) · outline · trace · context · diagnostics · overview · tags · flow · source | Orientation, file structure, call-chain tracing, rich symbol context, LSP diagnostics (`--file`), token-efficient symbol map, `@FLOW`/`@ENTRY` doc-tag audit, named-flow collection (`--check flow --name X`), a function's source by name (`--check source --name X`). | | `deps` | affected · dependents · circular (default: all three) · import-snapshot · export-snapshot | Dependency graph: what's affected by a change, who imports what, circular imports, team snapshot sharing. | | `audit` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css · a11y (default: all) | Code quality. `dead-code` cross-checked against `context --check trace` before you trust it. `css` = deep CSS analysis, `a11y` = WCAG 2.1 accessibility. | | `security` | secrets · vuln-scan · taint · binary-scan · regex-audit (default: all) | Hardcoded secrets, CVE/OSV dependency scanning, AST taint analysis, ReDoS. **Taint is Python/JS/TS/TSX only** — no Rust source/sink rules yet. | diff --git a/SKILL-QUICK.md b/SKILL-QUICK.md index 25bb128..950ce3e 100755 --- a/SKILL-QUICK.md +++ b/SKILL-QUICK.md @@ -105,7 +105,7 @@ codelens search "pattern" . --mode regex --limit 5 --offset 10 --format compact |---|---| | `scan` | scan (default) · rescan | | `search "pattern" [workspace]` | semantic (default) · symbol · regex · graph — **pattern first, workspace second**, opposite of every command below | -| `context [workspace]` | orient (default) · outline · trace (`--name X --direction up\|down\|both`) · context (`--name X`) · diagnostics (`--file X`, LSP) · overview (symbol map) · tags (doc-tag audit) · flow (`--name X`, collect named flow) | +| `context [workspace]` | orient (default) · outline · trace (`--name X --direction up\|down\|both`) · context (`--name X`) · diagnostics (`--file X`, LSP) · overview (symbol map) · tags (doc-tag audit) · flow (`--name X`, collect named flow) · source (`--name X`, a function's source) | | `deps [workspace]` | affected (`--files ...`) · dependents (`--files ...`) · circular · import-snapshot (`--input path.gz`) · export-snapshot (`--output path.gz`) | | `audit [workspace]` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css · a11y | | `security [workspace]` | secrets · vuln-scan · taint · binary-scan · regex-audit | diff --git a/SKILL.md b/SKILL.md index 8e79db5..ac84e75 100755 --- a/SKILL.md +++ b/SKILL.md @@ -43,7 +43,7 @@ codelens guard --pre --file X → DROPPED |---|---| | `scan` | scan (default) · rescan | | `search` | semantic (default) · symbol · regex · graph — **`pattern` comes first, workspace second**, opposite of every other command here | -| `context` | orient (default) · outline · trace · context · diagnostics (LSP lint, needs `--file`) · overview (token-efficient symbol map) · tags (`@FLOW`/`@ENTRY` doc-tag audit) · flow (collect a named `@FLOW`'s scattered functions, `--name X`) | +| `context` | orient (default) · outline · trace · context · diagnostics (LSP lint, needs `--file`) · overview (token-efficient symbol map) · tags (`@FLOW`/`@ENTRY` doc-tag audit) · flow (collect a named `@FLOW`'s scattered functions, `--name X`) · source (a function's source by name, `--name X` — skip reading the whole file) | | `deps` | affected · dependents · circular (default: all three) · import-snapshot · export-snapshot | | `audit` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css (deep CSS) · a11y (WCAG 2.1) (default: all) | | `security` | secrets · vuln-scan · taint · binary-scan · regex-audit (default: all) | diff --git a/docs/design/0316-get-source.md b/docs/design/0316-get-source.md new file mode 100644 index 0000000..ef605dc --- /dev/null +++ b/docs/design/0316-get-source.md @@ -0,0 +1,71 @@ +# Design Doc: Get Function Source (`context --check source`) + +> **Status:** Accepted +> **Date:** 2026-07-18 +> **Author:** Wolfvin (BOS) / Claude Code +> **Related issues:** #316 +> **North-star:** CodeLens replaces the agent's grep + Read navigation (#279 agent-ergonomics) + +--- + +## Problem + +Found by dogfooding CodeLens on itself. The most common thing the agent still +does manually is **Read an entire file just to see one function's body**. +Nothing in CodeLens returns a function's source: + +- `context --check outline --file F` → `{name, line}` only, even `--detail full`. +- `context --check context --name X` → metadata (callers, advice), no body. + +So the agent reads the whole file. That is exactly what CodeLens exists to +replace. + +## Goal + +`context --check source --name X` returns just function X's source + its +`file:start-end` range. Read-only, deterministic. Command count stays 12. + +## Design + +Read-only compose, no new engine: + +1. **Locate X.** With `--file F`, outline F and match by name (no graph needed). + Otherwise resolve via `graph_model.find_nodes_by_name` (the graph populated + by a prior scan). +2. **Bound it.** The function runs from its start line to the line before the + next declaration in the file (`outline_engine` functions + classes), or EOF + for the last one. Trailing blank lines are trimmed. +3. **Slice.** Return those lines. + +### Why a next-declaration heuristic, not the parser's end line + +`outline_engine` does not expose a node end line, and reaching into the +tree-sitter layer to add one is a parser change (backend) out of scope for a +read-only command. The next-declaration boundary is exact for the common case +(a function followed by another declaration) and never guesses beyond the +file's own structure. A more precise end line via the parser is a possible +follow-up (a worker task, since it touches the parser). + +## Non-goals + +- **No parser change.** Boundary is heuristic on purpose (see above). +- **No cross-file dedup.** A name defined in N files returns N matches; the + agent narrows with `--file`. +- **Not for module-level code.** It returns a *function's* source; free code + between declarations may be included up to the next declaration — documented, + acceptable for "show me this function". + +## Agent-ergonomics note + +An unscanned workspace with no `--file` returns an explicit +`error_type: "no_graph"` message — never a silent-empty result. This is the +same discipline as #315: the agent must always tell an error from an empty. + +## Testing + +`--file` mode (no graph) fixtures: exact single-function slice, boundary stops +before the next declaration (no bleed), last function to EOF, trailing-blank +trim, unknown name → not-found, missing `--name` → error. Graph mode: same-name +in two files → two matches. No-graph-and-no-file → explicit error, not empty. +Verified by dogfooding: retrieved `_fn_of`'s 3-line body from a 105-line file +without reading the file. diff --git a/scripts/commands/context.py b/scripts/commands/context.py index 8e12886..e23cd65 100644 --- a/scripts/commands/context.py +++ b/scripts/commands/context.py @@ -66,6 +66,10 @@ "module": "commands.flow", "help": "Collect a named @FLOW's scattered functions into one view (--name X; issue #309)", }, + "source": { + "module": "commands.source", + "help": "Return a function's source by name — no need to read the whole file (--name X; issue #316)", + }, } ALL_CHECKS = list(_CHECKS.keys()) @@ -84,6 +88,7 @@ def add_args(parser): " overview Token-efficient hierarchical symbols map (issue #254)\n" " tags Audit @FLOW/@ENTRY/@PART doc-tags (issue #305)\n" " flow Collect a named @FLOW's scattered functions (--name X, issue #309)\n" + " source Return a function's source by name (--name X, issue #316)\n" "\n" "Examples:\n" " codelens context . # orient (default)\n" @@ -187,6 +192,9 @@ def _build_namespace(base_args, check_name: str) -> argparse.Namespace: ns.max_files = getattr(base_args, "max_files", None) or 200 elif check_name == "flow": ns.name = getattr(base_args, "name", None) + elif check_name == "source": + ns.name = getattr(base_args, "name", None) + ns.file = getattr(base_args, "file", None) return ns diff --git a/scripts/commands/source.py b/scripts/commands/source.py new file mode 100644 index 0000000..b8c0329 --- /dev/null +++ b/scripts/commands/source.py @@ -0,0 +1,135 @@ +# @WHO: scripts/commands/source.py +# @WHAT: `context --check source` — return a function's source by name +# @PART: command (sub-check of `context`) +# @ENTRY: execute() +"""`context --check source --name X` — a function's source, by name. + +The most direct replacement for "Read the whole file to see one function": +resolve X to its file and start line, bound it by the next declaration, and +return just those lines. Read-only. Boundaries are heuristic — the next +declaration in the file, or EOF — which is exact for the common case and +never guesses beyond a file's own structure. +""" + +import os +from typing import Any, Dict, List + +from outline_engine import get_file_outline + + +def add_args(parser): + """Register CLI arguments (workspace/name/file carried by the umbrella).""" + parser.add_argument( + "workspace", nargs="?", default=None, + help="Path to workspace root (auto-detected if omitted)", + ) + + +def _symbol_lines(outline: Dict) -> List[int]: + """Every declaration line in a file, sorted — the boundary candidates.""" + lines = [] + for section in ("functions", "classes"): + for entry in outline.get(section, []): + if isinstance(entry.get("line"), int): + lines.append(entry["line"]) + return sorted(set(lines)) + + +def _extract(abs_file: str, rel_file: str, workspace: str, + start: int, name: str) -> Dict[str, Any]: + """Slice one function's source from its start line to the next declaration.""" + res = get_file_outline(abs_file, workspace, "normal") + outline = res.get("outline") or {} + line_count = outline.get("line_count", 0) + + decl_lines = _symbol_lines(outline) + end = line_count + for ln in decl_lines: + if ln > start: + end = ln - 1 + break + + try: + with open(abs_file, "r", encoding="utf-8", errors="replace") as f: + file_lines = f.read().splitlines() + except OSError as e: + return {"symbol": name, "file": rel_file, "error": str(e)} + + body = file_lines[start - 1:end] + # Drop blank lines between this function and the next declaration. + while body and not body[-1].strip(): + body.pop() + end -= 1 + return { + "symbol": name, + "file": rel_file, + "start_line": start, + "end_line": end, + "source": "\n".join(body), + } + + +def execute(args, workspace) -> Dict[str, Any]: + """Return the source of function(s) named ``--name``. + + @FLOW: SOURCE_VIEW + @CALLS: graph_model.find_nodes_by_name(), outline_engine.get_file_outline() + @MUTATES: nothing (read-only) + """ + name = getattr(args, "name", None) + if not name: + return { + "status": "error", + "error": "source needs --name X (the function to show)", + "error_type": "missing_argument", + } + + workspace = os.path.abspath(workspace) if workspace else os.getcwd() + only_file = getattr(args, "file", None) + + # Resolve where X is defined: an explicit --file needs no graph; otherwise + # ask the call-graph (populated by a prior scan). + locations = [] # (abs_file, rel_file, start_line) + if only_file: + abs_file = only_file if os.path.isabs(only_file) else os.path.join(workspace, only_file) + res = get_file_outline(abs_file, workspace, "normal") + outline = res.get("outline") or {} + for fn in outline.get("functions", []): + if fn.get("name") == name and isinstance(fn.get("line"), int): + locations.append((abs_file, os.path.relpath(abs_file, workspace), fn["line"])) + else: + try: + from utils import default_db_path + import graph_model as gm + except Exception: + return {"status": "error", + "error": "graph unavailable; pass --file to locate the function", + "error_type": "no_graph"} + db = getattr(args, "db_path", None) or default_db_path(workspace) + if not db or not os.path.exists(db): + return {"status": "error", + "error": "no graph DB — scan the workspace first, or pass --file", + "error_type": "no_graph"} + for node in gm.find_nodes_by_name(name, db): + rel = node.get("file", "") + if not rel: + continue + abs_file = rel if os.path.isabs(rel) else os.path.join(workspace, rel) + line = node.get("line") + if isinstance(line, int) and line > 0 and os.path.exists(abs_file): + locations.append((abs_file, rel.replace("\\", "/"), line)) + + if not locations: + where = f" in {only_file}" if only_file else "" + return {"status": "ok", "symbol": name, "found": False, + "message": f"No function named '{name}' found{where}. " + "If the workspace was never scanned, run scan first or pass --file."} + + matches = [_extract(a, r, workspace, ln, name) for a, r, ln in locations] + return { + "status": "ok", + "symbol": name, + "found": True, + "count": len(matches), + "matches": matches, + } diff --git a/scripts/formatters/markdown.py b/scripts/formatters/markdown.py index 8d940ff..5d83e1e 100644 --- a/scripts/formatters/markdown.py +++ b/scripts/formatters/markdown.py @@ -145,6 +145,8 @@ def to_markdown(data: Any, command: str = "") -> str: _md_flow(data, lines) elif command == "flow-diff": _md_flow_diff(data, lines) + elif command == "source": + _md_source(data, lines) else: # Generic markdown for any command _md_generic(data, lines) @@ -152,6 +154,26 @@ def to_markdown(data: Any, command: str = "") -> str: return "\n".join(lines) +def _md_source(data: Dict, lines: list) -> None: + """Markdown for a function's source (`context --check source`, issue #316).""" + name = data.get("symbol", "") + if not data.get("found"): + lines.append(f"## Source: `{name}` — not found") + lines.append("") + lines.append(data.get("message", "")) + return + matches = data.get("matches", []) + plural = "" if len(matches) == 1 else f" ({len(matches)} definitions)" + lines.append(f"## Source: `{name}`{plural}") + for m in matches: + lines.append("") + lines.append(f"**{m.get('file', '')}:{m.get('start_line', '')}-{m.get('end_line', '')}**") + lines.append("") + lines.append("```") + lines.append(m.get("source", "")) + lines.append("```") + + def _md_flow_diff(data: Dict, lines: list) -> None: """Markdown for a named flow's shape change (`flow-diff`, issue #313).""" name = data.get("flow", "") diff --git a/tests/test_command_registry.py b/tests/test_command_registry.py index a12d66f..d04ee05 100644 --- a/tests/test_command_registry.py +++ b/tests/test_command_registry.py @@ -44,7 +44,7 @@ def test_every_command_module_registers(): "dead_code", "dependents", "diagnostics", "diff", "env_check", "export_snapshot", "flow", "flow_diff", "git_status", "graph_schema", "import_snapshot", "init", "lsp_status", "orient", "outline", "ownership", "perf_hint", - "query_graph", "regex_audit", "secrets", "side_effect", "smell", + "query_graph", "regex_audit", "secrets", "side_effect", "smell", "source", "staleness", "symbols_overview", "tags", "taint", "trace", "vuln_scan", } _UTILITY_MODULES |= _DEPRECATED_ALIAS_MODULES diff --git a/tests/test_get_source.py b/tests/test_get_source.py new file mode 100644 index 0000000..208c71f --- /dev/null +++ b/tests/test_get_source.py @@ -0,0 +1,125 @@ +""" +Tests for get-function-source (`context --check source`, issue #316). + +The direct replacement for "Read the whole file to see one function". Tests use +--file mode (no graph DB needed) for determinism, plus one graph-resolved case. +""" + +import os +import sys + +import pytest + +SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts") +sys.path.insert(0, SCRIPT_DIR) + +from commands import source as source_cmd # noqa: E402 + + +class _Args: + def __init__(self, name=None, file=None, db_path=None): + self.name = name + self.file = file + self.db_path = db_path + + +def _write(root, rel, text): + path = os.path.join(root, rel) + os.makedirs(os.path.dirname(path) or root, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + return path + + +SAMPLE = ( + "import os\n" # 1 + "\n" # 2 + "def first():\n" # 3 + ' """First."""\n' # 4 + " return 1\n" # 5 + "\n" # 6 + "\n" # 7 + "def second(x):\n" # 8 + " return x + 1\n" # 9 +) + + +@pytest.fixture +def ws(tmp_path): + root = str(tmp_path) + _write(root, "mod.py", SAMPLE) + return root + + +# ─── --file mode (no graph) ────────────────────────────── + +def test_extracts_just_the_named_function(ws): + out = source_cmd.execute(_Args(name="first", file="mod.py"), ws) + assert out["found"] is True + m = out["matches"][0] + assert m["start_line"] == 3 + assert m["source"] == 'def first():\n """First."""\n return 1' + + +def test_boundary_stops_before_next_declaration(ws): + out = source_cmd.execute(_Args(name="first", file="mod.py"), ws) + m = out["matches"][0] + assert "def second" not in m["source"] # must not bleed into the next fn + assert m["end_line"] == 5 # trailing blanks trimmed + + +def test_last_function_runs_to_eof(ws): + out = source_cmd.execute(_Args(name="second", file="mod.py"), ws) + m = out["matches"][0] + assert m["source"] == "def second(x):\n return x + 1" + + +def test_unknown_function_is_not_found(ws): + out = source_cmd.execute(_Args(name="nope", file="mod.py"), ws) + assert out["found"] is False + assert "nope" in out["message"] + + +def test_missing_name_is_an_error(ws): + out = source_cmd.execute(_Args(name=None, file="mod.py"), ws) + assert out["status"] == "error" + assert out["error_type"] == "missing_argument" + + +# ─── ambiguity ─────────────────────────────────────────── + +def test_same_name_in_two_files_via_graph(tmp_path): + root = str(tmp_path) + _write(root, "a.py", "def handler():\n return 'a'\n") + _write(root, "b.py", "def handler():\n return 'b'\n") + + # Populate the graph so name resolution finds both. + import json + cl = os.path.join(root, ".codelens") + os.makedirs(cl, exist_ok=True) + with open(os.path.join(cl, "backend.json"), "w", encoding="utf-8") as f: + json.dump({"nodes": [ + {"id": "a.py:1", "fn": "handler", "file": "a.py", "line": 1, + "ref_count": 0, "status": "active"}, + {"id": "b.py:1", "fn": "handler", "file": "b.py", "line": 1, + "ref_count": 0, "status": "active"}, + ], "edges": []}, f) + import graph_model as gm + gm.populate_graph_tables(root) + + out = source_cmd.execute(_Args(name="handler"), root) + assert out["found"] is True + assert out["count"] == 2 + files = {m["file"] for m in out["matches"]} + assert files == {"a.py", "b.py"} + + +def test_no_graph_and_no_file_is_a_clear_error(tmp_path): + """Never scanned, no --file: an explicit error, not silent-empty.""" + out = source_cmd.execute(_Args(name="handler"), str(tmp_path)) + assert out["status"] == "error" + assert out["error_type"] == "no_graph" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])