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.
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/PKG-INFO +1 -1
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/pyproject.toml +1 -1
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/cli.py +307 -19
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/hooks.py +1 -48
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/storage.py +36 -0
- ctxgraph_code-0.6.1/src/ctxgraph_code/render/__init__.py +20 -0
- ctxgraph_code-0.6.1/src/ctxgraph_code/render/mermaid.py +146 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/PKG-INFO +1 -1
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/SOURCES.txt +3 -1
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/README.md +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/setup.cfg +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/__init__.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/__main__.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/__init__.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/python/__init__.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/python/importer.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/python/semantic.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/python/symbols.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/treesitter/__init__.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/treesitter/analyzer.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/treesitter/languages.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/__init__.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/build_status.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/global_paths.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/init.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/config/settings.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/exclude/__init__.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/exclude/patterns.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/__init__.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/builder.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/models.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/graph/query.py +0 -0
- /ctxgraph_code-0.5.1/src/ctxgraph_code/render.py → /ctxgraph_code-0.6.1/src/ctxgraph_code/render/_text.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/view/__init__.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/view/visualizer.py +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/dependency_links.txt +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/entry_points.txt +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/requires.txt +0 -0
- {ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ctxgraph-code"
|
|
7
|
-
version = "0.
|
|
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"` --
|
|
265
|
-
- `ctxgraph-code probe "question"` -- Search
|
|
266
|
-
- `ctxgraph-code deps <path>` --
|
|
267
|
-
- `ctxgraph-code usedby <path>` --
|
|
268
|
-
- `ctxgraph-code overview --dir <name>` --
|
|
269
|
-
- `ctxgraph-code symbols <path>` --
|
|
270
|
-
- `ctxgraph-code context "task"` --
|
|
271
|
-
- `ctxgraph-code
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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]
|
|
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]
|
|
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.
|
|
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
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/treesitter/__init__.py
RENAMED
|
File without changes
|
{ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/treesitter/analyzer.py
RENAMED
|
File without changes
|
{ctxgraph_code-0.5.1 → ctxgraph_code-0.6.1}/src/ctxgraph_code/analyzers/treesitter/languages.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|