sentinel-codegraph 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +5 -0
- codegraph/__main__.py +6 -0
- codegraph/cli.py +475 -0
- codegraph/config.py +55 -0
- codegraph/graph_store.py +455 -0
- codegraph/models.py +87 -0
- codegraph/parser/__init__.py +27 -0
- codegraph/parser/base.py +114 -0
- codegraph/parser/lang_go.py +524 -0
- codegraph/parser/lang_python.py +458 -0
- codegraph/parser/lang_typescript.py +554 -0
- codegraph/parser/links.py +314 -0
- codegraph/parser/queries.py +114 -0
- codegraph/parser/raw_core.py +66 -0
- codegraph/parser/rows.py +191 -0
- codegraph/pipeline.py +370 -0
- codegraph/query.py +125 -0
- codegraph/tree.py +230 -0
- codegraph/walk.py +114 -0
- sentinel_codegraph-0.3.0.dist-info/METADATA +251 -0
- sentinel_codegraph-0.3.0.dist-info/RECORD +23 -0
- sentinel_codegraph-0.3.0.dist-info/WHEEL +4 -0
- sentinel_codegraph-0.3.0.dist-info/entry_points.txt +2 -0
codegraph/pipeline.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Linear code-graph pipeline: read -> build -> out.
|
|
2
|
+
|
|
3
|
+
- :func:`read`: discover + read source files (I/O).
|
|
4
|
+
- :func:`build_graph`: collect per-file rows then link calls (pure).
|
|
5
|
+
- :func:`out`: persist + print (I/O).
|
|
6
|
+
|
|
7
|
+
Languages: Python, TypeScript/JavaScript, Go. Anything else discovered
|
|
8
|
+
is counted as skipped.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
from codegraph.config import ResolvedDb
|
|
20
|
+
from codegraph.models import Edge, Node
|
|
21
|
+
from codegraph.parser.lang_go import build_go_file_rows
|
|
22
|
+
from codegraph.parser.lang_typescript import build_ts_file_rows
|
|
23
|
+
from codegraph.parser.links import resolve_call_edges
|
|
24
|
+
from codegraph.parser.rows import FileRows, build_python_file_rows
|
|
25
|
+
from codegraph.graph_store import create_store
|
|
26
|
+
from codegraph.tree import GraphSnapshot, kind_label, load_snapshot, render_tree
|
|
27
|
+
from codegraph.walk import DiscoveredFile, adiscover_files, normalise_root
|
|
28
|
+
|
|
29
|
+
OutputMode = Literal["summary", "tree", "nodes", "calls"]
|
|
30
|
+
"""``out`` modes: summary line, hierarchy tree, node dump, call list."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class FileInput:
|
|
35
|
+
"""One file's source text plus its identity (I/O edge output)."""
|
|
36
|
+
|
|
37
|
+
rel_path: str
|
|
38
|
+
language: str
|
|
39
|
+
source_text: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class ScannedSources:
|
|
44
|
+
"""One scan root plus its read source inputs."""
|
|
45
|
+
|
|
46
|
+
root: str
|
|
47
|
+
items: list[FileInput]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class BuiltGraph:
|
|
52
|
+
"""Merged pure build output for one root: nodes + edges, nothing else."""
|
|
53
|
+
|
|
54
|
+
root: str
|
|
55
|
+
files: int
|
|
56
|
+
skipped: int
|
|
57
|
+
nodes: tuple[Node, ...]
|
|
58
|
+
edges: tuple[Edge, ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class IndexResult:
|
|
63
|
+
"""Outcome of one :func:`out` run."""
|
|
64
|
+
|
|
65
|
+
root: str
|
|
66
|
+
db_label: str
|
|
67
|
+
files: int
|
|
68
|
+
nodes: int
|
|
69
|
+
edges: int
|
|
70
|
+
skipped: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _read_source(path: Path) -> str:
|
|
74
|
+
"""Read a source file off the event loop."""
|
|
75
|
+
return await asyncio.to_thread(path.read_text, encoding="utf-8", errors="ignore")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def read(target: Path) -> ScannedSources:
|
|
79
|
+
"""Discover + read every supported file under ``target``."""
|
|
80
|
+
root_path: Path = (
|
|
81
|
+
normalise_root(target) if target.is_dir() else normalise_root(target.parent)
|
|
82
|
+
)
|
|
83
|
+
root: str = root_path.as_posix()
|
|
84
|
+
discovered: list[DiscoveredFile] = await adiscover_files(target, root_path)
|
|
85
|
+
items: list[FileInput] = []
|
|
86
|
+
for item in discovered:
|
|
87
|
+
try:
|
|
88
|
+
text: str = await _read_source(item.abs_path)
|
|
89
|
+
except OSError:
|
|
90
|
+
continue
|
|
91
|
+
items.append(
|
|
92
|
+
FileInput(rel_path=item.rel_path, language=item.language, source_text=text)
|
|
93
|
+
)
|
|
94
|
+
return ScannedSources(root=root, items=items)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_LANGUAGE_TO_BUILDER: dict[
|
|
98
|
+
str, Callable[[str, str, str, Mapping[str, str]], FileRows]
|
|
99
|
+
] = {
|
|
100
|
+
"python": build_python_file_rows,
|
|
101
|
+
"typescript": build_ts_file_rows,
|
|
102
|
+
"javascript": build_ts_file_rows,
|
|
103
|
+
"go": build_go_file_rows,
|
|
104
|
+
}
|
|
105
|
+
"""Per-language collect builders; unknown languages skip at :func:`parse_file_input`."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_file_input(
|
|
109
|
+
root: str, item: FileInput, import_index: Mapping[str, str] | None = None
|
|
110
|
+
) -> FileRows | None:
|
|
111
|
+
"""Parse one file into rows. Returns None when the file is skipped.
|
|
112
|
+
|
|
113
|
+
Pure given ``item``. Blank source or a non-Python language -> skip,
|
|
114
|
+
counted by the caller. ``import_index`` is accepted for
|
|
115
|
+
signature stability and ignored: the link phase resolves modules
|
|
116
|
+
from the full collected file set.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
if not item.source_text.strip():
|
|
120
|
+
return None
|
|
121
|
+
handler: Callable[[str, str, str, Mapping[str, str]], FileRows] | None = (
|
|
122
|
+
_LANGUAGE_TO_BUILDER.get(item.language)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if handler is None:
|
|
126
|
+
return None
|
|
127
|
+
return handler(root, item.rel_path, item.source_text, import_index or {})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_graph(root: str, items: list[FileInput]) -> BuiltGraph:
|
|
131
|
+
"""Run the full pure build in two passes: collect, then link.
|
|
132
|
+
|
|
133
|
+
Pass 1 parses every Python file into nodes + ``contains`` /
|
|
134
|
+
``imports`` edges plus buffered call sites (no ``calls`` edges).
|
|
135
|
+
Pass 2 joins the buffered sites against the global definition
|
|
136
|
+
registry + per-file import maps into ``calls`` edges with real
|
|
137
|
+
node ids; unresolvable sites yield no edge.
|
|
138
|
+
"""
|
|
139
|
+
rows: list[FileRows] = []
|
|
140
|
+
skipped: int = 0
|
|
141
|
+
for item in items:
|
|
142
|
+
file_rows: FileRows | None = parse_file_input(root, item)
|
|
143
|
+
if file_rows is None:
|
|
144
|
+
skipped += 1
|
|
145
|
+
continue
|
|
146
|
+
rows.append(file_rows)
|
|
147
|
+
call_edges: list[Edge] = resolve_call_edges(rows, root)
|
|
148
|
+
|
|
149
|
+
return BuiltGraph(
|
|
150
|
+
root=root,
|
|
151
|
+
files=len(rows),
|
|
152
|
+
skipped=skipped,
|
|
153
|
+
nodes=tuple(n for unit in rows for n in unit.nodes),
|
|
154
|
+
edges=tuple(e for unit in rows for e in unit.edges) + tuple(call_edges),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
PrintableGraph = BuiltGraph | GraphSnapshot
|
|
159
|
+
"""Anything the printers can render: a fresh build or a stored snapshot."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _snapshot_for(
|
|
163
|
+
db: ResolvedDb, root: str, graph: PrintableGraph | None
|
|
164
|
+
) -> GraphSnapshot:
|
|
165
|
+
"""Return the snapshot to render: in-memory build or stored rows."""
|
|
166
|
+
if graph is not None:
|
|
167
|
+
return GraphSnapshot(root=root, nodes=graph.nodes, edges=graph.edges)
|
|
168
|
+
store = create_store(db.path)
|
|
169
|
+
try:
|
|
170
|
+
await store.create_all()
|
|
171
|
+
return await load_snapshot(store, root)
|
|
172
|
+
finally:
|
|
173
|
+
await store.dispose()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _span_of(node: Node) -> str:
|
|
177
|
+
"""Format a node's line range as ``(Lstart-Lend)``."""
|
|
178
|
+
return f"(L{node.start_line}-L{node.end_line})"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def print_tree(
|
|
182
|
+
db: ResolvedDb, root: str, graph: PrintableGraph | None = None
|
|
183
|
+
) -> int:
|
|
184
|
+
"""Print the hierarchy tree of one indexed ``root``."""
|
|
185
|
+
snapshot: GraphSnapshot = await _snapshot_for(db, root, graph)
|
|
186
|
+
if not snapshot.nodes:
|
|
187
|
+
print(f"no nodes for root {root}")
|
|
188
|
+
return 0
|
|
189
|
+
try:
|
|
190
|
+
print(render_tree(snapshot))
|
|
191
|
+
except UnicodeEncodeError:
|
|
192
|
+
print(render_tree(snapshot, use_unicode=False))
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def print_nodes(
|
|
197
|
+
db: ResolvedDb, root: str, graph: PrintableGraph | None = None
|
|
198
|
+
) -> int:
|
|
199
|
+
"""Print every collected node of ``root`` for manual analysis."""
|
|
200
|
+
snapshot = await _snapshot_for(db, root, graph)
|
|
201
|
+
if not snapshot.nodes:
|
|
202
|
+
print(f"no nodes for root {root}")
|
|
203
|
+
return 0
|
|
204
|
+
by_id: dict[str, Node] = {n.id: n for n in snapshot.nodes}
|
|
205
|
+
children: dict[str, list[str]] = {}
|
|
206
|
+
calls: dict[str, list[Edge]] = {}
|
|
207
|
+
for edge in snapshot.edges:
|
|
208
|
+
label: str = kind_label(edge.kind)
|
|
209
|
+
if label in ("contains", "imports"):
|
|
210
|
+
children.setdefault(edge.src_id, []).append(edge.dst_id)
|
|
211
|
+
elif label == "calls":
|
|
212
|
+
calls.setdefault(edge.src_id, []).append(edge)
|
|
213
|
+
|
|
214
|
+
def names(node_ids: list[str]) -> str:
|
|
215
|
+
resolved: list[str] = sorted({by_id[i].name for i in node_ids if i in by_id})
|
|
216
|
+
return f"[{', '.join(resolved)}]"
|
|
217
|
+
|
|
218
|
+
def _callee_order_key(call: Edge) -> tuple[int, int, str]:
|
|
219
|
+
"""Implementation order: call-site line, then callee name."""
|
|
220
|
+
if call.site_line is not None:
|
|
221
|
+
return (0, call.site_line, "")
|
|
222
|
+
return (1, 1 << 30, call.dst_id)
|
|
223
|
+
|
|
224
|
+
def callee_names(call_edges: list[Edge]) -> str:
|
|
225
|
+
"""Names for calls dsts in implementation order (dsts are real ids)."""
|
|
226
|
+
resolved: list[tuple[tuple[int, int, str], str]] = []
|
|
227
|
+
for call in call_edges:
|
|
228
|
+
dst_id: str = call.dst_id
|
|
229
|
+
node: Node | None = by_id.get(dst_id)
|
|
230
|
+
if node is not None:
|
|
231
|
+
resolved.append(
|
|
232
|
+
(
|
|
233
|
+
_callee_order_key(call),
|
|
234
|
+
f"{node.name}?" if node.is_placeholder else node.name,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
resolved.append((_callee_order_key(call), f"{dst_id}?"))
|
|
239
|
+
resolved.sort(key=lambda item: item[0])
|
|
240
|
+
seen: set[str] = set()
|
|
241
|
+
ordered: list[str] = []
|
|
242
|
+
for _, name in resolved:
|
|
243
|
+
if name not in seen:
|
|
244
|
+
seen.add(name)
|
|
245
|
+
ordered.append(name)
|
|
246
|
+
return f"[{', '.join(ordered)}]"
|
|
247
|
+
|
|
248
|
+
print(f"root: {root} (nodes={len(snapshot.nodes)} edges={len(snapshot.edges)})")
|
|
249
|
+
ordered_nodes: list[Node] = sorted(
|
|
250
|
+
snapshot.nodes, key=lambda n: (n.file_path, n.start_line, n.name)
|
|
251
|
+
)
|
|
252
|
+
for node in ordered_nodes:
|
|
253
|
+
parent: str = by_id[node.parent_id].name if node.parent_id in by_id else "None"
|
|
254
|
+
print(
|
|
255
|
+
f"node {kind_label(node.kind):8} {node.name} "
|
|
256
|
+
f"[{node.language}] {_span_of(node)} "
|
|
257
|
+
f"file={node.file_path} parent={parent} "
|
|
258
|
+
f"children={names(children.get(node.id, []))} "
|
|
259
|
+
f"callees={callee_names(calls.get(node.id, []))}"
|
|
260
|
+
)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def print_calls(
|
|
265
|
+
db: ResolvedDb, root: str, graph: PrintableGraph | None = None
|
|
266
|
+
) -> int:
|
|
267
|
+
"""Print every ``calls`` edge of ``root`` as ``caller -> callee``.
|
|
268
|
+
|
|
269
|
+
Dsts are real node ids; a dst missing from the snapshot renders
|
|
270
|
+
with a ``?`` suffix (defensive — the build never emits dangling
|
|
271
|
+
edges since unresolved sites are dropped).
|
|
272
|
+
"""
|
|
273
|
+
snapshot = await _snapshot_for(db, root, graph)
|
|
274
|
+
by_id: dict[str, Node] = {n.id: n for n in snapshot.nodes}
|
|
275
|
+
pairs: list[tuple[str, str, str, int]] = []
|
|
276
|
+
for edge in snapshot.edges:
|
|
277
|
+
if kind_label(edge.kind) != "calls":
|
|
278
|
+
continue
|
|
279
|
+
src: Node | None = by_id.get(edge.src_id)
|
|
280
|
+
if src is None:
|
|
281
|
+
continue
|
|
282
|
+
site_line: int = edge.site_line if edge.site_line is not None else 1 << 30
|
|
283
|
+
dst: Node | None = by_id.get(edge.dst_id)
|
|
284
|
+
if dst is not None:
|
|
285
|
+
callee: str = f"{dst.name}?" if dst.is_placeholder else dst.name
|
|
286
|
+
else:
|
|
287
|
+
callee = f"{edge.dst_id}?"
|
|
288
|
+
pairs.append((src.name, callee, src.file_path, site_line))
|
|
289
|
+
if not pairs:
|
|
290
|
+
print(f"no calls for root {root}")
|
|
291
|
+
return 0
|
|
292
|
+
pairs.sort(key=lambda item: (item[2], item[3], item[0], item[1]))
|
|
293
|
+
print(f"root: {root} (calls={len(pairs)})")
|
|
294
|
+
for caller, callee, file_path, _ in pairs:
|
|
295
|
+
print(f"calls {caller} -> {callee} file={file_path}")
|
|
296
|
+
return 0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def out(
|
|
300
|
+
db: ResolvedDb,
|
|
301
|
+
root: str,
|
|
302
|
+
graph: BuiltGraph,
|
|
303
|
+
*,
|
|
304
|
+
overwrite: bool,
|
|
305
|
+
output: OutputMode = "summary",
|
|
306
|
+
quiet: bool = True,
|
|
307
|
+
persist: bool = True,
|
|
308
|
+
) -> IndexResult:
|
|
309
|
+
"""Persist ``graph`` (flushing first when ``overwrite``), then print.
|
|
310
|
+
|
|
311
|
+
One store for the whole call, so ``:memory:`` databases stay alive
|
|
312
|
+
from persist through the snapshot reads below.
|
|
313
|
+
"""
|
|
314
|
+
stored: GraphSnapshot | None = None
|
|
315
|
+
if persist:
|
|
316
|
+
if not db.is_memory:
|
|
317
|
+
# The default DB lives under ~/.codegraph/, which may not
|
|
318
|
+
# exist on first run. Read paths never create directories.
|
|
319
|
+
Path(db.path).parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
store = create_store(db.path)
|
|
321
|
+
try:
|
|
322
|
+
await store.create_all()
|
|
323
|
+
if overwrite:
|
|
324
|
+
await store.clear_all()
|
|
325
|
+
if graph.nodes or graph.edges:
|
|
326
|
+
await store.add_all(list(graph.nodes), list(graph.edges))
|
|
327
|
+
if output in ("tree", "nodes", "calls"):
|
|
328
|
+
stored = await load_snapshot(store, root)
|
|
329
|
+
finally:
|
|
330
|
+
await store.dispose()
|
|
331
|
+
|
|
332
|
+
if not quiet:
|
|
333
|
+
verb: str = "indexed" if persist else "scanned"
|
|
334
|
+
print(
|
|
335
|
+
f"{verb} {graph.files} files, {len(graph.nodes)} nodes, "
|
|
336
|
+
f"{len(graph.edges)} edges -> {db.label} [{root}]"
|
|
337
|
+
+ (f" ({graph.skipped} skipped)" if graph.skipped else "")
|
|
338
|
+
)
|
|
339
|
+
rendered: PrintableGraph | None = stored if persist else graph
|
|
340
|
+
if output == "tree":
|
|
341
|
+
await print_tree(db, root, rendered)
|
|
342
|
+
elif output == "nodes":
|
|
343
|
+
await print_nodes(db, root, rendered)
|
|
344
|
+
elif output == "calls":
|
|
345
|
+
await print_calls(db, root, rendered)
|
|
346
|
+
return IndexResult(
|
|
347
|
+
root=root,
|
|
348
|
+
db_label=db.label,
|
|
349
|
+
files=graph.files,
|
|
350
|
+
nodes=len(graph.nodes),
|
|
351
|
+
edges=len(graph.edges),
|
|
352
|
+
skipped=graph.skipped,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
__all__ = [
|
|
357
|
+
"BuiltGraph",
|
|
358
|
+
"FileInput",
|
|
359
|
+
"IndexResult",
|
|
360
|
+
"OutputMode",
|
|
361
|
+
"PrintableGraph",
|
|
362
|
+
"ScannedSources",
|
|
363
|
+
"build_graph",
|
|
364
|
+
"out",
|
|
365
|
+
"parse_file_input",
|
|
366
|
+
"print_calls",
|
|
367
|
+
"print_nodes",
|
|
368
|
+
"print_tree",
|
|
369
|
+
"read",
|
|
370
|
+
]
|
codegraph/query.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Agent-facing query shaping: explore-first access to the code graph.
|
|
2
|
+
|
|
3
|
+
Agents never know node id shapes, so every exploration starts from
|
|
4
|
+
`files` (file ids are plain rel paths) or `search` (a name fragment
|
|
5
|
+
-> full rows with ids), then drills via `node` / `callees` /
|
|
6
|
+
`callers` / `children` / `imports` using the discovered ids. All
|
|
7
|
+
functions here are pure given store rows; I/O lives in `cli.py`.
|
|
8
|
+
|
|
9
|
+
JSON envelope (stable keys for chaining):
|
|
10
|
+
`{"root", "verb", "count", "truncated", "items": [...]}` where each
|
|
11
|
+
item is `{"id", "kind", "name", "file_path", "language",
|
|
12
|
+
"start_line", "end_line", "parent_id"}` plus `site_line` on
|
|
13
|
+
`callees` / `callers` items and `target_module` on `imports` items.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from codegraph.models import Node
|
|
21
|
+
from codegraph.tree import kind_label
|
|
22
|
+
|
|
23
|
+
DEFAULT_SEARCH_LIMIT: int = 20
|
|
24
|
+
"""Cap for `search` hits; excess sets `truncated` so agents narrow down."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_item(node: Node) -> dict[str, Any]:
|
|
28
|
+
"""Project a node onto its stable JSON item shape."""
|
|
29
|
+
return {
|
|
30
|
+
"id": node.id,
|
|
31
|
+
"kind": kind_label(node.kind),
|
|
32
|
+
"name": node.name,
|
|
33
|
+
"file_path": node.file_path,
|
|
34
|
+
"language": node.language,
|
|
35
|
+
"start_line": node.start_line,
|
|
36
|
+
"end_line": node.end_line,
|
|
37
|
+
"parent_id": node.parent_id,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def envelope(root: str, verb: str, items: list[dict[str, Any]]) -> dict[str, Any]:
|
|
42
|
+
"""Wrap result items in the stable chaining envelope."""
|
|
43
|
+
return {
|
|
44
|
+
"root": root,
|
|
45
|
+
"verb": verb,
|
|
46
|
+
"count": len(items),
|
|
47
|
+
"truncated": False,
|
|
48
|
+
"items": items,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def search_nodes(
|
|
53
|
+
nodes: list[Node],
|
|
54
|
+
fragment: str,
|
|
55
|
+
*,
|
|
56
|
+
kind: str | None = None,
|
|
57
|
+
file_path: str | None = None,
|
|
58
|
+
limit: int = DEFAULT_SEARCH_LIMIT,
|
|
59
|
+
) -> tuple[list[Node], bool]:
|
|
60
|
+
"""Substring-match def names (case-insensitive), in stable order.
|
|
61
|
+
|
|
62
|
+
Returns ``(hits, truncated)``. File nodes never match — `files`
|
|
63
|
+
lists those. ``kind`` / ``file_path`` narrow exact (kind is
|
|
64
|
+
matched case-insensitively on its plain value).
|
|
65
|
+
"""
|
|
66
|
+
needle: str = fragment.strip().lower()
|
|
67
|
+
wanted_kind: str | None = kind.strip().lower() if kind else None
|
|
68
|
+
hits: list[Node] = [
|
|
69
|
+
node
|
|
70
|
+
for node in nodes
|
|
71
|
+
if kind_label(node.kind) != "file"
|
|
72
|
+
and (not needle or needle in node.name.lower())
|
|
73
|
+
and (wanted_kind is None or kind_label(node.kind) == wanted_kind)
|
|
74
|
+
and (file_path is None or node.file_path == file_path)
|
|
75
|
+
]
|
|
76
|
+
hits.sort(key=lambda n: (n.file_path, n.start_line, n.name))
|
|
77
|
+
if len(hits) > limit:
|
|
78
|
+
return (hits[:limit], True)
|
|
79
|
+
return (hits, False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def exact_matches(
|
|
83
|
+
nodes: list[Node], name: str, file_path: str | None = None
|
|
84
|
+
) -> list[Node]:
|
|
85
|
+
"""Exact-name matches (file nodes excluded), in stable order.
|
|
86
|
+
|
|
87
|
+
Backs ``--name`` sugar on the drill verbs: zero hits and
|
|
88
|
+
ambiguity are both errors the caller reports with node counts.
|
|
89
|
+
"""
|
|
90
|
+
found: list[Node] = [
|
|
91
|
+
node
|
|
92
|
+
for node in nodes
|
|
93
|
+
if kind_label(node.kind) != "file"
|
|
94
|
+
and node.name == name
|
|
95
|
+
and (file_path is None or node.file_path == file_path)
|
|
96
|
+
]
|
|
97
|
+
found.sort(key=lambda n: (n.file_path, n.start_line))
|
|
98
|
+
return found
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_human(root: str, verb: str, items: list[dict[str, Any]]) -> str:
|
|
102
|
+
"""Render result items as one human-readable line each."""
|
|
103
|
+
lines: list[str] = [f"root: {root} ({verb}={len(items)})"]
|
|
104
|
+
for item in items:
|
|
105
|
+
name: str = str(item["name"])
|
|
106
|
+
kind: str = str(item["kind"])
|
|
107
|
+
file_path: str = str(item["file_path"])
|
|
108
|
+
span: str = f"(L{item['start_line']}-L{item['end_line']})"
|
|
109
|
+
extra: str = ""
|
|
110
|
+
if item.get("site_line") is not None:
|
|
111
|
+
extra += f" site=L{item['site_line']}"
|
|
112
|
+
if item.get("target_module") is not None:
|
|
113
|
+
extra += f" -> {item['target_module']}"
|
|
114
|
+
lines.append(f"{kind} {name} {span} file={file_path}{extra}")
|
|
115
|
+
return "\n".join(lines)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = [
|
|
119
|
+
"DEFAULT_SEARCH_LIMIT",
|
|
120
|
+
"envelope",
|
|
121
|
+
"exact_matches",
|
|
122
|
+
"render_human",
|
|
123
|
+
"search_nodes",
|
|
124
|
+
"to_item",
|
|
125
|
+
]
|