Skip to content

Commit df869b9

Browse files
committed
feat(symbols): add a project-wide definition index for jump-to-definition
Reading a call meant losing your place: there was no way to ask "where is this name actually defined?" without grepping. Add a deterministic, no-LLM definition index that scans the loaded source for top-level functions and classes (plus one level of class methods) in Python and JS/TS, recording the file, line, and kind of each. A new GET /api/project/{id}/definition?name=... returns every place a name is declared, trying an exact match first and falling back to case-insensitive, so clicking a symbol jumps straight to it. Matching is regex over indentation, not a parser, so runtime-built or re-exported names are out of scope — the notes say so. References ("where is it used?") stay on the roadmap; this ships the "where is it defined?" half.
1 parent c93c479 commit df869b9

6 files changed

Lines changed: 513 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ CodeABC/
208208

209209
### Shipped
210210

211-
The reading experience is already complete end to end: a plain-language project manual, hover annotations, a terminology dictionary, natural-language editing, and snippet Q&A, all in a bilingual UI you launch with one command (or as a native Tauri desktop app). On top of that sit a dozen deterministic, no-API-key maps — reading map, core-module ranking, test coverage, git-history hotspots and ownership, tech-debt markers, env-var surface, entry points, external integrations, silent-failure spots, under-documented files, and logic complexity. It all exports offline too: every map stitches into a single `codemap.md`, or a self-contained HTML report you can email to a non-technical stakeholder — no server, no API key, nothing fetched from the network. Analyses also persist to a small on-disk library (`GET /api/projects`), so you can list past projects and reopen one without remembering its id or re-scanning.
211+
The reading experience is already complete end to end: a plain-language project manual, hover annotations, a terminology dictionary, natural-language editing, and snippet Q&A, all in a bilingual UI you launch with one command (or as a native Tauri desktop app). On top of that sit a dozen deterministic, no-API-key maps — reading map, core-module ranking, a project-wide definition index (look up any name and jump to where it's declared), test coverage, git-history hotspots and ownership, tech-debt markers, env-var surface, entry points, external integrations, silent-failure spots, under-documented files, and logic complexity. It all exports offline too: every map stitches into a single `codemap.md`, or a self-contained HTML report you can email to a non-technical stakeholder — no server, no API key, nothing fetched from the network. Analyses also persist to a small on-disk library (`GET /api/projects`), so you can list past projects and reopen one without remembering its id or re-scanning.
212212

213213
### Planned
214214

@@ -217,7 +217,7 @@ These are the directions I want to take next, roughly in priority order:
217217
- **Pull-request reading mode** — paste a PR link and get the diff explained in plain language: what changed, why it might matter, and which files to look at first. This is the most-requested extension and a natural fit for the existing annotation engine.
218218
- **Whole-project chat** — Q&A today is grounded in one file or snippet; the next step is a conversation that can reach across the project's maps and source at once, while staying honest about what it actually read.
219219
- **Annotation coverage beyond Python** — hover annotations lead with Python today; bringing JavaScript/TypeScript and Go up to the same depth is mostly prompt and tokenizer work.
220-
- **Jump-to-definition reading**click a function or import and jump to where it's defined or used, so reading a call doesn't mean losing your place.
220+
- **Find-all-references**the definition index already answers "where is this declared?" (`GET /api/project/{id}/definition?name=...`); the other half is "where is it used?", so clicking a name shows every call site, not just the declaration.
221221

222222
Have an idea or a codebase that confuses CodeABC? Open an issue — the roadmap is shaped by what real non-coders get stuck on.
223223

backend/models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,25 @@ class FileDependencies(BaseModel):
406406
imported_by: list[str] = [] # in-project files that import this file
407407

408408

409+
class Definition(BaseModel):
410+
name: str
411+
qualname: str = "" # "Class.method" for methods, plain name otherwise
412+
kind: str # function | class | method
413+
parent: str | None = None # enclosing class for a method
414+
lang: str = "" # python | js
415+
file: str
416+
line: int
417+
418+
419+
class DefinitionMatches(BaseModel):
420+
"""Where a clicked name is defined — the jump-to-definition payload."""
421+
422+
name: str # the queried name
423+
total: int = 0 # number of places that define it
424+
definitions: list[Definition] = []
425+
notes: list[str] = []
426+
427+
409428
class GitHubRequest(BaseModel):
410429
url: str = Field(..., pattern=r"^https?://github\.com/.+/.+")
411430

backend/routers/project.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
CodeWalkStep,
2424
ComplexFile,
2525
CouplingHotspot,
26+
Definition,
27+
DefinitionMatches,
2628
Dependency,
2729
DocCoverage,
2830
DocCoverageFile,
@@ -80,6 +82,7 @@
8082
risk,
8183
scanner,
8284
security,
85+
symbols,
8386
techdebt,
8487
)
8588
from backend.services import (
@@ -585,6 +588,27 @@ async def get_file_dependencies(project_id: str, file_path: str):
585588
)
586589

