pycode-kg 0.16.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.
- pycode_kg/.DS_Store +0 -0
- pycode_kg/__init__.py +91 -0
- pycode_kg/__main__.py +11 -0
- pycode_kg/analysis/__init__.py +15 -0
- pycode_kg/analysis/bridge.py +108 -0
- pycode_kg/analysis/centrality.py +412 -0
- pycode_kg/analysis/framework_detector.py +103 -0
- pycode_kg/analysis/hybrid_rank.py +53 -0
- pycode_kg/app.py +1335 -0
- pycode_kg/architecture.py +624 -0
- pycode_kg/build_pycodekg_lancedb.py +15 -0
- pycode_kg/build_pycodekg_sqlite.py +15 -0
- pycode_kg/cli/__init__.py +29 -0
- pycode_kg/cli/cmd_analyze.py +96 -0
- pycode_kg/cli/cmd_architecture.py +150 -0
- pycode_kg/cli/cmd_bridges.py +26 -0
- pycode_kg/cli/cmd_build.py +154 -0
- pycode_kg/cli/cmd_build_full.py +242 -0
- pycode_kg/cli/cmd_centrality.py +131 -0
- pycode_kg/cli/cmd_explain.py +180 -0
- pycode_kg/cli/cmd_framework_nodes.py +18 -0
- pycode_kg/cli/cmd_hooks.py +137 -0
- pycode_kg/cli/cmd_init.py +312 -0
- pycode_kg/cli/cmd_mcp.py +71 -0
- pycode_kg/cli/cmd_model.py +53 -0
- pycode_kg/cli/cmd_query.py +211 -0
- pycode_kg/cli/cmd_snapshot.py +421 -0
- pycode_kg/cli/cmd_viz.py +180 -0
- pycode_kg/cli/main.py +23 -0
- pycode_kg/cli/options.py +63 -0
- pycode_kg/config.py +78 -0
- pycode_kg/graph.py +125 -0
- pycode_kg/index.py +542 -0
- pycode_kg/kg.py +220 -0
- pycode_kg/layout3d.py +470 -0
- pycode_kg/mcp/bridge_tools.py +19 -0
- pycode_kg/mcp/framework_tools.py +18 -0
- pycode_kg/mcp_server.py +1965 -0
- pycode_kg/module/__init__.py +83 -0
- pycode_kg/module/base.py +720 -0
- pycode_kg/module/extractor.py +276 -0
- pycode_kg/module/types.py +532 -0
- pycode_kg/pycodekg.py +543 -0
- pycode_kg/pycodekg_query.py +1 -0
- pycode_kg/pycodekg_snippet_packer.py +1 -0
- pycode_kg/pycodekg_thorough_analysis.py +2751 -0
- pycode_kg/pycodekg_viz.py +1 -0
- pycode_kg/pycodekg_viz3d.py +1 -0
- pycode_kg/ranking/__init__.py +1 -0
- pycode_kg/ranking/cli_rank.py +92 -0
- pycode_kg/ranking/coderank.py +555 -0
- pycode_kg/snapshots.py +612 -0
- pycode_kg/sql/004_add_centrality_table.sql +12 -0
- pycode_kg/store.py +766 -0
- pycode_kg/utils.py +39 -0
- pycode_kg/visitor.py +413 -0
- pycode_kg/viz3d.py +1353 -0
- pycode_kg/viz3d_timeline.py +364 -0
- pycode_kg-0.16.0.dist-info/METADATA +305 -0
- pycode_kg-0.16.0.dist-info/RECORD +63 -0
- pycode_kg-0.16.0.dist-info/WHEEL +4 -0
- pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
- pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
module/types.py
|
|
4
|
+
|
|
5
|
+
Shared result types and pure utility functions for the KGModule pipeline.
|
|
6
|
+
|
|
7
|
+
These are domain-agnostic and used by both KGModule (base) and any
|
|
8
|
+
concrete implementation (PyCodeKG, TypeScriptKG, GenomicsKG, …).
|
|
9
|
+
|
|
10
|
+
Author: Eric G. Suchanek, PhD
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Result types
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class BuildStats:
|
|
27
|
+
"""
|
|
28
|
+
Statistics returned by :meth:`KGModule.build` and related methods.
|
|
29
|
+
|
|
30
|
+
:param repo_root: Repository root that was analysed.
|
|
31
|
+
:param db_path: Path to the SQLite database.
|
|
32
|
+
:param total_nodes: Total nodes written to SQLite.
|
|
33
|
+
:param total_edges: Total edges written to SQLite.
|
|
34
|
+
:param node_counts: Node counts broken down by kind.
|
|
35
|
+
:param edge_counts: Edge counts broken down by relation.
|
|
36
|
+
:param indexed_rows: Number of nodes embedded into LanceDB
|
|
37
|
+
(``None`` if the index was not built).
|
|
38
|
+
:param index_dim: Embedding dimension (``None`` if not built).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
repo_root: str
|
|
42
|
+
db_path: str
|
|
43
|
+
total_nodes: int
|
|
44
|
+
total_edges: int
|
|
45
|
+
node_counts: dict[str, int]
|
|
46
|
+
edge_counts: dict[str, int]
|
|
47
|
+
indexed_rows: int | None = None
|
|
48
|
+
index_dim: int | None = None
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict:
|
|
51
|
+
"""Serialise the stats to a plain dictionary.
|
|
52
|
+
|
|
53
|
+
:return: Dictionary containing all ``BuildStats`` fields.
|
|
54
|
+
"""
|
|
55
|
+
return {
|
|
56
|
+
"repo_root": self.repo_root,
|
|
57
|
+
"db_path": self.db_path,
|
|
58
|
+
"total_nodes": self.total_nodes,
|
|
59
|
+
"total_edges": self.total_edges,
|
|
60
|
+
"node_counts": self.node_counts,
|
|
61
|
+
"edge_counts": self.edge_counts,
|
|
62
|
+
"indexed_rows": self.indexed_rows,
|
|
63
|
+
"index_dim": self.index_dim,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str:
|
|
67
|
+
"""Render a human-readable multi-line summary of the build statistics.
|
|
68
|
+
|
|
69
|
+
:return: Formatted string with repo root, db path, node/edge counts,
|
|
70
|
+
and (if available) indexed vector count and dimension.
|
|
71
|
+
"""
|
|
72
|
+
lines = [
|
|
73
|
+
f"repo_root : {self.repo_root}",
|
|
74
|
+
f"db_path : {self.db_path}",
|
|
75
|
+
f"nodes : {self.total_nodes} {self.node_counts}",
|
|
76
|
+
f"edges : {self.total_edges} {self.edge_counts}",
|
|
77
|
+
]
|
|
78
|
+
if self.indexed_rows is not None:
|
|
79
|
+
lines.append(f"indexed : {self.indexed_rows} vectors dim={self.index_dim}")
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class QueryResult:
|
|
85
|
+
"""
|
|
86
|
+
Result of a hybrid query (:meth:`KGModule.query`).
|
|
87
|
+
|
|
88
|
+
:param query: Original query string.
|
|
89
|
+
:param seeds: Number of semantic seed nodes.
|
|
90
|
+
:param expanded_nodes: Total nodes after graph expansion.
|
|
91
|
+
:param returned_nodes: Nodes returned after filtering.
|
|
92
|
+
:param hop: Hop count used.
|
|
93
|
+
:param rels: Edge relations used for expansion.
|
|
94
|
+
:param nodes: List of node dicts (sorted by rank).
|
|
95
|
+
:param edges: List of edge dicts within the returned node set.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
query: str
|
|
99
|
+
seeds: int
|
|
100
|
+
expanded_nodes: int
|
|
101
|
+
returned_nodes: int
|
|
102
|
+
hop: int
|
|
103
|
+
rels: list[str]
|
|
104
|
+
nodes: list[dict]
|
|
105
|
+
edges: list[dict]
|
|
106
|
+
|
|
107
|
+
def to_dict(self) -> dict:
|
|
108
|
+
"""Serialise the query result to a plain dictionary.
|
|
109
|
+
|
|
110
|
+
:return: Dictionary containing all ``QueryResult`` fields.
|
|
111
|
+
"""
|
|
112
|
+
return {
|
|
113
|
+
"query": self.query,
|
|
114
|
+
"seeds": self.seeds,
|
|
115
|
+
"expanded_nodes": self.expanded_nodes,
|
|
116
|
+
"returned_nodes": self.returned_nodes,
|
|
117
|
+
"hop": self.hop,
|
|
118
|
+
"rels": self.rels,
|
|
119
|
+
"nodes": self.nodes,
|
|
120
|
+
"edges": self.edges,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
124
|
+
"""Serialise to JSON string."""
|
|
125
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
126
|
+
|
|
127
|
+
def print_summary(self) -> None:
|
|
128
|
+
"""Print a human-readable summary of the query result to stdout."""
|
|
129
|
+
sep = "=" * 80
|
|
130
|
+
print(sep)
|
|
131
|
+
print(f"QUERY: {self.query}")
|
|
132
|
+
print(
|
|
133
|
+
f"Seeds: {self.seeds} | Expanded: {self.expanded_nodes} "
|
|
134
|
+
f"| Returned: {self.returned_nodes} | hop={self.hop}"
|
|
135
|
+
)
|
|
136
|
+
print(f"Rels: {', '.join(self.rels)}")
|
|
137
|
+
print(sep)
|
|
138
|
+
for n in self.nodes:
|
|
139
|
+
print(
|
|
140
|
+
f"{n['kind']:8s} {(n.get('module_path') or ''):40s} "
|
|
141
|
+
f"{n.get('qualname') or n['name']} [{n['id']}]"
|
|
142
|
+
)
|
|
143
|
+
if n.get("docstring"):
|
|
144
|
+
ds0 = n["docstring"].strip().splitlines()[0]
|
|
145
|
+
print(f" {ds0[:120]}")
|
|
146
|
+
print()
|
|
147
|
+
print("-" * 80)
|
|
148
|
+
print(f"EDGES (within returned set): {len(self.edges)}")
|
|
149
|
+
print("-" * 80)
|
|
150
|
+
for e in sorted(self.edges, key=lambda x: (x["rel"], x["src"], x["dst"])):
|
|
151
|
+
print(f" {e['src']} -[{e['rel']}]-> {e['dst']}")
|
|
152
|
+
print(sep)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class Snippet:
|
|
157
|
+
"""
|
|
158
|
+
A source-grounded snippet.
|
|
159
|
+
|
|
160
|
+
:param path: Repo-relative file path.
|
|
161
|
+
:param start: 1-based start line (inclusive).
|
|
162
|
+
:param end: 1-based end line (inclusive).
|
|
163
|
+
:param text: Line-numbered source text.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
path: str
|
|
167
|
+
start: int
|
|
168
|
+
end: int
|
|
169
|
+
text: str
|
|
170
|
+
|
|
171
|
+
def to_dict(self) -> dict:
|
|
172
|
+
"""Serialise the snippet to a plain dictionary.
|
|
173
|
+
|
|
174
|
+
:return: Dictionary with ``path``, ``start``, ``end``, and ``text`` keys.
|
|
175
|
+
"""
|
|
176
|
+
return {"path": self.path, "start": self.start, "end": self.end, "text": self.text}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class SnippetPack:
|
|
181
|
+
"""
|
|
182
|
+
Result of :meth:`KGModule.pack` — nodes with attached source snippets.
|
|
183
|
+
|
|
184
|
+
:param query: Original query string.
|
|
185
|
+
:param seeds: Number of semantic seed nodes.
|
|
186
|
+
:param expanded_nodes: Total nodes after graph expansion.
|
|
187
|
+
:param returned_nodes: Nodes returned after deduplication.
|
|
188
|
+
:param hop: Hop count used.
|
|
189
|
+
:param rels: Edge relations used for expansion.
|
|
190
|
+
:param model: Embedding model name.
|
|
191
|
+
:param nodes: Node dicts, each optionally containing a ``snippet`` key.
|
|
192
|
+
:param edges: Edge dicts within the returned node set.
|
|
193
|
+
:param warnings: Non-fatal issues encountered during packing.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
query: str
|
|
197
|
+
seeds: int
|
|
198
|
+
expanded_nodes: int
|
|
199
|
+
returned_nodes: int
|
|
200
|
+
hop: int
|
|
201
|
+
rels: list[str]
|
|
202
|
+
model: str
|
|
203
|
+
nodes: list[dict]
|
|
204
|
+
edges: list[dict]
|
|
205
|
+
warnings: list[str] = field(default_factory=list)
|
|
206
|
+
|
|
207
|
+
def to_dict(self) -> dict:
|
|
208
|
+
"""Serialise the snippet pack to a plain dictionary."""
|
|
209
|
+
return {
|
|
210
|
+
"query": self.query,
|
|
211
|
+
"seeds": self.seeds,
|
|
212
|
+
"expanded_nodes": self.expanded_nodes,
|
|
213
|
+
"returned_nodes": self.returned_nodes,
|
|
214
|
+
"hop": self.hop,
|
|
215
|
+
"rels": self.rels,
|
|
216
|
+
"model": self.model,
|
|
217
|
+
"nodes": self.nodes,
|
|
218
|
+
"edges": self.edges,
|
|
219
|
+
"warnings": self.warnings,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
223
|
+
"""Serialise to JSON string."""
|
|
224
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
225
|
+
|
|
226
|
+
def to_markdown(self) -> str:
|
|
227
|
+
"""Render the snippet pack as a Markdown context document.
|
|
228
|
+
|
|
229
|
+
:return: Markdown-formatted string representing the full context pack.
|
|
230
|
+
"""
|
|
231
|
+
out: list[str] = []
|
|
232
|
+
out.append("# KGModule Snippet Pack\n")
|
|
233
|
+
out.append(f"**Query:** `{self.query}` ")
|
|
234
|
+
out.append(f"**Seeds:** {self.seeds} ")
|
|
235
|
+
out.append(f"**Expanded nodes:** {self.expanded_nodes} (returned: {self.returned_nodes}) ")
|
|
236
|
+
out.append(f"**hop:** {self.hop} ")
|
|
237
|
+
out.append(f"**rels:** {', '.join(self.rels)} ")
|
|
238
|
+
out.append(f"**model:** {self.model} ")
|
|
239
|
+
out.append("\n---\n")
|
|
240
|
+
|
|
241
|
+
if self.warnings:
|
|
242
|
+
out.append("## Warnings\n")
|
|
243
|
+
for w in self.warnings:
|
|
244
|
+
out.append(f"- {w}")
|
|
245
|
+
out.append("")
|
|
246
|
+
|
|
247
|
+
out.append("## Nodes\n")
|
|
248
|
+
for n in self.nodes:
|
|
249
|
+
out.append(f"### {n['kind']} — `{n.get('qualname') or n['name']}`")
|
|
250
|
+
out.append(f"- id: `{n['id']}`")
|
|
251
|
+
if n.get("module_path"):
|
|
252
|
+
out.append(f"- module: `{n['module_path']}`")
|
|
253
|
+
if n.get("lineno") is not None:
|
|
254
|
+
out.append(f"- line: {n['lineno']}")
|
|
255
|
+
if n.get("docstring"):
|
|
256
|
+
ds0 = n["docstring"].strip().splitlines()[0]
|
|
257
|
+
out.append(f"- doc: {ds0[:140]}")
|
|
258
|
+
if n.get("relevance"):
|
|
259
|
+
rel = n["relevance"]
|
|
260
|
+
_score = rel.get("score", 0.0)
|
|
261
|
+
_tier = "HIGH" if _score >= 0.60 else ("MEDIUM" if _score >= 0.45 else "LOW")
|
|
262
|
+
out.append(
|
|
263
|
+
"- relevance: "
|
|
264
|
+
f"{_score:.3f} [{_tier}] "
|
|
265
|
+
f"(semantic={rel.get('semantic', 0.0):.3f}, "
|
|
266
|
+
f"lexical={rel.get('lexical', 0.0):.3f}, "
|
|
267
|
+
f"docstring_signal={rel.get('docstring_signal', 0.0):.3f}, "
|
|
268
|
+
f"hop={rel.get('hop', 0)})"
|
|
269
|
+
)
|
|
270
|
+
sn = n.get("snippet")
|
|
271
|
+
if sn:
|
|
272
|
+
end_lineno = n.get("end_lineno")
|
|
273
|
+
if end_lineno is not None and sn["end"] < end_lineno:
|
|
274
|
+
out.append(
|
|
275
|
+
f"*(truncated: showing lines {sn['start']}–{sn['end']} "
|
|
276
|
+
f"of {n.get('lineno', sn['start'])}–{end_lineno})*"
|
|
277
|
+
)
|
|
278
|
+
out.append("")
|
|
279
|
+
out.append(f"```\n{sn['text']}\n```")
|
|
280
|
+
out.append("")
|
|
281
|
+
|
|
282
|
+
out.append("\n---\n")
|
|
283
|
+
out.append("## Edges\n")
|
|
284
|
+
for e in self.edges:
|
|
285
|
+
out.append(f"- `{e['src']}` -[{e['rel']}]-> `{e['dst']}`")
|
|
286
|
+
out.append("")
|
|
287
|
+
return "\n".join(out)
|
|
288
|
+
|
|
289
|
+
def save(self, path: str | Path, *, fmt: str = "md") -> None:
|
|
290
|
+
"""Write the pack to a file.
|
|
291
|
+
|
|
292
|
+
:param path: Output file path.
|
|
293
|
+
:param fmt: ``"md"`` for Markdown or ``"json"`` for JSON.
|
|
294
|
+
"""
|
|
295
|
+
text = self.to_markdown() if fmt == "md" else self.to_json()
|
|
296
|
+
Path(path).write_text(text, encoding="utf-8")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
# Scoring utilities (domain-agnostic)
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def semantic_score_from_distance(distance: float) -> float:
|
|
305
|
+
"""Convert embedding distance to a bounded similarity-like score.
|
|
306
|
+
|
|
307
|
+
:param distance: Distance value returned by vector search.
|
|
308
|
+
:return: Score in ``[0, 1]`` where higher is better.
|
|
309
|
+
"""
|
|
310
|
+
d = max(0.0, float(distance))
|
|
311
|
+
return 1.0 / (1.0 + d)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def query_tokens(query: str) -> set[str]:
|
|
315
|
+
"""Tokenize a natural-language query for lightweight lexical overlap scoring.
|
|
316
|
+
|
|
317
|
+
:param query: Raw query text.
|
|
318
|
+
:return: Lower-cased alphanumeric tokens with length >= 2.
|
|
319
|
+
"""
|
|
320
|
+
tokens: set[str] = set()
|
|
321
|
+
for tok in re.findall(r"[A-Za-z0-9_]+", query.lower()):
|
|
322
|
+
if len(tok) >= 2:
|
|
323
|
+
tokens.add(tok)
|
|
324
|
+
if "_" in tok:
|
|
325
|
+
for part in tok.split("_"):
|
|
326
|
+
if len(part) >= 2:
|
|
327
|
+
tokens.add(part)
|
|
328
|
+
return tokens
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def normalize_query_text(query: str) -> str:
|
|
332
|
+
"""Normalize query text for semantic retrieval.
|
|
333
|
+
|
|
334
|
+
:param query: Raw user query.
|
|
335
|
+
:return: Normalized query string.
|
|
336
|
+
"""
|
|
337
|
+
return re.sub(r"[_-]+", " ", query).strip()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def docstring_signal(docstring: str | None) -> float:
|
|
341
|
+
"""Estimate docstring signal quality in ``[0, 1]``.
|
|
342
|
+
|
|
343
|
+
:param docstring: Node docstring text.
|
|
344
|
+
:return: Signal score in ``[0, 1]``.
|
|
345
|
+
"""
|
|
346
|
+
if not docstring:
|
|
347
|
+
return 0.0
|
|
348
|
+
tokens = re.findall(r"[A-Za-z0-9_]+", docstring.lower())
|
|
349
|
+
if not tokens:
|
|
350
|
+
return 0.0
|
|
351
|
+
unique_ratio = len(set(tokens)) / max(1, len(tokens))
|
|
352
|
+
length_score = min(1.0, len(tokens) / 40.0)
|
|
353
|
+
return max(0.0, min(1.0, 0.6 * length_score + 0.4 * unique_ratio))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def lexical_overlap_score(query_tokens_set: set[str], node: dict) -> float:
|
|
357
|
+
"""Compute lexical overlap between query tokens and node text features.
|
|
358
|
+
|
|
359
|
+
:param query_tokens_set: Tokenized query terms.
|
|
360
|
+
:param node: Node dictionary from the store.
|
|
361
|
+
:return: Overlap score in ``[0, 1]``.
|
|
362
|
+
"""
|
|
363
|
+
if not query_tokens_set:
|
|
364
|
+
return 0.0
|
|
365
|
+
haystack = " ".join(
|
|
366
|
+
[
|
|
367
|
+
str(node.get("name") or ""),
|
|
368
|
+
str(node.get("qualname") or ""),
|
|
369
|
+
str(node.get("module_path") or ""),
|
|
370
|
+
str(node.get("docstring") or ""),
|
|
371
|
+
]
|
|
372
|
+
).lower()
|
|
373
|
+
node_toks = set(re.findall(r"[A-Za-z0-9_]+", haystack))
|
|
374
|
+
if not node_toks:
|
|
375
|
+
return 0.0
|
|
376
|
+
return len(query_tokens_set & node_toks) / len(query_tokens_set)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
# Snippet / span utilities (domain-agnostic)
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def safe_join(repo_root: Path, rel_path: str) -> Path:
|
|
385
|
+
"""Safely join a repo-relative path to the repository root.
|
|
386
|
+
|
|
387
|
+
:param repo_root: Absolute path to the repository root directory.
|
|
388
|
+
:param rel_path: Repository-relative path to join.
|
|
389
|
+
:return: Resolved absolute ``Path`` within ``repo_root``.
|
|
390
|
+
:raises ValueError: If the resolved path escapes ``repo_root``.
|
|
391
|
+
"""
|
|
392
|
+
p = (repo_root / rel_path).resolve()
|
|
393
|
+
rr = repo_root.resolve()
|
|
394
|
+
if rr not in p.parents and p != rr:
|
|
395
|
+
raise ValueError(f"Unsafe path outside repo_root: {rel_path!r}")
|
|
396
|
+
return p
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def read_lines(path: Path) -> list[str]:
|
|
400
|
+
"""Read all lines from a file, returning ``[]`` if the file is missing.
|
|
401
|
+
|
|
402
|
+
:param path: Absolute path to the file.
|
|
403
|
+
:return: List of lines (without newlines) or ``[]`` on error.
|
|
404
|
+
"""
|
|
405
|
+
try:
|
|
406
|
+
return path.read_text(encoding="utf-8").splitlines()
|
|
407
|
+
except UnicodeDecodeError:
|
|
408
|
+
return path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
409
|
+
except FileNotFoundError:
|
|
410
|
+
return []
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def compute_span(
|
|
414
|
+
kind: str,
|
|
415
|
+
lineno: int | None,
|
|
416
|
+
end_lineno: int | None,
|
|
417
|
+
*,
|
|
418
|
+
context: int,
|
|
419
|
+
max_lines: int,
|
|
420
|
+
file_nlines: int,
|
|
421
|
+
) -> tuple[int, int]:
|
|
422
|
+
"""Compute the 1-based ``(start, end)`` line span for a node's source snippet.
|
|
423
|
+
|
|
424
|
+
:param kind: Node kind (e.g. ``"module"``, ``"function"``, ``"class"``).
|
|
425
|
+
:param lineno: 1-based start line of the definition, or ``None``.
|
|
426
|
+
:param end_lineno: 1-based end line of the definition, or ``None``.
|
|
427
|
+
:param context: Number of extra lines to include before and after the span.
|
|
428
|
+
:param max_lines: Maximum number of lines the span may contain.
|
|
429
|
+
:param file_nlines: Total number of lines in the source file.
|
|
430
|
+
:return: ``(start, end)`` tuple of 1-based line numbers.
|
|
431
|
+
"""
|
|
432
|
+
if file_nlines <= 0:
|
|
433
|
+
return (1, 0)
|
|
434
|
+
if kind == "module":
|
|
435
|
+
return (1, min(file_nlines, max_lines))
|
|
436
|
+
if lineno is None:
|
|
437
|
+
return (1, min(file_nlines, max_lines))
|
|
438
|
+
if end_lineno is not None and end_lineno >= lineno:
|
|
439
|
+
start = max(1, lineno - context)
|
|
440
|
+
end = min(file_nlines, end_lineno + context)
|
|
441
|
+
if (end - start + 1) > max_lines:
|
|
442
|
+
end = min(file_nlines, start + max_lines - 1)
|
|
443
|
+
return (start, end)
|
|
444
|
+
start = max(1, lineno - context)
|
|
445
|
+
end = min(file_nlines, lineno + context)
|
|
446
|
+
if (end - start + 1) > max_lines:
|
|
447
|
+
end = min(file_nlines, start + max_lines - 1)
|
|
448
|
+
return (start, end)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def make_snippet(rel_path: str, lines: list[str], start: int, end: int) -> dict:
|
|
452
|
+
"""Build a snippet dictionary from a slice of source lines.
|
|
453
|
+
|
|
454
|
+
:param rel_path: Repository-relative file path.
|
|
455
|
+
:param lines: Full list of source lines for the file (0-indexed).
|
|
456
|
+
:param start: 1-based first line of the snippet (inclusive).
|
|
457
|
+
:param end: 1-based last line of the snippet (inclusive).
|
|
458
|
+
:return: Dictionary with ``path``, ``start``, ``end``, and ``text`` keys.
|
|
459
|
+
"""
|
|
460
|
+
s0 = max(0, start - 1)
|
|
461
|
+
e0 = max(0, end)
|
|
462
|
+
chunk = lines[s0:e0]
|
|
463
|
+
numbered = "\n".join(f"{i:>5d}: {line}" for i, line in enumerate(chunk, start=start))
|
|
464
|
+
return {"path": rel_path, "start": start, "end": end, "text": numbered}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def make_module_summary(
|
|
468
|
+
rel_path: str,
|
|
469
|
+
lines: list[str],
|
|
470
|
+
docstring: str | None,
|
|
471
|
+
contained_nodes: list[dict],
|
|
472
|
+
max_lines: int,
|
|
473
|
+
) -> dict:
|
|
474
|
+
"""Build a summary snippet for a module-level node.
|
|
475
|
+
|
|
476
|
+
:param rel_path: Repository-relative file path.
|
|
477
|
+
:param lines: Full list of source lines for the file.
|
|
478
|
+
:param docstring: Module docstring text, or ``None``.
|
|
479
|
+
:param contained_nodes: List of node dicts directly contained by this module.
|
|
480
|
+
:param max_lines: Maximum lines for the summary.
|
|
481
|
+
:return: Snippet dict with ``is_summary=True``.
|
|
482
|
+
"""
|
|
483
|
+
out: list[str] = []
|
|
484
|
+
if docstring:
|
|
485
|
+
for dl in docstring.strip().splitlines():
|
|
486
|
+
out.append(dl)
|
|
487
|
+
out.append("")
|
|
488
|
+
|
|
489
|
+
by_kind: dict[str, list[dict]] = {}
|
|
490
|
+
for cn in contained_nodes:
|
|
491
|
+
k = cn.get("kind", "unknown")
|
|
492
|
+
by_kind.setdefault(k, []).append(cn)
|
|
493
|
+
|
|
494
|
+
for kind in ("class", "function", "method"):
|
|
495
|
+
group = by_kind.get(kind, [])
|
|
496
|
+
if not group:
|
|
497
|
+
continue
|
|
498
|
+
out.append(f"# {kind}s ({len(group)}):")
|
|
499
|
+
for cn in sorted(group, key=lambda x: x.get("lineno") or 0):
|
|
500
|
+
name = cn.get("qualname") or cn.get("name", "?")
|
|
501
|
+
ln = cn.get("lineno")
|
|
502
|
+
ds = cn.get("docstring") or ""
|
|
503
|
+
ds_first = ds.strip().splitlines()[0][:80] if ds.strip() else ""
|
|
504
|
+
loc = f"L{ln}" if ln else "?"
|
|
505
|
+
if ds_first:
|
|
506
|
+
out.append(f"# {name} ({loc}) — {ds_first}")
|
|
507
|
+
else:
|
|
508
|
+
out.append(f"# {name} ({loc})")
|
|
509
|
+
out.append("")
|
|
510
|
+
|
|
511
|
+
summary_lines = out[:max_lines]
|
|
512
|
+
text = "\n".join(f"{'':>5s} {line}" for line in summary_lines)
|
|
513
|
+
return {
|
|
514
|
+
"path": rel_path,
|
|
515
|
+
"start": 1,
|
|
516
|
+
"end": min(len(lines), max_lines),
|
|
517
|
+
"text": text,
|
|
518
|
+
"is_summary": True,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def spans_overlap(a: tuple[int, int], b: tuple[int, int], gap: int = 2) -> bool:
|
|
523
|
+
"""Return ``True`` when two line spans are considered overlapping.
|
|
524
|
+
|
|
525
|
+
:param a: First span as a ``(start, end)`` tuple of 1-based line numbers.
|
|
526
|
+
:param b: Second span as a ``(start, end)`` tuple of 1-based line numbers.
|
|
527
|
+
:param gap: Minimum separation lines for non-overlapping spans.
|
|
528
|
+
:return: ``True`` if the spans overlap or are within ``gap`` lines of each other.
|
|
529
|
+
"""
|
|
530
|
+
a0, a1 = a
|
|
531
|
+
b0, b1 = b
|
|
532
|
+
return not (a1 + gap < b0 or b1 + gap < a0)
|