tscode-kg 0.2.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.
- tscode_kg/__init__.py +42 -0
- tscode_kg/__main__.py +6 -0
- tscode_kg/analysis.py +1829 -0
- tscode_kg/app.py +1355 -0
- tscode_kg/bridge.py +114 -0
- tscode_kg/centrality.py +434 -0
- tscode_kg/cli/__init__.py +1 -0
- tscode_kg/cli/cmd_analyze.py +69 -0
- tscode_kg/cli/cmd_bridges.py +38 -0
- tscode_kg/cli/cmd_build.py +86 -0
- tscode_kg/cli/cmd_centrality.py +124 -0
- tscode_kg/cli/cmd_explain.py +58 -0
- tscode_kg/cli/cmd_framework_nodes.py +43 -0
- tscode_kg/cli/cmd_hooks.py +125 -0
- tscode_kg/cli/cmd_init.py +234 -0
- tscode_kg/cli/cmd_mcp.py +35 -0
- tscode_kg/cli/cmd_model.py +52 -0
- tscode_kg/cli/cmd_query.py +75 -0
- tscode_kg/cli/cmd_snapshot.py +431 -0
- tscode_kg/cli/cmd_viz.py +175 -0
- tscode_kg/cli/main.py +56 -0
- tscode_kg/coderank.py +564 -0
- tscode_kg/config.py +36 -0
- tscode_kg/explain.py +270 -0
- tscode_kg/extractor.py +827 -0
- tscode_kg/framework_detector.py +106 -0
- tscode_kg/kg.py +193 -0
- tscode_kg/layout3d.py +492 -0
- tscode_kg/mcp_server.py +1412 -0
- tscode_kg/snapshots.py +64 -0
- tscode_kg/viz3d.py +1457 -0
- tscode_kg/viz3d_timeline.py +369 -0
- tscode_kg-0.2.0.dist-info/METADATA +196 -0
- tscode_kg-0.2.0.dist-info/RECORD +37 -0
- tscode_kg-0.2.0.dist-info/WHEEL +4 -0
- tscode_kg-0.2.0.dist-info/entry_points.txt +15 -0
- tscode_kg-0.2.0.dist-info/licenses/LICENSE +24 -0
tscode_kg/cli/cmd_mcp.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli/cmd_mcp.py — tscodekg mcp command: launch the MCP server.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command("mcp")
|
|
11
|
+
@click.option("--repo", default=".", show_default=True, help="Repository root.")
|
|
12
|
+
@click.option(
|
|
13
|
+
"--db",
|
|
14
|
+
default=".tscodekg/graph.sqlite",
|
|
15
|
+
show_default=True,
|
|
16
|
+
help="SQLite database path.",
|
|
17
|
+
)
|
|
18
|
+
@click.option(
|
|
19
|
+
"--vectors",
|
|
20
|
+
default=".tscodekg/vectors.sqlite",
|
|
21
|
+
show_default=True,
|
|
22
|
+
help="sqlite-vec store path.",
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--transport",
|
|
26
|
+
default="stdio",
|
|
27
|
+
show_default=True,
|
|
28
|
+
type=click.Choice(["stdio", "sse"]),
|
|
29
|
+
help="MCP transport.",
|
|
30
|
+
)
|
|
31
|
+
def mcp_cmd(repo: str, db: str, vectors: str, transport: str) -> None:
|
|
32
|
+
"""Launch the TypeScriptKG MCP server."""
|
|
33
|
+
from tscode_kg.mcp_server import main # pylint: disable=import-outside-toplevel
|
|
34
|
+
|
|
35
|
+
main(["--repo", repo, "--db", db, "--vectors", vectors, "--transport", transport])
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli/cmd_model.py — tscodekg download-model command.
|
|
3
|
+
|
|
4
|
+
download-model — download and cache the sentence-transformer model for offline use
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command("download-model")
|
|
13
|
+
@click.option(
|
|
14
|
+
"--model",
|
|
15
|
+
default=None,
|
|
16
|
+
help="SentenceTransformer model name to download (default: shared kg_utils default).",
|
|
17
|
+
)
|
|
18
|
+
@click.option(
|
|
19
|
+
"--force",
|
|
20
|
+
is_flag=True,
|
|
21
|
+
help="Re-download even if a local copy already exists.",
|
|
22
|
+
)
|
|
23
|
+
def download_model(model: str | None, force: bool) -> None:
|
|
24
|
+
"""Download and cache the embedding model for offline use.
|
|
25
|
+
|
|
26
|
+
The model is saved to the shared kg_utils model cache
|
|
27
|
+
(``./.kgcache/models/<model>/`` by default, overridable via the
|
|
28
|
+
``KGRAG_MODEL_DIR`` environment variable). Once cached,
|
|
29
|
+
``tscodekg build`` and ``tscodekg query`` use this local copy without
|
|
30
|
+
any network access.
|
|
31
|
+
"""
|
|
32
|
+
from kg_utils.semantic import ( # pylint: disable=import-outside-toplevel
|
|
33
|
+
DEFAULT_MODEL,
|
|
34
|
+
_local_model_path,
|
|
35
|
+
)
|
|
36
|
+
from sentence_transformers import ( # pylint: disable=import-outside-toplevel
|
|
37
|
+
SentenceTransformer,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
model = model or DEFAULT_MODEL
|
|
41
|
+
local_path = _local_model_path(model)
|
|
42
|
+
|
|
43
|
+
if local_path.exists() and not force:
|
|
44
|
+
click.echo(f"Model already cached at {local_path}")
|
|
45
|
+
click.echo("Use --force to re-download.")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
click.echo(f"Downloading model '{model}'...")
|
|
49
|
+
st_model = SentenceTransformer(model)
|
|
50
|
+
local_path.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
st_model.save(str(local_path))
|
|
52
|
+
click.echo(f"OK: model saved to {local_path}")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli/cmd_query.py — tscodekg query / pack commands.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("query")
|
|
16
|
+
@click.argument("q")
|
|
17
|
+
@click.option("--repo", default=".", show_default=True, help="Repository root.")
|
|
18
|
+
@click.option("-k", default=8, show_default=True, help="Semantic seed count.")
|
|
19
|
+
@click.option("--hop", default=1, show_default=True, help="Graph expansion hops.")
|
|
20
|
+
@click.option("--max-nodes", default=25, show_default=True, help="Maximum nodes returned.")
|
|
21
|
+
@click.option(
|
|
22
|
+
"--rerank",
|
|
23
|
+
default="hybrid",
|
|
24
|
+
show_default=True,
|
|
25
|
+
type=click.Choice(["hybrid", "semantic", "legacy"]),
|
|
26
|
+
help="Reranking strategy.",
|
|
27
|
+
)
|
|
28
|
+
def query(q: str, repo: str, k: int, hop: int, max_nodes: int, rerank: str) -> None:
|
|
29
|
+
"""Query the TypeScript/JS knowledge graph."""
|
|
30
|
+
from tscode_kg.kg import TypeScriptKG # pylint: disable=import-outside-toplevel
|
|
31
|
+
|
|
32
|
+
kg = TypeScriptKG(repo_root=Path(repo).resolve())
|
|
33
|
+
result = kg.query(q, k=k, hop=hop, max_nodes=max_nodes, rerank_mode=rerank)
|
|
34
|
+
result.print_summary()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.command("pack")
|
|
38
|
+
@click.argument("q")
|
|
39
|
+
@click.option("--repo", default=".", show_default=True, help="Repository root.")
|
|
40
|
+
@click.option("-k", default=8, show_default=True, help="Semantic seed count.")
|
|
41
|
+
@click.option("--hop", default=1, show_default=True, help="Graph expansion hops.")
|
|
42
|
+
@click.option("--max-nodes", default=15, show_default=True, help="Maximum nodes in pack.")
|
|
43
|
+
@click.option("--max-lines", default=60, show_default=True, help="Maximum lines per snippet.")
|
|
44
|
+
@click.option(
|
|
45
|
+
"--rerank",
|
|
46
|
+
default="hybrid",
|
|
47
|
+
show_default=True,
|
|
48
|
+
type=click.Choice(["hybrid", "semantic", "legacy"]),
|
|
49
|
+
help="Reranking strategy.",
|
|
50
|
+
)
|
|
51
|
+
@click.option("--out", default=None, help="Output file path (.md or .json).")
|
|
52
|
+
def pack(
|
|
53
|
+
q: str,
|
|
54
|
+
repo: str,
|
|
55
|
+
k: int,
|
|
56
|
+
hop: int,
|
|
57
|
+
max_nodes: int,
|
|
58
|
+
max_lines: int,
|
|
59
|
+
rerank: str,
|
|
60
|
+
out: str | None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Pack source snippets from the TypeScript/JS knowledge graph."""
|
|
63
|
+
from tscode_kg.kg import TypeScriptKG # pylint: disable=import-outside-toplevel
|
|
64
|
+
|
|
65
|
+
kg = TypeScriptKG(repo_root=Path(repo).resolve())
|
|
66
|
+
pack_result = kg.pack(
|
|
67
|
+
q, k=k, hop=hop, max_nodes=max_nodes, max_lines=max_lines, rerank_mode=rerank
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if out:
|
|
71
|
+
fmt = "json" if out.endswith(".json") else "md"
|
|
72
|
+
pack_result.save(out, fmt=fmt)
|
|
73
|
+
console.print(f"[green]Saved to {out}[/green]")
|
|
74
|
+
else:
|
|
75
|
+
console.print(pack_result.to_markdown())
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli/cmd_snapshot.py — tscodekg snapshot subcommands.
|
|
3
|
+
|
|
4
|
+
Manage temporal snapshots of TypeScriptKG metrics:
|
|
5
|
+
|
|
6
|
+
snapshot save — capture current metrics and save snapshot
|
|
7
|
+
snapshot list — show all snapshots with key metrics
|
|
8
|
+
snapshot show — display full snapshot details
|
|
9
|
+
snapshot diff — compare two snapshots
|
|
10
|
+
snapshot prune — remove vestigial snapshots
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.group("snapshot")
|
|
25
|
+
def snapshot() -> None:
|
|
26
|
+
"""Manage temporal snapshots of TypeScriptKG metrics."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_snapshots_dir(snapshots_dir: str | None, repo_root: Path | None = None) -> Path:
|
|
30
|
+
base = repo_root if repo_root is not None else Path.cwd()
|
|
31
|
+
return Path(snapshots_dir).resolve() if snapshots_dir else base / ".tscodekg" / "snapshots"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@snapshot.command("save")
|
|
35
|
+
@click.argument("version", metavar="VERSION", default="", required=False)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--repo",
|
|
38
|
+
default=".",
|
|
39
|
+
type=click.Path(exists=True),
|
|
40
|
+
show_default=True,
|
|
41
|
+
help="Repository root path.",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--db",
|
|
45
|
+
default=None,
|
|
46
|
+
type=click.Path(),
|
|
47
|
+
help="SQLite knowledge graph path (default: <repo>/.tscodekg/graph.sqlite).",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--snapshots-dir",
|
|
51
|
+
default=None,
|
|
52
|
+
type=click.Path(),
|
|
53
|
+
help="Snapshots directory (default: <repo>/.tscodekg/snapshots).",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--branch",
|
|
57
|
+
default=None,
|
|
58
|
+
type=str,
|
|
59
|
+
help="Branch name; auto-detected if not provided.",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"--tree-hash",
|
|
63
|
+
default="",
|
|
64
|
+
type=str,
|
|
65
|
+
help="Git tree hash; auto-detected if not provided.",
|
|
66
|
+
)
|
|
67
|
+
def save_snapshot(
|
|
68
|
+
version: str | None,
|
|
69
|
+
repo: str,
|
|
70
|
+
db: str | None,
|
|
71
|
+
snapshots_dir: str | None,
|
|
72
|
+
branch: str | None,
|
|
73
|
+
tree_hash: str,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Capture current TypeScriptKG metrics and save as a temporal snapshot.
|
|
77
|
+
|
|
78
|
+
Reads graph statistics and JSDoc coverage from the SQLite graph, runs the
|
|
79
|
+
analyzer for issue counts and hotspots, then saves a snapshot tagged with
|
|
80
|
+
the given VERSION. The tree hash is auto-detected from git when not
|
|
81
|
+
provided.
|
|
82
|
+
|
|
83
|
+
Snapshots are stored in .tscodekg/snapshots/{tree_hash}.json, with a
|
|
84
|
+
manifest.json tracking all snapshots and their metrics.
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
tscodekg snapshot save 0.1.0 --repo .
|
|
88
|
+
"""
|
|
89
|
+
capture_snapshot(
|
|
90
|
+
version=version,
|
|
91
|
+
repo=repo,
|
|
92
|
+
db=db,
|
|
93
|
+
snapshots_dir=snapshots_dir,
|
|
94
|
+
branch=branch,
|
|
95
|
+
tree_hash=tree_hash,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def capture_snapshot(
|
|
100
|
+
*,
|
|
101
|
+
version: str | None,
|
|
102
|
+
repo: str,
|
|
103
|
+
db: str | None,
|
|
104
|
+
snapshots_dir: str | None,
|
|
105
|
+
branch: str | None,
|
|
106
|
+
tree_hash: str,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Capture and persist a snapshot; shared by ``snapshot save`` and ``init``.
|
|
109
|
+
|
|
110
|
+
:param version: Version tag; auto-detected from the package when falsy.
|
|
111
|
+
:param repo: Repository root path.
|
|
112
|
+
:param db: SQLite graph path; defaults to ``<repo>/.tscodekg/graph.sqlite``.
|
|
113
|
+
:param snapshots_dir: Snapshots directory; defaults to ``<repo>/.tscodekg/snapshots``.
|
|
114
|
+
:param branch: Branch name; auto-detected when ``None``.
|
|
115
|
+
:param tree_hash: Git tree hash; auto-detected when empty.
|
|
116
|
+
"""
|
|
117
|
+
from tscode_kg.kg import TypeScriptKG # pylint: disable=import-outside-toplevel
|
|
118
|
+
from tscode_kg.snapshots import SnapshotManager # pylint: disable=import-outside-toplevel
|
|
119
|
+
|
|
120
|
+
repo_root = Path(repo).resolve()
|
|
121
|
+
db_path = Path(db) if db else repo_root / ".tscodekg" / "graph.sqlite"
|
|
122
|
+
snapshots_path = _default_snapshots_dir(snapshots_dir, repo_root)
|
|
123
|
+
|
|
124
|
+
kg = TypeScriptKG(repo_root=repo_root, db_path=db_path)
|
|
125
|
+
snap_mgr = SnapshotManager(snapshots_path, db_path=db_path)
|
|
126
|
+
|
|
127
|
+
critical_issues = 0
|
|
128
|
+
complexity_median = 0.0
|
|
129
|
+
hotspots: list[dict] = []
|
|
130
|
+
issue_strings: list[str] = []
|
|
131
|
+
try:
|
|
132
|
+
stats = kg.stats()
|
|
133
|
+
|
|
134
|
+
# Run the analyzer for issues/hotspots; a snapshot must still be
|
|
135
|
+
# capturable when the semantic index or kg extras are unavailable
|
|
136
|
+
# (e.g. from the pre-commit hook on a graph-only build).
|
|
137
|
+
try:
|
|
138
|
+
from tscode_kg.analysis import ( # pylint: disable=import-outside-toplevel
|
|
139
|
+
TSCodeKGAnalyzer,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
analyzer = TSCodeKGAnalyzer(kg, snapshot_mgr=snap_mgr)
|
|
143
|
+
analysis = analyzer.run_analysis()
|
|
144
|
+
|
|
145
|
+
issue_strings = analysis.get("issues", [])
|
|
146
|
+
critical_issues = len(issue_strings)
|
|
147
|
+
|
|
148
|
+
fn_metrics = analysis.get("function_metrics", {})
|
|
149
|
+
hotspots = [
|
|
150
|
+
{
|
|
151
|
+
"name": name,
|
|
152
|
+
"callers": m.get("fan_in", 0),
|
|
153
|
+
"callees": m.get("fan_out", 0),
|
|
154
|
+
}
|
|
155
|
+
for name, m in list(fn_metrics.items())[:10]
|
|
156
|
+
]
|
|
157
|
+
fan_ins = [m.get("fan_in", 0) for m in fn_metrics.values()]
|
|
158
|
+
complexity_median = float(sorted(fan_ins)[len(fan_ins) // 2]) if fan_ins else 0.0
|
|
159
|
+
except Exception as exc: # noqa: BLE001
|
|
160
|
+
logger.warning("Analyzer unavailable, capturing stats-only snapshot: %s", exc)
|
|
161
|
+
click.echo(f" (analyzer unavailable — stats-only snapshot: {exc})", err=True)
|
|
162
|
+
finally:
|
|
163
|
+
kg.close()
|
|
164
|
+
|
|
165
|
+
snapshot_obj = snap_mgr.capture(
|
|
166
|
+
version=version or None,
|
|
167
|
+
branch=branch,
|
|
168
|
+
graph_stats_dict=stats,
|
|
169
|
+
critical_issues=critical_issues,
|
|
170
|
+
complexity_median=complexity_median,
|
|
171
|
+
hotspots=hotspots,
|
|
172
|
+
issues=issue_strings,
|
|
173
|
+
tree_hash=tree_hash,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
snapshot_file = snap_mgr.save_snapshot(snapshot_obj)
|
|
177
|
+
coverage = snapshot_obj.metrics.get("docstring_coverage") or 0.0
|
|
178
|
+
click.echo(f"OK Snapshot saved: {snapshot_file}")
|
|
179
|
+
click.echo(f" Key: {snapshot_obj.key}")
|
|
180
|
+
click.echo(f" Version: {snapshot_obj.version}")
|
|
181
|
+
click.echo(f" Nodes: {snapshot_obj.metrics.get('total_nodes', 0)}")
|
|
182
|
+
click.echo(f" Edges: {snapshot_obj.metrics.get('total_edges', 0)}")
|
|
183
|
+
click.echo(f" Coverage: {coverage:.1%}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@snapshot.command("list")
|
|
187
|
+
@click.option(
|
|
188
|
+
"--snapshots-dir",
|
|
189
|
+
default=None,
|
|
190
|
+
type=click.Path(exists=True),
|
|
191
|
+
help="Snapshots directory (default: .tscodekg/snapshots).",
|
|
192
|
+
)
|
|
193
|
+
@click.option(
|
|
194
|
+
"--limit",
|
|
195
|
+
type=int,
|
|
196
|
+
default=None,
|
|
197
|
+
help="Max snapshots to show.",
|
|
198
|
+
)
|
|
199
|
+
@click.option(
|
|
200
|
+
"--json",
|
|
201
|
+
"output_json",
|
|
202
|
+
is_flag=True,
|
|
203
|
+
help="Output as JSON.",
|
|
204
|
+
)
|
|
205
|
+
def list_snapshots(snapshots_dir: str | None, limit: int | None, output_json: bool) -> None:
|
|
206
|
+
"""
|
|
207
|
+
List all temporal snapshots in reverse chronological order.
|
|
208
|
+
|
|
209
|
+
Shows key, timestamp, version, and key metrics (nodes, edges, coverage)
|
|
210
|
+
for each snapshot.
|
|
211
|
+
"""
|
|
212
|
+
from tscode_kg.snapshots import SnapshotManager # pylint: disable=import-outside-toplevel
|
|
213
|
+
|
|
214
|
+
mgr = SnapshotManager(_default_snapshots_dir(snapshots_dir))
|
|
215
|
+
snapshots = mgr.list_snapshots(limit=limit)
|
|
216
|
+
|
|
217
|
+
if not snapshots:
|
|
218
|
+
click.echo("No snapshots found.")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
if output_json:
|
|
222
|
+
click.echo(json.dumps(snapshots, indent=2))
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
click.echo(
|
|
226
|
+
f"{'Key':<12} {'Timestamp':<20} {'Branch':<12} {'Version':<8}"
|
|
227
|
+
f" {'Nodes':<6} {'Edges':<6} {'Coverage':<9}"
|
|
228
|
+
)
|
|
229
|
+
click.echo("-" * 85)
|
|
230
|
+
for snap in snapshots:
|
|
231
|
+
key = snap["key"][:12]
|
|
232
|
+
ts = snap["timestamp"]
|
|
233
|
+
ts_display = ts[:16].replace("T", " ") if ts else "unknown"
|
|
234
|
+
branch = (snap.get("branch") or "")[:12]
|
|
235
|
+
version = (snap.get("version") or "")[:8]
|
|
236
|
+
metrics = snap.get("metrics", {})
|
|
237
|
+
nodes = metrics.get("total_nodes", 0)
|
|
238
|
+
edges = metrics.get("total_edges", 0)
|
|
239
|
+
coverage = metrics.get("docstring_coverage") or 0.0
|
|
240
|
+
click.echo(
|
|
241
|
+
f"{key:<12} {ts_display:<20} {branch:<12} {version:<8}"
|
|
242
|
+
f" {nodes:<6} {edges:<6} {coverage:>6.1%}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@snapshot.command("show")
|
|
247
|
+
@click.argument("key", metavar="KEY")
|
|
248
|
+
@click.option(
|
|
249
|
+
"--snapshots-dir",
|
|
250
|
+
default=None,
|
|
251
|
+
type=click.Path(exists=True),
|
|
252
|
+
help="Snapshots directory (default: .tscodekg/snapshots).",
|
|
253
|
+
)
|
|
254
|
+
def show_snapshot(key: str, snapshots_dir: str | None) -> None:
|
|
255
|
+
"""
|
|
256
|
+
Display full details for a single snapshot by key (tree hash).
|
|
257
|
+
|
|
258
|
+
Shows all metrics, hotspots, and deltas vs. previous and baseline snapshots.
|
|
259
|
+
"""
|
|
260
|
+
from tscode_kg.snapshots import SnapshotManager # pylint: disable=import-outside-toplevel
|
|
261
|
+
|
|
262
|
+
mgr = SnapshotManager(_default_snapshots_dir(snapshots_dir))
|
|
263
|
+
snapshot_obj = mgr.load_snapshot(key)
|
|
264
|
+
|
|
265
|
+
if not snapshot_obj:
|
|
266
|
+
click.echo(f"Snapshot not found: {key}", err=True)
|
|
267
|
+
raise click.Abort()
|
|
268
|
+
|
|
269
|
+
metrics = snapshot_obj.metrics
|
|
270
|
+
|
|
271
|
+
click.echo(f"Key: {snapshot_obj.key}")
|
|
272
|
+
click.echo(f"Branch: {snapshot_obj.branch}")
|
|
273
|
+
click.echo(f"Timestamp: {snapshot_obj.timestamp}")
|
|
274
|
+
click.echo(f"Version: {snapshot_obj.version}")
|
|
275
|
+
click.echo()
|
|
276
|
+
|
|
277
|
+
coverage = metrics.get("docstring_coverage") or 0.0
|
|
278
|
+
click.echo("Metrics:")
|
|
279
|
+
click.echo(f" Total Nodes: {metrics.get('total_nodes', 0)}")
|
|
280
|
+
click.echo(f" Total Edges: {metrics.get('total_edges', 0)}")
|
|
281
|
+
click.echo(f" Meaningful Nodes: {metrics.get('meaningful_nodes', 0)}")
|
|
282
|
+
click.echo(f" JSDoc Coverage: {coverage:.1%}")
|
|
283
|
+
click.echo(f" Critical Issues: {metrics.get('critical_issues', 0)}")
|
|
284
|
+
click.echo(f" Complexity Median: {metrics.get('complexity_median', 0.0):.2f}")
|
|
285
|
+
click.echo()
|
|
286
|
+
|
|
287
|
+
click.echo("Node/Edge Breakdown:")
|
|
288
|
+
for kind, count in sorted(metrics.get("node_counts", {}).items()):
|
|
289
|
+
click.echo(f" {kind}: {count}")
|
|
290
|
+
click.echo()
|
|
291
|
+
for rel, count in sorted(metrics.get("edge_counts", {}).items()):
|
|
292
|
+
click.echo(f" {rel}: {count}")
|
|
293
|
+
click.echo()
|
|
294
|
+
|
|
295
|
+
if snapshot_obj.hotspots:
|
|
296
|
+
click.echo("Top Hotspots (Fan-In):")
|
|
297
|
+
for i, hotspot in enumerate(snapshot_obj.hotspots[:5], 1):
|
|
298
|
+
name = hotspot.get("name", "unknown")
|
|
299
|
+
callers = hotspot.get("callers", 0)
|
|
300
|
+
click.echo(f" {i}. {name} ({callers} callers)")
|
|
301
|
+
click.echo()
|
|
302
|
+
|
|
303
|
+
if snapshot_obj.vs_previous:
|
|
304
|
+
delta = snapshot_obj.vs_previous
|
|
305
|
+
click.echo("Delta vs. Previous:")
|
|
306
|
+
click.echo(f" Nodes: {delta.get('nodes', 0):+d}")
|
|
307
|
+
click.echo(f" Edges: {delta.get('edges', 0):+d}")
|
|
308
|
+
click.echo()
|
|
309
|
+
|
|
310
|
+
if snapshot_obj.vs_baseline:
|
|
311
|
+
delta = snapshot_obj.vs_baseline
|
|
312
|
+
click.echo("Delta vs. Baseline:")
|
|
313
|
+
click.echo(f" Nodes: {delta.get('nodes', 0):+d}")
|
|
314
|
+
click.echo(f" Edges: {delta.get('edges', 0):+d}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@snapshot.command("diff")
|
|
318
|
+
@click.argument("key_a", metavar="KEY_A")
|
|
319
|
+
@click.argument("key_b", metavar="KEY_B")
|
|
320
|
+
@click.option(
|
|
321
|
+
"--snapshots-dir",
|
|
322
|
+
default=None,
|
|
323
|
+
type=click.Path(exists=True),
|
|
324
|
+
help="Snapshots directory (default: .tscodekg/snapshots).",
|
|
325
|
+
)
|
|
326
|
+
@click.option(
|
|
327
|
+
"--json",
|
|
328
|
+
"output_json",
|
|
329
|
+
is_flag=True,
|
|
330
|
+
help="Output as JSON.",
|
|
331
|
+
)
|
|
332
|
+
def diff_snapshots(key_a: str, key_b: str, snapshots_dir: str | None, output_json: bool) -> None:
|
|
333
|
+
"""
|
|
334
|
+
Compare two snapshots side-by-side.
|
|
335
|
+
|
|
336
|
+
Shows metrics from both snapshots and computed deltas (B - A).
|
|
337
|
+
|
|
338
|
+
Example:
|
|
339
|
+
tscodekg snapshot diff 660e4f0a 3487ed5b
|
|
340
|
+
"""
|
|
341
|
+
from tscode_kg.snapshots import SnapshotManager # pylint: disable=import-outside-toplevel
|
|
342
|
+
|
|
343
|
+
mgr = SnapshotManager(_default_snapshots_dir(snapshots_dir))
|
|
344
|
+
diff_result = mgr.diff_snapshots(key_a, key_b)
|
|
345
|
+
|
|
346
|
+
if "error" in diff_result:
|
|
347
|
+
click.echo(f"Error: {diff_result['error']}", err=True)
|
|
348
|
+
raise click.Abort()
|
|
349
|
+
|
|
350
|
+
if output_json:
|
|
351
|
+
click.echo(json.dumps(diff_result, indent=2))
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
a = diff_result["a"]
|
|
355
|
+
b = diff_result["b"]
|
|
356
|
+
metrics_a = a["metrics"]
|
|
357
|
+
metrics_b = b["metrics"]
|
|
358
|
+
|
|
359
|
+
click.echo(f"Comparing {a['key'][:10]} vs {b['key'][:10]}")
|
|
360
|
+
click.echo()
|
|
361
|
+
click.echo(f"{'Metric':<20} {'A':<12} {'B':<12} {'Δ':<12}")
|
|
362
|
+
click.echo("-" * 56)
|
|
363
|
+
|
|
364
|
+
for metric_key in ["total_nodes", "total_edges", "meaningful_nodes"]:
|
|
365
|
+
val_a = metrics_a.get(metric_key, 0)
|
|
366
|
+
val_b = metrics_b.get(metric_key, 0)
|
|
367
|
+
click.echo(f"{metric_key:<20} {val_a:<12} {val_b:<12} {val_b - val_a:+d}")
|
|
368
|
+
|
|
369
|
+
cov_a = metrics_a.get("docstring_coverage") or 0.0
|
|
370
|
+
cov_b = metrics_b.get("docstring_coverage") or 0.0
|
|
371
|
+
click.echo(f"{'docstring_coverage':<20} {cov_a:<12.1%} {cov_b:<12.1%} {cov_b - cov_a:+.1%}")
|
|
372
|
+
|
|
373
|
+
issues_a = metrics_a.get("critical_issues", 0)
|
|
374
|
+
issues_b = metrics_b.get("critical_issues", 0)
|
|
375
|
+
click.echo(f"{'critical_issues':<20} {issues_a:<12} {issues_b:<12} {issues_b - issues_a:+d}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@snapshot.command("prune")
|
|
379
|
+
@click.option(
|
|
380
|
+
"--snapshots-dir",
|
|
381
|
+
default=None,
|
|
382
|
+
type=click.Path(),
|
|
383
|
+
help="Snapshots directory (default: .tscodekg/snapshots).",
|
|
384
|
+
)
|
|
385
|
+
@click.option(
|
|
386
|
+
"--dry-run",
|
|
387
|
+
is_flag=True,
|
|
388
|
+
help="Show what would be removed without deleting anything.",
|
|
389
|
+
)
|
|
390
|
+
def prune_snapshots(snapshots_dir: str | None, dry_run: bool) -> None:
|
|
391
|
+
"""
|
|
392
|
+
Remove vestigial snapshots that carry no new metric information.
|
|
393
|
+
|
|
394
|
+
Cleans up three categories:
|
|
395
|
+
|
|
396
|
+
\b
|
|
397
|
+
1. Metric-duplicates — interior snapshots with unchanged metrics.
|
|
398
|
+
2. Broken entries — manifest entries whose JSON file is missing.
|
|
399
|
+
3. Orphaned files — JSON files on disk not referenced by the manifest.
|
|
400
|
+
|
|
401
|
+
The oldest (baseline) and newest (latest) snapshots are always kept.
|
|
402
|
+
|
|
403
|
+
Example:
|
|
404
|
+
tscodekg snapshot prune --dry-run
|
|
405
|
+
tscodekg snapshot prune
|
|
406
|
+
"""
|
|
407
|
+
from tscode_kg.snapshots import SnapshotManager # pylint: disable=import-outside-toplevel
|
|
408
|
+
|
|
409
|
+
mgr = SnapshotManager(_default_snapshots_dir(snapshots_dir))
|
|
410
|
+
result = mgr.prune_snapshots(dry_run=dry_run)
|
|
411
|
+
|
|
412
|
+
prefix = "[dry-run] " if dry_run else ""
|
|
413
|
+
if result.total_cleaned == 0:
|
|
414
|
+
click.echo("Nothing to prune.")
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
if result.removed:
|
|
418
|
+
click.echo(f"{prefix}Metric-duplicates removed: {len(result.removed)}")
|
|
419
|
+
for key in result.removed:
|
|
420
|
+
click.echo(f" - {key}")
|
|
421
|
+
if result.broken_entries:
|
|
422
|
+
click.echo(f"{prefix}Broken manifest entries removed: {len(result.broken_entries)}")
|
|
423
|
+
for key in result.broken_entries:
|
|
424
|
+
click.echo(f" - {key}")
|
|
425
|
+
if result.orphaned_files:
|
|
426
|
+
click.echo(f"{prefix}Orphaned JSON files removed: {len(result.orphaned_files)}")
|
|
427
|
+
for fname in result.orphaned_files:
|
|
428
|
+
click.echo(f" - {fname}")
|
|
429
|
+
|
|
430
|
+
action = "would be" if dry_run else "were"
|
|
431
|
+
click.echo(f"\nTotal: {result.total_cleaned} item(s) {action} cleaned.")
|