587590

591+
@router.get("/project/{project_id}/definition", response_model=DefinitionMatches)
592+
async def get_definition(project_id: str, name: str):
593+
"""Return where a name is defined — the jump-to-definition lookup.
594+
595+
Deterministic (no LLM): click a function, class, or import and find the
596+
file and line that declares it. Tries an exact match, then a
597+
case-insensitive one, and returns every place a shared name is defined.
598+
"""
599+
proj = await _resolve_project(project_id)
600+
if not proj:
601+
raise HTTPException(404, "Project not found")
602+
603+
matches = symbols.find_definition(proj.get("file_contents", {}), name)
604+
return DefinitionMatches(
605+
name=name,
606+
total=len(matches),
607+
definitions=[Definition(**m) for m in matches],
608+
notes=[] if matches else [f"No definition of '{name}' found in this project."],
609+
)
610+
611+
588612
@router.get("/project/{project_id}/codemap.md")
589613
async def get_codemap_markdown(project_id: str):
590614
"""Export the deterministic code map (import-graph analyses) as Markdown."""

backend/services/symbols.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
"""Definition index — where each name in the code is actually defined.
2+
3+
Reading unfamiliar code means constantly hitting a name and wondering "where
4+
does this come from?". A call to ``parse_config``, a ``class Scanner``, an
5+
imported helper — the reader wants the file and line that defines it, not a web
6+
search. This module builds that index from the source CodeABC already loaded,
7+
with no LLM and no language server: it scans for top-level definitions (and one
8+
level of class methods) and records where each one lives, so the UI can offer
9+
"jump to definition" for any name a reader clicks.
10+
11+
It covers the two languages CodeABC annotates today:
12+
13+
Python ``def`` / ``async def`` / ``class`` at module level, plus methods
14+
one indent inside a class (recorded with their enclosing class).
15+
JS / TS ``function`` / ``async function`` / ``export function`` / ``class``,
16+
and ``const name = (...) =>`` / ``const name = function`` style
17+
assignments.
18+
19+
Each entry carries the name, its kind (function / class / method), the
20+
enclosing class for a method, the file, and a 1-based line number.
21+
22+
Limitations (kept honest on purpose):
23+
24+
* Matching is regex over indentation, not a real parser. Decorators that
25+
rename, ``setattr`` / ``globals()`` tricks, conditional re-exports, and
26+
names built at runtime are out of scope.
27+
* Only definitions are indexed, not references — this points at where a name
28+
is born, not everywhere it is used.
29+
* A name defined in several files yields one entry per file; the caller
30+
decides which is meant.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import re
36+
37+
# ---------------------------------------------------------------------------
38+
# Shared helpers
39+
# ---------------------------------------------------------------------------
40+
41+
_IDENT = r"[A-Za-z_$][\w$]*"
42+
43+
44+
def _lang(path: str) -> str:
45+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
46+
if ext == "py":
47+
return "python"
48+
if ext in ("js", "ts", "jsx", "tsx", "mjs", "cjs"):
49+
return "js"
50+
return ext
51+
52+
53+
def _indent_width(line: str) -> int:
54+
expanded = line.expandtabs(4)
55+
return len(expanded) - len(expanded.lstrip())
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# Python
60+
# ---------------------------------------------------------------------------
61+
62+
_PY_DEF_RE = re.compile(r"^(?P<indent>\s*)(?:async\s+)?def\s+(?P<name>\w+)\s*\(")
63+
_PY_CLASS_RE = re.compile(r"^(?P<indent>\s*)class\s+(?P<name>\w+)\s*[:(]")
64+
65+
66+
def _py_definitions(path: str, content: str) -> list[dict]:
67+
"""Top-level functions/classes plus direct (and nested-class) methods.
68+
69+
A stack of open ``class`` / ``def`` blocks, keyed by indentation, tells us
70+
what encloses each definition. A ``def`` whose nearest enclosing block is a
71+
class is a method; a ``def`` nested inside another ``def`` is a local helper
72+
and is skipped (it is not part of the public reading surface).
73+
"""
74+
out: list[dict] = []
75+
# frames: list of (indent, kind, name) for currently open class/def blocks
76+
frames: list[tuple[int, str, str]] = []
77+
78+
for lineno, raw in enumerate(content.splitlines(), start=1):
79+
stripped = raw.strip()
80+
if not stripped or stripped.startswith("#"):
81+
continue
82+
83+
class_match = _PY_CLASS_RE.match(raw)
84+
def_match = None if class_match else _PY_DEF_RE.match(raw)
85+
if class_match is None and def_match is None:
86+
continue
87+
88+
indent = _indent_width(raw)
89+
# close every block that this line is not nested inside
90+
while frames and frames[-1][0] >= indent:
91+
frames.pop()
92+
enclosing = frames[-1] if frames else None
93+
94+
if class_match is not None:
95+
name = class_match.group("name")
96+
parent = enclosing[2] if enclosing and enclosing[1] == "class" else None
97+
out.append(_entry(path, "python", name, "class", parent, lineno))
98+
frames.append((indent, "class", name))
99+
continue
100+
101+
assert def_match is not None # narrowing: class_match is None here
102+
name = def_match.group("name")
103+
if enclosing is None:
104+
out.append(_entry(path, "python", name, "function", None, lineno))
105+
frames.append((indent, "def", name))
106+
elif enclosing[1] == "class":
107+
out.append(_entry(path, "python", name, "method", enclosing[2], lineno))
108+
frames.append((indent, "def", name))
109+
else:
110+
# nested inside another def: a local helper — track it so its own
111+
# nested blocks resolve correctly, but do not index it.
112+
frames.append((indent, "def", name))
113+
114+
return out
115+
116+
117+
# ---------------------------------------------------------------------------
118+
# JavaScript / TypeScript
119+
# ---------------------------------------------------------------------------
120+
121+
_JS_FUNC_RE = re.compile(
122+
r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*(" + _IDENT + r")\s*\("
123+
)
124+
_JS_CLASS_RE = re.compile(
125+
r"^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(" + _IDENT + r")\b"
126+
)
127+
# const/let/var name = (...) => ... or = async (...) => or = function
128+
_JS_ASSIGN_RE = re.compile(
129+
r"^\s*(?:export\s+)?(?:default\s+)?(?:const|let|var)\s+(" + _IDENT + r")\s*=\s*"
130+
r"(?:async\s+)?(?:function\b|\*?\s*\([^)]*\)\s*=>|" + _IDENT + r"\s*=>)"
131+
)
132+
133+
134+
def _js_definitions(path: str, content: str) -> list[dict]:
135+
out: list[dict] = []
136+
for lineno, raw in enumerate(content.splitlines(), start=1):
137+
stripped = raw.strip()
138+
if not stripped or stripped.startswith("//"):
139+
continue
140+
141+
class_match = _JS_CLASS_RE.match(raw)
142+
if class_match:
143+
out.append(_entry(path, "js", class_match.group(1), "class", None, lineno))
144+
continue
145+
146+
func_match = _JS_FUNC_RE.match(raw)
147+
if func_match:
148+
out.append(_entry(path, "js", func_match.group(1), "function", None, lineno))
149+
continue
150+
151+
assign_match = _JS_ASSIGN_RE.match(raw)
152+
if assign_match:
153+
out.append(_entry(path, "js", assign_match.group(1), "function", None, lineno))
154+
155+
return out
156+
157+
158+
# ---------------------------------------------------------------------------
159+
# Assembly
160+
# ---------------------------------------------------------------------------
161+
162+
163+
def _entry(path: str, lang: str, name: str, kind: str, parent: str | None, line: int) -> dict:
164+
qualname = f"{parent}.{name}" if parent else name
165+
return {
166+
"name": name,
167+
"qualname": qualname,
168+
"kind": kind,
169+
"parent": parent,
170+
"lang": lang,
171+
"file": path,
172+
"line": line,
173+
}
174+
175+
176+
def _build_notes(definitions: list[dict], file_contents: dict[str, str]) -> list[str]:
177+
notes: list[str] = []
178+
scanned = sum(1 for p in file_contents if _lang(p) in ("python", "js"))
179+
if scanned == 0:
180+
notes.append("No Python or JS/TS files were found to index.")
181+
return notes
182+
183+
by_kind: dict[str, int] = {}
184+
for d in definitions:
185+
by_kind[d["kind"]] = by_kind.get(d["kind"], 0) + 1
186+
parts = [f"{by_kind[k]} {k}{'es' if k == 'class' else 's'}" for k in sorted(by_kind)]
187+
if parts:
188+
notes.append(f"Indexed {', '.join(parts)} across {scanned} source file(s).")
189+
190+
duplicates = _duplicate_names(definitions)
191+
if duplicates:
192+
sample = ", ".join(duplicates[:5])
193+
notes.append(
194+
f"{len(duplicates)} name(s) are defined in more than one place "
195+
f"(e.g. {sample}); a lookup returns every match."
196+
)
197+
198+
notes.append(
199+
"Definitions only: this index shows where a name is declared, not "
200+
"every place it is used. Matching is regex-based, so runtime-built or "
201+
"re-exported names may be missed."
202+
)
203+
return notes
204+
205+
206+
def _duplicate_names(definitions: list[dict]) -> list[str]:
207+
seen: dict[str, int] = {}
208+
for d in definitions:
209+
seen[d["name"]] = seen.get(d["name"], 0) + 1
210+
return sorted(name for name, count in seen.items() if count > 1)
211+
212+
213+
def build_definition_index(file_contents: dict[str, str], *, limit: int = 2000) -> dict:
214+
"""Index every top-level definition (and class method) in the project.
215+
216+
Returns ``{"total", "definitions", "notes"}`` where ``definitions`` is a
217+
deterministic, alphabetically sorted list of entries (see :func:`_entry`),
218+
truncated to ``limit``.
219+
"""
220+
definitions: list[dict] = []
221+
for path in sorted(file_contents):
222+
lang = _lang(path)
223+
content = file_contents[path]
224+
if lang == "python":
225+
definitions.extend(_py_definitions(path, content))
226+
elif lang == "js":
227+
definitions.extend(_js_definitions(path, content))
228+
229+
definitions.sort(key=lambda d: (d["name"].lower(), d["file"], d["line"]))
230+
notes = _build_notes(definitions, file_contents)
231+
total = len(definitions)
232+
return {"total": total, "definitions": definitions[:limit], "notes": notes}
233+
234+
235+
def find_definition(file_contents: dict[str, str], name: str, *, limit: int = 50) -> list[dict]:
236+
"""Return the places that define ``name``.
237+
238+
Tries an exact match first, then falls back to a case-insensitive match so
239+
a reader who types ``scanner`` still finds ``Scanner``. Results are ordered
240+
by file then line.
241+
"""
242+
target = (name or "").strip()
243+
if not target:
244+
return []
245+
246+
index = build_definition_index(file_contents, limit=100_000)
247+
matches = [d for d in index["definitions"] if d["name"] == target]
248+
if not matches:
249+
lowered = target.lower()
250+
matches = [d for d in index["definitions"] if d["name"].lower() == lowered]
251+
252+
matches.sort(key=lambda d: (d["file"], d["line"]))
253+
return matches[:limit]

tests/test_project.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,42 @@ async def main():
107107
assert by_id["persisted"].created_at is not None
108108
assert by_id["memory-only"].name == "仅内存"
109109
assert by_id["memory-only"].created_at is None # not persisted yet
110+
111+
112+
def test_definition_endpoint_returns_location(monkeypatch):
113+
# Jump-to-definition: looking up a name returns the file and line that
114+
# declares it, drawn from the project's loaded file contents.
115+
import asyncio
116+
117+
from backend.routers import project as project_router
118+
119+
source = "class Scanner:\n def scan(self):\n pass\n"
120+
monkeypatch.setattr(
121+
project_router,
122+
"_projects",
123+
{"p1": {"file_contents": {"app/scanner.py": source}}},
124+
)
125+
126+
result = asyncio.run(project_router.get_definition("p1", "scan"))
127+
128+
assert result.name == "scan"
129+
assert result.total == 1
130+
hit = result.definitions[0]
131+
assert hit.file == "app/scanner.py"
132+
assert hit.line == 2
133+
assert hit.kind == "method"
134+
assert hit.parent == "Scanner"
135+
136+
137+
def test_definition_endpoint_reports_missing_name(monkeypatch):
138+
import asyncio
139+
140+
from backend.routers import project as project_router
141+
142+
monkeypatch.setattr(project_router, "_projects", {"p1": {"file_contents": {"a.py": "x = 1\n"}}})
143+
144+
result = asyncio.run(project_router.get_definition("p1", "nope"))
145+
146+
assert result.total == 0
147+
assert result.definitions == []
148+
assert any("nope" in n for n in result.notes)

0 commit comments

Comments
 (0)