ctxgraph-code 0.5.1__tar.gz → 0.6.1__tar.gz

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.
Files changed (39) hide show
  1. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/PKG-INFO +1 -1
  2. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/pyproject.toml +1 -1
  3. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/cli.py +307 -19
  4. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/hooks.py +1 -48
  5. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/storage.py +36 -0
  6. ctxgraph_code-0.6.1/src/ctxgraph_code/render/__init__.py +20 -0
  7. ctxgraph_code-0.6.1/src/ctxgraph_code/render/mermaid.py +146 -0
  8. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/PKG-INFO +1 -1
  9. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/SOURCES.txt +3 -1
  10. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/README.md +0 -0
  11. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/setup.cfg +0 -0
  12. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/__init__.py +0 -0
  13. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/__main__.py +0 -0
  14. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/__init__.py +0 -0
  15. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/python/__init__.py +0 -0
  16. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/python/importer.py +0 -0
  17. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/python/semantic.py +0 -0
  18. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/python/symbols.py +0 -0
  19. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/treesitter/__init__.py +0 -0
  20. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/treesitter/analyzer.py +0 -0
  21. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/treesitter/languages.py +0 -0
  22. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/__init__.py +0 -0
  23. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/build_status.py +0 -0
  24. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/global_paths.py +0 -0
  25. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/init.py +0 -0
  26. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/settings.py +0 -0
  27. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/exclude/__init__.py +0 -0
  28. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/exclude/patterns.py +0 -0
  29. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/__init__.py +0 -0
  30. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/builder.py +0 -0
  31. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/models.py +0 -0
  32. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/query.py +0 -0
  33. /ctxgraph_code-0.5.1/src/ctxgraph_code/render.py → /ctxgraph_code-0.6.1/src/ctxgraph_code/render/_text.py +0 -0
  34. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/view/__init__.py +0 -0
  35. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/view/visualizer.py +0 -0
  36. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/dependency_links.txt +0 -0
  37. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/entry_points.txt +0 -0
  38. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/requires.txt +0 -0
  39. {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctxgraph-code
3
- Version: 0.5.1
3
+ Version: 0.6.1
4
4
  Summary: Code knowledge graph for Claude Code. Build a relationship graph of your Python codebase and query it during coding sessions.
5
5
  Author: ctxgraph-code contributors
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ctxgraph-code"
7
- version = "0.5.1"
7
+ version = "0.6.1"
8
8
  description = "Code knowledge graph for Claude Code. Build a relationship graph of your Python codebase and query it during coding sessions."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -254,24 +254,22 @@ def _write_slash_command(slash_path: Path, path: Path):
254
254
 
255
255
  SLASH_COMMAND_TEMPLATE = """# ctxgraph-code: Code Relationship Graph
256
256
 
257
- **First time in this project?** Tell the user to run `ctxgraph-code setup` in their terminal.
258
-
259
- **Graph needs refresh?** Tell the user to run `ctxgraph-code build`.
260
-
261
257
  **Available graphs:** {available}
262
258
 
263
259
  **Commands:**
264
- - `ctxgraph-code query "terms"` -- Find relevant files, classes, and functions
265
- - `ctxgraph-code probe "question"` -- Search graph and read matching source code inline
266
- - `ctxgraph-code deps <path>` -- Show what a file imports and what calls it
267
- - `ctxgraph-code usedby <path>` -- Show what depends on a file
268
- - `ctxgraph-code overview --dir <name>` -- Show project structure for a specific graph
269
- - `ctxgraph-code symbols <path>` -- List classes/functions defined in a file
270
- - `ctxgraph-code context "task"` -- Generate a focused context summary
271
- - `ctxgraph-code view --dir <name>` -- Visualize a graph interactively
272
-
273
- **Tip:** Use `--dir <name>` to scope queries to a per-directory graph.
274
- When passing a file path like `auth/login.py`, the correct graph is auto-detected.
260
+ - `ctxgraph-code query "terms"` -- Files, classes, functions
261
+ - `ctxgraph-code probe "question"` -- Search + read source inline
262
+ - `ctxgraph-code deps <path>` -- Dependencies of a file
263
+ - `ctxgraph-code usedby <path>` -- What depends on a file
264
+ - `ctxgraph-code overview --dir <name>` -- Project structure
265
+ - `ctxgraph-code symbols <path>` -- Classes/functions in a file
266
+ - `ctxgraph-code context "task"` -- Focused context summary
267
+ - `ctxgraph-code subgraph "task"` -- Focused subgraph with source
268
+ - `ctxgraph-code diff` -- Files changed since build
269
+ - `ctxgraph-code mermaid --type classDiagram` -- Mermaid diagram
270
+ - `ctxgraph-code view --dir <name>` -- Interactive D3.js graph
271
+
272
+ Use `--dir <name>` to scope queries. File paths auto-detect the graph dir.
275
273
  """
276
274
 
277
275
 
@@ -386,13 +384,13 @@ def _build_dirs_parallel(path, exts, user_patterns, top_dirs, graphs_dir, jobs,
386
384
  edges = stats.get("total_edges", 0)
387
385
  t = stats.get("elapsed_seconds", 0)
388
386
  console.print(
389
- f" [green][/green] {label}/ "
387
+ f" [green]OK[/green] {label}/ "
390
388
  f"({files} files, {nodes} nodes, {edges} edges, {t}s)"
391
389
  )
392
390
  except Exception as e:
393
391
  results.append((label, str(e)))
394
392
  err_count += 1
395
- console.print(f" [red][/red] {label}/ ([red]{e}[/red])")
393
+ console.print(f" [red]FAIL[/red] {label}/ ([red]{e}[/red])")
396
394
 
397
395
  elapsed_total = time.time() - _build_progress_start
398
396
  if err_count:
@@ -819,7 +817,7 @@ def setup(
819
817
  )
820
818
 
821
819
 
822
- @app.command(name="build-status")
820
+ @app.command(name="build-status")
823
821
  def build_status(
824
822
  repo_path: Optional[str] = typer.Option(
825
823
  None, "--repo", "-r", help="Repository path"
@@ -1105,10 +1103,300 @@ def version():
1105
1103
  try:
1106
1104
  ver = _v("ctxgraph-code")
1107
1105
  except Exception:
1108
- ver = "0.5.0"
1106
+ ver = "0.6.1"
1109
1107
  console.print(f"ctxgraph-code version [bold]{ver}[/bold]")
1110
1108
 
1111
1109
 
1110
+ @app.command()
1111
+ def mermaid(
1112
+ repo_path: Optional[str] = typer.Option(
1113
+ None, "--repo", "-r", help="Repository path"
1114
+ ),
1115
+ output: Optional[str] = typer.Option(
1116
+ None, "--output", "-o", help="Save Mermaid output to file"
1117
+ ),
1118
+ max_nodes: int = typer.Option(
1119
+ 50, "--max-nodes", "-n", help="Maximum nodes in diagram"
1120
+ ),
1121
+ dir_name: Optional[str] = typer.Option(
1122
+ None, "--dir", "-d", help="Directory graph to use"
1123
+ ),
1124
+ diagram_type: str = typer.Argument(
1125
+ "classDiagram", help="Diagram type: classDiagram, flowchart, sequence"
1126
+ ),
1127
+ ):
1128
+ """Export the graph as a Mermaid diagram.
1129
+
1130
+ Supported diagram types: classDiagram, flowchart, sequence.
1131
+ Outputs to console by default, or to a file with --output.
1132
+ """
1133
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
1134
+ storage = _resolve_storage(path, dir_name=dir_name)
1135
+
1136
+ from ctxgraph_code.render.mermaid import MermaidError, render_mermaid
1137
+
1138
+ try:
1139
+ result = render_mermaid(
1140
+ storage,
1141
+ output_type=diagram_type,
1142
+ max_nodes=max_nodes,
1143
+ )
1144
+ except MermaidError as e:
1145
+ console.print(f"[red]{e}[/red]")
1146
+ raise typer.Exit(1)
1147
+
1148
+ if output:
1149
+ out_path = Path(output)
1150
+ out_path.write_text(result, encoding="utf-8")
1151
+ console.print(f"[green]Mermaid diagram saved to [bold]{out_path}[/bold][/green]")
1152
+ else:
1153
+ console.print(result)
1154
+
1155
+
1156
+ @app.command()
1157
+ def subgraph(
1158
+ query: str = typer.Argument(..., help="Task description"),
1159
+ repo_path: Optional[str] = typer.Option(
1160
+ None, "--repo", "-r", help="Repository path"
1161
+ ),
1162
+ max_nodes: int = typer.Option(
1163
+ 10, "--max-nodes", "-n", help="Maximum nodes in subgraph"
1164
+ ),
1165
+ dir_name: Optional[str] = typer.Option(
1166
+ None, "--dir", "-d", help="Directory graph to query"
1167
+ ),
1168
+ ):
1169
+ """Extract a focused subgraph relevant to a task description.
1170
+
1171
+ Returns matching nodes, their relationships, and inline source code
1172
+ for a compact context window.
1173
+ """
1174
+ import hashlib
1175
+
1176
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
1177
+ storage = _resolve_storage(path, dir_name=dir_name)
1178
+
1179
+ results = search_relevant_nodes(storage, query, max_nodes=max_nodes)
1180
+ if not results:
1181
+ console.print("[yellow]No relevant nodes found for subgraph.[/yellow]")
1182
+ raise typer.Exit()
1183
+
1184
+ matched_ids = {n.id for n, _ in results}
1185
+ edge_ids: set[str] = set()
1186
+ edges = storage.get_edges_for_nodes(matched_ids)
1187
+ for e in edges:
1188
+ if e.source_id in matched_ids:
1189
+ edge_ids.add(e.target_id)
1190
+ if e.target_id in matched_ids:
1191
+ edge_ids.add(e.source_id)
1192
+
1193
+ all_ids = matched_ids | edge_ids
1194
+ node_map: dict[str, object] = {}
1195
+ for nid in all_ids:
1196
+ n = storage.get_node(nid)
1197
+ if n:
1198
+ node_map[nid] = n
1199
+
1200
+ file_nodes_in_subgraph = {
1201
+ n for n in node_map.values()
1202
+ if hasattr(n, 'type') and n.type == "file"
1203
+ }
1204
+
1205
+ lines = [
1206
+ f"Subgraph for: {query}",
1207
+ f"Nodes: {len(matched_ids)} seed + {len(edge_ids)} related = {len(all_ids)} total",
1208
+ "",
1209
+ ]
1210
+
1211
+ # Group by file
1212
+ file_groups: dict[str, list] = {}
1213
+ for n in sorted(node_map.values(), key=lambda x: x.path or ""):
1214
+ fp = getattr(n, 'path', None) or ""
1215
+ file_groups.setdefault(fp, []).append(n)
1216
+
1217
+ for fp, nodes in sorted(file_groups.items()):
1218
+ file_node = next((n for n in nodes if n.type == "file"), None)
1219
+ if file_node:
1220
+ tag = "F"
1221
+ lines.append(f" [{tag}] [bold]{file_node.name}[/bold] [blue]{file_node.path or '-'}[/blue]")
1222
+ if file_node.summary:
1223
+ lines.append(f" {file_node.summary}")
1224
+ symbols = [n for n in nodes if n.type in ("class", "function")]
1225
+ if symbols:
1226
+ for s in symbols:
1227
+ tag = "C" if s.type == "class" else "M"
1228
+ lines.append(f" [{tag}] {s.name} (line {s.lineno})")
1229
+ if s.summary:
1230
+ lines.append(f" {s.summary}")
1231
+
1232
+ if edges:
1233
+ lines.append("")
1234
+ lines.append(" Relationships:")
1235
+ for e in edges[:15]:
1236
+ src = node_map.get(e.source_id)
1237
+ tgt = node_map.get(e.target_id)
1238
+ src_name = src.name if src else e.source_id
1239
+ tgt_name = tgt.name if tgt else e.target_id
1240
+ lines.append(f" {src_name} --[{e.relation}]--> {tgt_name}")
1241
+
1242
+ # Inline source for each file node
1243
+ lines.append("")
1244
+ for n in sorted(file_nodes_in_subgraph, key=lambda x: x.path or ""):
1245
+ fp = path / n.path if n.path else None
1246
+ if fp and fp.is_file():
1247
+ try:
1248
+ code = fp.read_text(encoding="utf-8", errors="replace")
1249
+ lines.append(f" === {n.path} ===")
1250
+ snippet = code.splitlines()[:60]
1251
+ lines.extend(snippet)
1252
+ if len(code.splitlines()) > 60:
1253
+ lines.append(f"... ({len(code.splitlines()) - 60} more lines)")
1254
+ lines.append("")
1255
+ except OSError:
1256
+ pass
1257
+
1258
+ console.print("\n".join(lines))
1259
+
1260
+
1261
+ @app.command()
1262
+ def diff(
1263
+ repo_path: Optional[str] = typer.Option(
1264
+ None, "--repo", "-r", help="Repository path"
1265
+ ),
1266
+ dir_name: Optional[str] = typer.Option(
1267
+ None, "--dir", "-d", help="Directory graph to compare"
1268
+ ),
1269
+ ref_branch: Optional[str] = typer.Option(
1270
+ None, "--ref", help="Git reference/branch to diff against (requires git)"
1271
+ ),
1272
+ ):
1273
+ """Compare the graph with the filesystem.
1274
+
1275
+ Shows files that have been added, removed, or changed since the
1276
+ graph was built. Use --ref for a git-aware diff against a branch.
1277
+ """
1278
+ import hashlib
1279
+ import json as j
1280
+
1281
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
1282
+ storage = _resolve_storage(path, dir_name=dir_name)
1283
+
1284
+ new_files: list[str] = []
1285
+ removed_files: list[str] = []
1286
+ changed_files: list[str] = []
1287
+
1288
+ graph_paths = set(storage.get_file_paths())
1289
+ hashes_json = storage.get_metadata("content_hashes")
1290
+ stored_hashes: dict[str, str] = {}
1291
+ if hashes_json:
1292
+ try:
1293
+ stored_hashes = j.loads(hashes_json)
1294
+ except (j.JSONDecodeError, OSError):
1295
+ pass
1296
+
1297
+ if ref_branch:
1298
+ try:
1299
+ import subprocess
1300
+ result = subprocess.run(
1301
+ ["git", "diff", "--name-only", ref_branch],
1302
+ capture_output=True, text=True, check=False, cwd=str(path),
1303
+ )
1304
+ if result.returncode == 0:
1305
+ git_files = set(result.stdout.strip().splitlines()) if result.stdout.strip() else set()
1306
+ for gf in git_files:
1307
+ if gf not in graph_paths:
1308
+ new_files.append(gf)
1309
+ for gp in graph_paths:
1310
+ if gp not in git_files:
1311
+ removed_files.append(gp)
1312
+ # Changed files = git diff --name-only from HEAD
1313
+ head_result = subprocess.run(
1314
+ ["git", "diff", "--name-only", "HEAD"],
1315
+ capture_output=True, text=True, check=False, cwd=str(path),
1316
+ )
1317
+ if head_result.returncode == 0:
1318
+ changed_files = [f for f in head_result.stdout.strip().splitlines() if f in graph_paths]
1319
+ else:
1320
+ console.print(f"[red]git diff failed: {result.stderr.strip()}[/red]")
1321
+ console.print("[yellow]Falling back to filesystem comparison...[/yellow]")
1322
+ ref_branch = None
1323
+ except FileNotFoundError:
1324
+ console.print("[yellow]Git not found. Falling back to filesystem comparison...[/yellow]")
1325
+ ref_branch = None
1326
+
1327
+ if not ref_branch:
1328
+ for gp in graph_paths:
1329
+ fp = path / gp
1330
+ if not fp.is_file():
1331
+ removed_files.append(gp)
1332
+
1333
+ src_extensions = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".c", ".h", ".cpp", ".java", ".rb", ".kt", ".swift"}
1334
+ for root, _dirs, files in os.walk(path):
1335
+ _dirs[:] = [d for d in _dirs if not d.startswith(".") and d not in ("node_modules", "venv", ".venv", "env", "dist", "build", "__pycache__")]
1336
+ for f in files:
1337
+ ext = os.path.splitext(f)[1].lower()
1338
+ if ext not in src_extensions:
1339
+ continue
1340
+ rel_path = os.path.relpath(os.path.join(root, f), path).replace("\\", "/")
1341
+ if rel_path not in graph_paths:
1342
+ new_files.append(rel_path)
1343
+
1344
+ # Content hash comparison for changed detection
1345
+ if stored_hashes:
1346
+ for rel_path, stored_hash in stored_hashes.items():
1347
+ fp = path / rel_path
1348
+ if fp.is_file():
1349
+ try:
1350
+ actual = hashlib.sha256(
1351
+ fp.read_text(encoding="utf-8", errors="replace")
1352
+ .encode("utf-8")
1353
+ ).hexdigest()
1354
+ if actual != stored_hash:
1355
+ changed_files.append(rel_path)
1356
+ except OSError:
1357
+ changed_files.append(rel_path)
1358
+
1359
+ lines = ["Graph vs Filesystem Diff", ""]
1360
+ if new_files:
1361
+ lines.append(f" [green]+ {len(new_files)} new file(s):[/green]")
1362
+ for f in sorted(new_files)[:10]:
1363
+ lines.append(f" + {f}")
1364
+ if len(new_files) > 10:
1365
+ lines.append(f" ... and {len(new_files) - 10} more")
1366
+ else:
1367
+ lines.append(" [green]+ No new files[/green]")
1368
+
1369
+ if removed_files:
1370
+ lines.append(f"")
1371
+ lines.append(f" [red]- {len(removed_files)} removed file(s):[/red]")
1372
+ for f in sorted(removed_files)[:10]:
1373
+ lines.append(f" - {f}")
1374
+ if len(removed_files) > 10:
1375
+ lines.append(f" ... and {len(removed_files) - 10} more")
1376
+ else:
1377
+ lines.append(f"")
1378
+ lines.append(" [red]- No removed files[/red]")
1379
+
1380
+ if changed_files:
1381
+ lines.append(f"")
1382
+ lines.append(f" [yellow]~ {len(changed_files)} changed file(s):[/yellow]")
1383
+ for f in sorted(changed_files)[:10]:
1384
+ lines.append(f" ~ {f}")
1385
+ if len(changed_files) > 10:
1386
+ lines.append(f" ... and {len(changed_files) - 10} more")
1387
+ else:
1388
+ lines.append(f"")
1389
+ lines.append(" [yellow]~ No changed files[/yellow]")
1390
+
1391
+ lines.append("")
1392
+ if new_files or removed_files or changed_files:
1393
+ lines.append("[yellow]Run [bold]ctxgraph-code build --incremental[/bold] to update the graph.[/yellow]")
1394
+ else:
1395
+ lines.append("[green]Graph is up to date with the filesystem.[/green]")
1396
+
1397
+ console.print("\n".join(lines))
1398
+
1399
+
1112
1400
  if __name__ == "__main__":
1113
1401
  app()
1114
1402
 
@@ -79,8 +79,6 @@ def uninstall_hooks(project_path: Path, local: bool = False) -> bool:
79
79
 
80
80
 
81
81
  def compute_hint_summary(repo_path: Path) -> Optional[str]:
82
- import hashlib
83
-
84
82
  avail = get_available_graphs(repo_path)
85
83
  if not avail["_combined"] and not avail["dirs"]:
86
84
  return None
@@ -100,8 +98,6 @@ def compute_hint_summary(repo_path: Path) -> Optional[str]:
100
98
  total_edges = stats.get("edges", 0)
101
99
  types = stats.get("types", {})
102
100
 
103
- top_files = _get_top_files(storage, 5)
104
-
105
101
  file_count = types.get("file", 0)
106
102
  class_count = types.get("class", 0)
107
103
  func_count = types.get("function", 0)
@@ -112,54 +108,11 @@ def compute_hint_summary(repo_path: Path) -> Optional[str]:
112
108
  ]
113
109
  if file_count:
114
110
  lines.append(f"Files: {file_count} | Classes: {class_count} | Functions: {func_count}.")
115
- if top_files:
116
- lines.append(f"Key files: {', '.join(top_files)}.")
117
-
118
- # ── Tamper detection ──────────────────────────────────────
119
- hashes_json = storage.get_metadata("content_hashes")
120
- tampered: int = 0
121
- if hashes_json:
122
- try:
123
- stored_hashes = json.loads(hashes_json)
124
- for rel_path, stored_hash in stored_hashes.items():
125
- fp = repo_path / rel_path
126
- if not fp.is_file():
127
- tampered += 1
128
- continue
129
- try:
130
- actual = hashlib.sha256(
131
- fp.read_text(encoding="utf-8", errors="replace")
132
- .encode("utf-8")
133
- ).hexdigest()
134
- if actual != stored_hash:
135
- tampered += 1
136
- except OSError:
137
- tampered += 1
138
- except (json.JSONDecodeError, OSError):
139
- pass
140
- if tampered:
141
- lines.append(
142
- f"Warning: {tampered} file(s) have changed since graph was built. "
143
- "Run `ctxgraph-code build --incremental` to update."
144
- )
145
111
 
146
112
  lines.append(
147
- "Use `ctxgraph-code probe \"<question>\"` to search or "
148
- "`/ctxgraph-code` for help."
113
+ "Use `ctxgraph-code probe` or `/ctxgraph-code` for help."
149
114
  )
150
115
 
151
116
  return "\n".join(lines)
152
117
  finally:
153
118
  storage.close()
154
-
155
-
156
- def _get_top_files(storage, limit: int = 5) -> list[str]:
157
- try:
158
- rows = storage.conn.execute(
159
- "SELECT path FROM nodes WHERE type = 'file' AND path IS NOT NULL "
160
- "ORDER BY size_bytes DESC LIMIT ?",
161
- (limit,),
162
- ).fetchall()
163
- return [r[0] for r in rows if r[0]]
164
- except Exception:
165
- return []
@@ -224,6 +224,42 @@ class Storage:
224
224
  )
225
225
  self.conn.commit()
226
226
 
227
+ def get_nodes_by_file_path(self, file_path: str) -> list[Node]:
228
+ rows = self.conn.execute(
229
+ "SELECT * FROM nodes WHERE path = ?", (file_path,)
230
+ ).fetchall()
231
+ return [
232
+ Node(
233
+ id=r["id"],
234
+ type=r["type"],
235
+ name=r["name"],
236
+ path=r["path"],
237
+ parent_id=r["parent_id"],
238
+ summary=r["summary"],
239
+ importance=r["importance"],
240
+ size_bytes=r["size_bytes"],
241
+ lineno=r["lineno"],
242
+ )
243
+ for r in rows
244
+ ]
245
+
246
+ def get_file_paths(self) -> list[str]:
247
+ rows = self.conn.execute(
248
+ "SELECT DISTINCT path FROM nodes WHERE type = 'file' AND path IS NOT NULL"
249
+ ).fetchall()
250
+ return [r[0] for r in rows]
251
+
252
+ def get_content_hash(self, file_path: str) -> Optional[str]:
253
+ hashes_json = self.get_metadata("content_hashes")
254
+ if not hashes_json:
255
+ return None
256
+ try:
257
+ import json
258
+ stored = json.loads(hashes_json)
259
+ return stored.get(file_path)
260
+ except (json.JSONDecodeError, OSError):
261
+ return None
262
+
227
263
  def save_metadata(self, key: str, value: str):
228
264
  self.conn.execute(
229
265
  "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
@@ -0,0 +1,20 @@
1
+ from ctxgraph_code.render._text import (
2
+ render_context,
3
+ render_deps,
4
+ render_overview,
5
+ render_symbols,
6
+ render_treeview,
7
+ render_usedby,
8
+ )
9
+
10
+ from ctxgraph_code.render.mermaid import render_mermaid as render_mermaid
11
+
12
+ __all__ = [
13
+ "render_context",
14
+ "render_deps",
15
+ "render_overview",
16
+ "render_symbols",
17
+ "render_treeview",
18
+ "render_usedby",
19
+ "render_mermaid",
20
+ ]
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from ctxgraph_code.graph.storage import Storage
6
+
7
+
8
+ class MermaidError(Exception):
9
+ pass
10
+
11
+
12
+ def render_mermaid(
13
+ storage: Storage,
14
+ output_type: str = "classDiagram",
15
+ max_nodes: int = 50,
16
+ ) -> str:
17
+ output_type = output_type or "classDiagram"
18
+ supported = {"classDiagram", "flowchart", "sequence"}
19
+ if output_type not in supported:
20
+ raise MermaidError(
21
+ f"Unsupported diagram type '{output_type}'. "
22
+ f"Choose from: {', '.join(sorted(supported))}"
23
+ )
24
+
25
+ all_nodes = storage.get_all_nodes()
26
+ all_edges = storage.get_all_edges()
27
+
28
+ if output_type == "classDiagram":
29
+ return _render_class_diagram(all_nodes, all_edges, max_nodes)
30
+ elif output_type == "flowchart":
31
+ return _render_flowchart(all_nodes, all_edges, max_nodes)
32
+ elif output_type == "sequence":
33
+ return _render_sequence(all_nodes, all_edges, max_nodes)
34
+ return ""
35
+
36
+
37
+ def _safe_id(name: str) -> str:
38
+ safe = "".join(c if c.isalnum() or c == "_" else "_" for c in name)
39
+ if safe and safe[0].isdigit():
40
+ safe = "n" + safe
41
+ return safe or "node"
42
+
43
+
44
+ def _render_class_diagram(nodes: list, edges: list, max_nodes: int) -> str:
45
+ lines = ["classDiagram"]
46
+ added = 0
47
+
48
+ class_nodes = [n for n in nodes if n.type == "class"]
49
+ file_nodes = [n for n in nodes if n.type == "file"]
50
+
51
+ for cls in class_nodes[:max_nodes]:
52
+ nid = _safe_id(cls.name)
53
+ lines.append(f" class {nid} {{")
54
+ if cls.summary:
55
+ lines.append(f" +{cls.summary}")
56
+ lines.append(" }")
57
+ added += 1
58
+ if added >= max_nodes:
59
+ break
60
+
61
+ if added < max_nodes:
62
+ remaining = max_nodes - added
63
+ for f in file_nodes[:remaining]:
64
+ nid = _safe_id(f.name)
65
+ label = f.path or f.name
66
+ lines.append(f" class {nid} {{")
67
+ lines.append(f" +File: {label}")
68
+ if f.summary:
69
+ lines.append(f" +{f.summary}")
70
+ lines.append(" }")
71
+
72
+ inheritance_edges = [e for e in edges if e.relation == "inherits"]
73
+ for e in inheritance_edges:
74
+ src_name = _find_node_name(nodes, e.source_id)
75
+ tgt_name = _find_node_name(nodes, e.target_id)
76
+ if src_name and tgt_name:
77
+ lines.append(f" {_safe_id(src_name)} --|> {_safe_id(tgt_name)}")
78
+
79
+ lines.append("")
80
+ return "\n".join(lines)
81
+
82
+
83
+ def _render_flowchart(nodes: list, edges: list, max_nodes: int) -> str:
84
+ lines = ["flowchart TD"]
85
+ node_map = {n.id: n for n in nodes}
86
+ shown: set[str] = set()
87
+
88
+ sorted_edges = sorted(edges, key=lambda e: e.weight or 1.0, reverse=True)
89
+ for e in sorted_edges[:max_nodes * 2]:
90
+ src = node_map.get(e.source_id)
91
+ tgt = node_map.get(e.target_id)
92
+ if not src or not tgt:
93
+ continue
94
+ for n in (src, tgt):
95
+ if n.id not in shown and len(shown) < max_nodes:
96
+ nid = _safe_id(n.name)
97
+ label = n.path or n.name
98
+ lines.append(f" {nid}[\"{label}\"]")
99
+ shown.add(n.id)
100
+ if src.id in shown and tgt.id in shown:
101
+ src_id = _safe_id(src.name)
102
+ tgt_id = _safe_id(tgt.name)
103
+ rel = e.relation or "link"
104
+ lines.append(f" {src_id} -->|{rel}| {tgt_id}")
105
+
106
+ if not shown and nodes:
107
+ for n in nodes[:max_nodes]:
108
+ nid = _safe_id(n.name)
109
+ label = n.path or n.name
110
+ lines.append(f" {nid}[\"{label}\"]")
111
+
112
+ lines.append("")
113
+ return "\n".join(lines)
114
+
115
+
116
+ def _render_sequence(nodes: list, edges: list, max_nodes: int) -> str:
117
+ lines = ["sequenceDiagram"]
118
+ participants: set[str] = set()
119
+ node_map = {n.id: n for n in nodes}
120
+
121
+ sorted_edges = sorted(edges, key=lambda e: e.weight or 1.0, reverse=True)
122
+ for e in sorted_edges[:max_nodes * 2]:
123
+ src = node_map.get(e.source_id)
124
+ tgt = node_map.get(e.target_id)
125
+ if not src or not tgt:
126
+ continue
127
+ for label, n in [("", src), ("", tgt)]:
128
+ if n.id not in participants:
129
+ safe = _safe_id(n.name)
130
+ display = n.path or n.name
131
+ lines.append(f" participant {safe} as \"{display}\"")
132
+ participants.add(n.id)
133
+ src_safe = _safe_id(src.name)
134
+ tgt_safe = _safe_id(tgt.name)
135
+ rel = e.relation or "calls"
136
+ lines.append(f" {src_safe}->>+{tgt_safe}: {rel}")
137
+
138
+ lines.append("")
139
+ return "\n".join(lines)
140
+
141
+
142
+ def _find_node_name(nodes: list, node_id: str) -> Optional[str]:
143
+ for n in nodes:
144
+ if n.id == node_id:
145
+ return n.name
146
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctxgraph-code
3
- Version: 0.5.1
3
+ Version: 0.6.1
4
4
  Summary: Code knowledge graph for Claude Code. Build a relationship graph of your Python codebase and query it during coding sessions.
5
5
  Author: ctxgraph-code contributors
6
6
  License: MIT
@@ -3,7 +3,6 @@ pyproject.toml
3
3
  src/ctxgraph_code/__init__.py
4
4
  src/ctxgraph_code/__main__.py
5
5
  src/ctxgraph_code/cli.py
6
- src/ctxgraph_code/render.py
7
6
  src/ctxgraph_code.egg-info/PKG-INFO
8
7
  src/ctxgraph_code.egg-info/SOURCES.txt
9
8
  src/ctxgraph_code.egg-info/dependency_links.txt
@@ -31,5 +30,8 @@ src/ctxgraph_code/graph/builder.py
31
30
  src/ctxgraph_code/graph/models.py
32
31
  src/ctxgraph_code/graph/query.py
33
32
  src/ctxgraph_code/graph/storage.py
33
+ src/ctxgraph_code/render/__init__.py
34
+ src/ctxgraph_code/render/_text.py
35
+ src/ctxgraph_code/render/mermaid.py
34
36
  src/ctxgraph_code/view/__init__.py
35
37
  src/ctxgraph_code/view/visualizer.py
File without changes
File without changes