Skip to content

Commit 2daba47

Browse files
authored
Merge pull request #317 from Wolfvin/feat/issue-316-get-source
Merged on local verification: retrieved a function body without reading the file (dogfood), 7 tests, 19=19 on main, Design Doc passed. Directly serves the north-star — the agent stops Read-ing whole files. CI test-suite gate non-functional (#303).
2 parents 7590f28 + 81c3c15 commit 2daba47

9 files changed

Lines changed: 365 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ CodeLens consolidates what used to be ~78 separate commands into **12 umbrella c
109109
|---|---|---|
110110
| `scan` | scan (default) · rescan | Build/refresh the workspace graph. Everything else depends on this having run once. |
111111
| `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). |
112-
| `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`). |
112+
| `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`). |
113113
| `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. |
114114
| `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. |
115115
| `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. |

SKILL-QUICK.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ codelens search "pattern" . --mode regex --limit 5 --offset 10 --format compact
105105
|---|---|
106106
| `scan` | scan (default) · rescan |
107107
| `search "pattern" [workspace]` | semantic (default) · symbol · regex · graph — **pattern first, workspace second**, opposite of every command below |
108-
| `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) |
108+
| `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) |
109109
| `deps [workspace]` | affected (`--files ...`) · dependents (`--files ...`) · circular · import-snapshot (`--input path.gz`) · export-snapshot (`--output path.gz`) |
110110
| `audit [workspace]` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css · a11y |
111111
| `security [workspace]` | secrets · vuln-scan · taint · binary-scan · regex-audit |

SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ codelens guard --pre --file X → DROPPED
4343
|---|---|
4444
| `scan` | scan (default) · rescan |
4545
| `search` | semantic (default) · symbol · regex · graph — **`pattern` comes first, workspace second**, opposite of every other command here |
46-
| `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`) |
46+
| `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) |
4747
| `deps` | affected · dependents · circular (default: all three) · import-snapshot · export-snapshot |
4848
| `audit` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css (deep CSS) · a11y (WCAG 2.1) (default: all) |
4949
| `security` | secrets · vuln-scan · taint · binary-scan · regex-audit (default: all) |

docs/design/0316-get-source.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Design Doc: Get Function Source (`context --check source`)
2+
3+
> **Status:** Accepted
4+
> **Date:** 2026-07-18
5+
> **Author:** Wolfvin (BOS) / Claude Code
6+
> **Related issues:** #316
7+
> **North-star:** CodeLens replaces the agent's grep + Read navigation (#279 agent-ergonomics)
8+
9+
---
10+
11+
## Problem
12+
13+
Found by dogfooding CodeLens on itself. The most common thing the agent still
14+
does manually is **Read an entire file just to see one function's body**.
15+
Nothing in CodeLens returns a function's source:
16+
17+
- `context --check outline --file F``{name, line}` only, even `--detail full`.
18+
- `context --check context --name X` → metadata (callers, advice), no body.
19+
20+
So the agent reads the whole file. That is exactly what CodeLens exists to
21+
replace.
22+
23+
## Goal
24+
25+
`context --check source --name X` returns just function X's source + its
26+
`file:start-end` range. Read-only, deterministic. Command count stays 12.
27+
28+
## Design
29+
30+
Read-only compose, no new engine:
31+
32+
1. **Locate X.** With `--file F`, outline F and match by name (no graph needed).
33+
Otherwise resolve via `graph_model.find_nodes_by_name` (the graph populated
34+
by a prior scan).
35+
2. **Bound it.** The function runs from its start line to the line before the
36+
next declaration in the file (`outline_engine` functions + classes), or EOF
37+
for the last one. Trailing blank lines are trimmed.
38+
3. **Slice.** Return those lines.
39+
40+
### Why a next-declaration heuristic, not the parser's end line
41+
42+
`outline_engine` does not expose a node end line, and reaching into the
43+
tree-sitter layer to add one is a parser change (backend) out of scope for a
44+
read-only command. The next-declaration boundary is exact for the common case
45+
(a function followed by another declaration) and never guesses beyond the
46+
file's own structure. A more precise end line via the parser is a possible
47+
follow-up (a worker task, since it touches the parser).
48+
49+
## Non-goals
50+
51+
- **No parser change.** Boundary is heuristic on purpose (see above).
52+
- **No cross-file dedup.** A name defined in N files returns N matches; the
53+
agent narrows with `--file`.
54+
- **Not for module-level code.** It returns a *function's* source; free code
55+
between declarations may be included up to the next declaration — documented,
56+
acceptable for "show me this function".
57+
58+
## Agent-ergonomics note
59+
60+
An unscanned workspace with no `--file` returns an explicit
61+
`error_type: "no_graph"` message — never a silent-empty result. This is the
62+
same discipline as #315: the agent must always tell an error from an empty.
63+
64+
## Testing
65+
66+
`--file` mode (no graph) fixtures: exact single-function slice, boundary stops
67+
before the next declaration (no bleed), last function to EOF, trailing-blank
68+
trim, unknown name → not-found, missing `--name` → error. Graph mode: same-name
69+
in two files → two matches. No-graph-and-no-file → explicit error, not empty.
70+
Verified by dogfooding: retrieved `_fn_of`'s 3-line body from a 105-line file
71+
without reading the file.

scripts/commands/context.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@
6666
"module": "commands.flow",
6767
"help": "Collect a named @FLOW's scattered functions into one view (--name X; issue #309)",
6868
},
69+
"source": {
70+
"module": "commands.source",
71+
"help": "Return a function's source by name — no need to read the whole file (--name X; issue #316)",
72+
},
6973
}
7074

7175
ALL_CHECKS = list(_CHECKS.keys())
@@ -84,6 +88,7 @@ def add_args(parser):
8488
" overview Token-efficient hierarchical symbols map (issue #254)\n"
8589
" tags Audit @FLOW/@ENTRY/@PART doc-tags (issue #305)\n"
8690
" flow Collect a named @FLOW's scattered functions (--name X, issue #309)\n"
91+
" source Return a function's source by name (--name X, issue #316)\n"
8792
"\n"
8893
"Examples:\n"
8994
" codelens context . # orient (default)\n"
@@ -187,6 +192,9 @@ def _build_namespace(base_args, check_name: str) -> argparse.Namespace:
187192
ns.max_files = getattr(base_args, "max_files", None) or 200
188193
elif check_name == "flow":
189194
ns.name = getattr(base_args, "name", None)
195+
elif check_name == "source":
196+
ns.name = getattr(base_args, "name", None)
197+
ns.file = getattr(base_args, "file", None)
190198
return ns
191199

192200

scripts/commands/source.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# @WHO: scripts/commands/source.py
2+
# @WHAT: `context --check source` — return a function's source by name
3+
# @PART: command (sub-check of `context`)
4+
# @ENTRY: execute()
5+
"""`context --check source --name X` — a function's source, by name.
6+
7+
The most direct replacement for "Read the whole file to see one function":
8+
resolve X to its file and start line, bound it by the next declaration, and
9+
return just those lines. Read-only. Boundaries are heuristic — the next
10+
declaration in the file, or EOF — which is exact for the common case and
11+
never guesses beyond a file's own structure.
12+
"""
13+
14+
import os
15+
from typing import Any, Dict, List
16+
17+
from outline_engine import get_file_outline
18+
19+
20+
def add_args(parser):
21+
"""Register CLI arguments (workspace/name/file carried by the umbrella)."""
22+
parser.add_argument(
23+
"workspace", nargs="?", default=None,
24+
help="Path to workspace root (auto-detected if omitted)",
25+
)
26+
27+
28+
def _symbol_lines(outline: Dict) -> List[int]:
29+
"""Every declaration line in a file, sorted — the boundary candidates."""
30+
lines = []
31+
for section in ("functions", "classes"):
32+
for entry in outline.get(section, []):
33+
if isinstance(entry.get("line"), int):
34+
lines.append(entry["line"])
35+
return sorted(set(lines))
36+
37+
38+
def _extract(abs_file: str, rel_file: str, workspace: str,
39+
start: int, name: str) -> Dict[str, Any]:
40+
"""Slice one function's source from its start line to the next declaration."""
41+
res = get_file_outline(abs_file, workspace, "normal")
42+
outline = res.get("outline") or {}
43+
line_count = outline.get("line_count", 0)
44+
45+
decl_lines = _symbol_lines(outline)
46+
end = line_count
47+
for ln in decl_lines:
48+
if ln > start:
49+
end = ln - 1
50+
break
51+
52+
try:
53+
with open(abs_file, "r", encoding="utf-8", errors="replace") as f:
54+
file_lines = f.read().splitlines()
55+
except OSError as e:
56+
return {"symbol": name, "file": rel_file, "error": str(e)}
57+
58+
body = file_lines[start - 1:end]
59+
# Drop blank lines between this function and the next declaration.
60+
while body and not body[-1].strip():
61+
body.pop()
62+
end -= 1
63+
return {
64+
"symbol": name,
65+
"file": rel_file,
66+
"start_line": start,
67+
"end_line": end,
68+
"source": "\n".join(body),
69+
}
70+
71+
72+
def execute(args, workspace) -> Dict[str, Any]:
73+
"""Return the source of function(s) named ``--name``.
74+
75+
@FLOW: SOURCE_VIEW
76+
@CALLS: graph_model.find_nodes_by_name(), outline_engine.get_file_outline()
77+
@MUTATES: nothing (read-only)
78+
"""
79+
name = getattr(args, "name", None)
80+
if not name:
81+
return {
82+
"status": "error",
83+
"error": "source needs --name X (the function to show)",
84+
"error_type": "missing_argument",
85+
}
86+
87+
workspace = os.path.abspath(workspace) if workspace else os.getcwd()
88+
only_file = getattr(args, "file", None)
89+
90+
# Resolve where X is defined: an explicit --file needs no graph; otherwise
91+
# ask the call-graph (populated by a prior scan).
92+
locations = [] # (abs_file, rel_file, start_line)
93+
if only_file:
94+
abs_file = only_file if os.path.isabs(only_file) else os.path.join(workspace, only_file)
95+
res = get_file_outline(abs_file, workspace, "normal")
96+
outline = res.get("outline") or {}
97+
for fn in outline.get("functions", []):
98+
if fn.get("name") == name and isinstance(fn.get("line"), int):
99+
locations.append((abs_file, os.path.relpath(abs_file, workspace), fn["line"]))
100+
else:
101+
try:
102+
from utils import default_db_path
103+
import graph_model as gm
104+
except Exception:
105+
return {"status": "error",
106+
"error": "graph unavailable; pass --file to locate the function",
107+
"error_type": "no_graph"}
108+
db = getattr(args, "db_path", None) or default_db_path(workspace)
109+
if not db or not os.path.exists(db):
110+
return {"status": "error",
111+
"error": "no graph DB — scan the workspace first, or pass --file",
112+
"error_type": "no_graph"}
113+
for node in gm.find_nodes_by_name(name, db):
114+
rel = node.get("file", "")
115+
if not rel:
116+
continue
117+
abs_file = rel if os.path.isabs(rel) else os.path.join(workspace, rel)
118+
line = node.get("line")
119+
if isinstance(line, int) and line > 0 and os.path.exists(abs_file):
120+
locations.append((abs_file, rel.replace("\\", "/"), line))
121+
122+
if not locations:
123+
where = f" in {only_file}" if only_file else ""
124+
return {"status": "ok", "symbol": name, "found": False,
125+
"message": f"No function named '{name}' found{where}. "
126+
"If the workspace was never scanned, run scan first or pass --file."}
127+
128+
matches = [_extract(a, r, workspace, ln, name) for a, r, ln in locations]
129+
return {
130+
"status": "ok",
131+
"symbol": name,
132+
"found": True,
133+
"count": len(matches),
134+
"matches": matches,
135+
}

scripts/formatters/markdown.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,35 @@ def to_markdown(data: Any, command: str = "") -> str:
145145
_md_flow(data, lines)
146146
elif command == "flow-diff":
147147
_md_flow_diff(data, lines)
148+
elif command == "source":
149+
_md_source(data, lines)
148150
else:
149151
# Generic markdown for any command
150152
_md_generic(data, lines)
151153

152154
return "\n".join(lines)
153155

154156

157+
def _md_source(data: Dict, lines: list) -> None:
158+
"""Markdown for a function's source (`context --check source`, issue #316)."""
159+
name = data.get("symbol", "")
160+
if not data.get("found"):
161+
lines.append(f"## Source: `{name}` — not found")
162+
lines.append("")
163+
lines.append(data.get("message", ""))
164+
return
165+
matches = data.get("matches", [])
166+
plural = "" if len(matches) == 1 else f" ({len(matches)} definitions)"
167+
lines.append(f"## Source: `{name}`{plural}")
168+
for m in matches:
169+
lines.append("")
170+
lines.append(f"**{m.get('file', '')}:{m.get('start_line', '')}-{m.get('end_line', '')}**")
171+
lines.append("")
172+
lines.append("```")
173+
lines.append(m.get("source", ""))
174+
lines.append("```")
175+
176+
155177
def _md_flow_diff(data: Dict, lines: list) -> None:
156178
"""Markdown for a named flow's shape change (`flow-diff`, issue #313)."""
157179
name = data.get("flow", "")

tests/test_command_registry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def test_every_command_module_registers():
4444
"dead_code", "dependents", "diagnostics", "diff", "env_check",
4545
"export_snapshot", "flow", "flow_diff", "git_status", "graph_schema", "import_snapshot",
4646
"init", "lsp_status", "orient", "outline", "ownership", "perf_hint",
47-
"query_graph", "regex_audit", "secrets", "side_effect", "smell",
47+
"query_graph", "regex_audit", "secrets", "side_effect", "smell", "source",
4848
"staleness", "symbols_overview", "tags", "taint", "trace", "vuln_scan",
4949
}
5050
_UTILITY_MODULES |= _DEPRECATED_ALIAS_MODULES

0 commit comments

Comments
 (0)