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.
@@ -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.")