Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion SKILL-QUICK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
71 changes: 71 additions & 0 deletions docs/design/0316-get-source.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions scripts/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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"
Expand Down Expand Up @@ -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


Expand Down
135 changes: 135 additions & 0 deletions scripts/commands/source.py
Original file line number Diff line number Diff line change
@@ -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]:

Check failure on line 72 in scripts/commands/source.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 34 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ9y7FM6lqCs1FI_yUfn&open=AZ9y7FM6lqCs1FI_yUfn&pullRequest=317
"""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,
}
22 changes: 22 additions & 0 deletions scripts/formatters/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,35 @@ 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)

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", "")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_command_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading