workspace-graph 0.1.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.
codegraph/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
codegraph/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from codegraph.cli import app
2
+
3
+ app()
codegraph/builder.py ADDED
@@ -0,0 +1,300 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import json
5
+ import subprocess
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from codegraph.config import load_config
11
+ from codegraph.cross_service import detect_cross_service_edges
12
+ from codegraph.discover import BUILT_LANGUAGES, resolve_entries
13
+ from codegraph.graph.serialize import write_graph, write_manifest
14
+ from codegraph.graph.types import UnifiedGraph, WorkspaceEntry, make_unified_graph
15
+ from codegraph.plugin import run_plugins
16
+
17
+ TOOL_BY_LANGUAGE: dict[str, str] = {
18
+ "go": "gograph",
19
+ "python": "pygraph",
20
+ "typescript": "tsgraph",
21
+ }
22
+
23
+ GRAPH_FILE_BY_LANGUAGE: dict[str, str] = {
24
+ "go": ".gograph/graph.json",
25
+ "python": ".pygraph/graph.json",
26
+ "typescript": ".tsgraph/graph.json",
27
+ }
28
+
29
+
30
+ def _build_cmd(tool: str, entry_path: Path) -> list[str]:
31
+ if tool in ("gograph", "tsgraph"):
32
+ return [tool, "build", str(entry_path)]
33
+ return [tool, "build", "--root", str(entry_path)]
34
+
35
+
36
+ def _run_tool_build(entry: WorkspaceEntry, root_path: Path) -> WorkspaceEntry:
37
+ tool = TOOL_BY_LANGUAGE.get(entry.language)
38
+ if tool is None:
39
+ entry.build_status = "unsupported"
40
+ return entry
41
+
42
+ entry_path = root_path / entry.path
43
+ if not entry_path.is_dir():
44
+ entry.build_status = "failed"
45
+ return entry
46
+
47
+ start = time.monotonic()
48
+ try:
49
+ result = subprocess.run(
50
+ _build_cmd(tool, entry_path),
51
+ capture_output=True, text=True, timeout=120,
52
+ cwd=str(entry_path),
53
+ )
54
+ elapsed = int((time.monotonic() - start) * 1000)
55
+ entry.build_duration_ms = elapsed
56
+
57
+ if result.returncode != 0:
58
+ entry.build_status = "failed"
59
+ return entry
60
+
61
+ entry.build_status = "ok"
62
+
63
+ try:
64
+ version_result = subprocess.run(
65
+ [tool, "--version"],
66
+ capture_output=True, text=True, timeout=10,
67
+ )
68
+ ver = version_result.stdout.strip() if version_result.returncode == 0 else ""
69
+ entry.tool_version = ver
70
+ except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
71
+ entry.tool_version = ""
72
+
73
+ except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
74
+ entry.build_status = "failed"
75
+
76
+ return entry
77
+
78
+
79
+ def _prefix_ids(items: list[dict[str, Any]], prefix: str, id_fields: set[str]) -> None:
80
+ for item in items:
81
+ for field in id_fields:
82
+ if field in item and isinstance(item[field], str):
83
+ item[field] = f"{prefix}::{item[field]}"
84
+
85
+
86
+ SYMBOL_ID_FIELDS = {"id"}
87
+ CALL_ID_FIELDS = {"caller_symbol_id"}
88
+ FILE_ID_FIELDS = {"id"}
89
+ PACKAGE_ID_FIELDS = {"id"}
90
+ TEST_EDGE_ID_FIELDS = {"test_func", "target"}
91
+ ERROR_ID_FIELDS = {"function_name"}
92
+ MUTATION_ID_FIELDS = {"function_name"}
93
+ ENV_ID_FIELDS = {"function_name"}
94
+ IMPLEMENTS_ID_FIELDS = {"interface", "concrete"}
95
+
96
+
97
+ def _stamp_and_collect(
98
+ entry: WorkspaceEntry,
99
+ root_path: Path,
100
+ unified: UnifiedGraph,
101
+ ) -> None:
102
+ graph_rel = GRAPH_FILE_BY_LANGUAGE.get(entry.language)
103
+ if graph_rel is None:
104
+ return
105
+
106
+ graph_path = root_path / entry.path / graph_rel
107
+ if not graph_path.exists():
108
+ return
109
+
110
+ try:
111
+ raw = graph_path.read_text()
112
+ data: dict[str, Any] = json.loads(raw)
113
+ except (OSError, json.JSONDecodeError, ValueError):
114
+ return
115
+
116
+ entry_name = entry.name
117
+ prefix = entry_name
118
+
119
+ packages = data.get("packages", [])
120
+ _prefix_ids(packages, prefix, PACKAGE_ID_FIELDS)
121
+ for p in packages:
122
+ p["entry_name"] = entry_name
123
+ p["language"] = entry.language
124
+ p["type"] = entry.type
125
+ unified.packages.extend(packages)
126
+
127
+ files = data.get("files", [])
128
+ _prefix_ids(files, prefix, FILE_ID_FIELDS)
129
+ for f in files:
130
+ f["entry_name"] = entry_name
131
+ f["language"] = entry.language
132
+ f["type"] = entry.type
133
+ unified.files.extend(files)
134
+
135
+ symbols = data.get("symbols", [])
136
+ _prefix_ids(symbols, prefix, SYMBOL_ID_FIELDS)
137
+ for s in symbols:
138
+ s["entry_name"] = entry_name
139
+ s["language"] = entry.language
140
+ s["type"] = entry.type
141
+ unified.symbols.extend(symbols)
142
+ entry.symbol_count = len(symbols)
143
+
144
+ calls = data.get("calls", [])
145
+ _prefix_ids(calls, prefix, CALL_ID_FIELDS)
146
+ for c in calls:
147
+ c["entry_name"] = entry_name
148
+ c["language"] = entry.language
149
+ c["type"] = entry.type
150
+ unified.calls.extend(calls)
151
+ entry.call_count = len(calls)
152
+
153
+ imports = data.get("imports", [])
154
+ for im in imports:
155
+ im["entry_name"] = entry_name
156
+ im["language"] = entry.language
157
+ im["type"] = entry.type
158
+ unified.imports.extend(imports)
159
+
160
+ routes = data.get("routes", [])
161
+ for r in routes:
162
+ r["entry_name"] = entry_name
163
+ r["language"] = entry.language
164
+ r["type"] = entry.type
165
+ unified.routes.extend(routes)
166
+ entry.route_count = len(routes)
167
+
168
+ all_lists: list[tuple[list[dict[str, Any]] | None, str, set[str]]] = [
169
+ (data.get("env_reads"), "env_reads", ENV_ID_FIELDS),
170
+ (data.get("errors"), "errors", ERROR_ID_FIELDS),
171
+ (data.get("test_edges"), "test_edges", TEST_EDGE_ID_FIELDS),
172
+ (data.get("mutations"), "mutations", MUTATION_ID_FIELDS),
173
+ (data.get("implements"), "implements", IMPLEMENTS_ID_FIELDS),
174
+ (data.get("blueprints"), "blueprints", set()),
175
+ (data.get("blueprint_registrations"), "blueprint_registrations", set()),
176
+ (data.get("template_refs"), "template_refs", set()),
177
+ (data.get("extensions"), "extensions", set()),
178
+ (data.get("dependencies"), "dependencies", set()),
179
+ (data.get("http_calls"), "http_calls", set()),
180
+ ]
181
+ for items, field_name, id_fields in all_lists:
182
+ if items:
183
+ _prefix_ids(items, prefix, id_fields)
184
+ for item in items:
185
+ item["entry_name"] = entry_name
186
+ item["language"] = entry.language
187
+ item["type"] = entry.type
188
+ getattr(unified, field_name).extend(items)
189
+
190
+
191
+ def _build_single(entry: WorkspaceEntry, root_path: Path) -> None:
192
+ if entry.language not in BUILT_LANGUAGES:
193
+ entry.build_status = "unsupported"
194
+ return
195
+
196
+ _run_tool_build(entry, root_path)
197
+
198
+
199
+ def build_entry(entry: WorkspaceEntry, root_path: Path) -> WorkspaceEntry:
200
+ result = _run_tool_build(entry, root_path)
201
+ return result
202
+
203
+
204
+ def _build_one(entry: WorkspaceEntry, root_path: Path) -> WorkspaceEntry:
205
+ if entry.language not in BUILT_LANGUAGES:
206
+ entry.build_status = "unsupported"
207
+ return entry
208
+ start = time.monotonic()
209
+ print(f" [{entry.name}] building ({entry.language})...", flush=True)
210
+ entry = _run_tool_build(entry, root_path)
211
+ elapsed = time.monotonic() - start
212
+ if entry.build_status == "ok":
213
+ print(f" [{entry.name}] done ({elapsed:.1f}s)", flush=True)
214
+ elif entry.build_status == "failed":
215
+ print(f" [{entry.name}] FAILED ({elapsed:.1f}s)", flush=True)
216
+ else:
217
+ print(f" [{entry.name}] {entry.build_status}", flush=True)
218
+ return entry
219
+
220
+
221
+ def build_all(
222
+ root: str,
223
+ entries: list[WorkspaceEntry] | None = None,
224
+ max_workers: int = 4,
225
+ ) -> UnifiedGraph:
226
+ root_path = Path(root).resolve()
227
+ config = load_config(root)
228
+
229
+ if entries is None:
230
+ entries = resolve_entries(config, root)
231
+
232
+ unified = make_unified_graph(workspace_root=str(root_path))
233
+
234
+ buildable = [e for e in entries if e.language in BUILT_LANGUAGES]
235
+ skipped = [e for e in entries if e.language not in BUILT_LANGUAGES]
236
+
237
+ for e in skipped:
238
+ e.build_status = "unsupported"
239
+ if unified.manifest is not None:
240
+ unified.manifest.entries.append(e)
241
+
242
+ print(f"Building {len(buildable)} entries ({len(skipped)} skipped)...")
243
+ overall_start = time.monotonic()
244
+
245
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
246
+ futures = {
247
+ pool.submit(_build_one, entry, root_path): entry
248
+ for entry in buildable
249
+ }
250
+ for future in concurrent.futures.as_completed(futures):
251
+ entry = futures[future]
252
+ try:
253
+ result = future.result()
254
+ except Exception as exc:
255
+ print(f" [{entry.name}] ERROR: {exc}", flush=True)
256
+ result = entry
257
+ result.build_status = "failed"
258
+
259
+ if result.build_status == "ok":
260
+ _stamp_and_collect(result, root_path, unified)
261
+
262
+ if unified.manifest is not None:
263
+ unified.manifest.entries.append(result)
264
+
265
+ total_time = time.monotonic() - overall_start
266
+ ok_count = sum(1 for e in buildable if e.build_status == "ok")
267
+ fail_count = sum(1 for e in buildable if e.build_status == "failed")
268
+ print(
269
+ f"Built {ok_count}/{len(buildable)} entries "
270
+ f"({fail_count} failed) in {total_time:.1f}s"
271
+ )
272
+
273
+ return unified
274
+
275
+
276
+ def build_and_write(
277
+ root: str,
278
+ entry_name: str | None = None,
279
+ ) -> Path:
280
+ root_path = Path(root).resolve()
281
+ config = load_config(root)
282
+ entries = resolve_entries(config, root)
283
+
284
+ if entry_name is not None:
285
+ entries = [e for e in entries if e.name == entry_name]
286
+
287
+ unified = build_all(root, entries)
288
+
289
+ unified.cross_service_edges = detect_cross_service_edges(unified)
290
+ run_plugins(unified, root)
291
+
292
+ out_dir = root_path / ".codegraph"
293
+ graph_path = out_dir / "workspace.graph.json"
294
+ write_graph(unified, graph_path)
295
+
296
+ if unified.manifest is not None:
297
+ manifest_path = out_dir / "manifest.json"
298
+ write_manifest(unified.manifest, manifest_path)
299
+
300
+ return graph_path
codegraph/cli.py ADDED
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from codegraph.commands.build import run as run_build
8
+ from codegraph.commands.callees import run as run_callees
9
+ from codegraph.commands.callers import run as run_callers
10
+ from codegraph.commands.clean import run as run_clean
11
+ from codegraph.commands.context import run as run_context
12
+ from codegraph.commands.cross_service import run as run_cross_service
13
+ from codegraph.commands.impact import run as run_impact
14
+ from codegraph.commands.opencode_plugin import run as run_opencode_plugin
15
+ from codegraph.commands.orphans import run as run_orphans
16
+ from codegraph.commands.query_cmd import run as run_query
17
+ from codegraph.commands.routes import run as run_routes
18
+ from codegraph.commands.status import run as run_status
19
+ from codegraph.commands.trace import run as run_trace
20
+ from codegraph.graph.serialize import read_graph
21
+ from codegraph.query import WorkspaceQuery
22
+ from codegraph.server import run_server
23
+
24
+ app = typer.Typer()
25
+
26
+
27
+ def _find_nearest_graph(root: str) -> Path | None:
28
+ path = Path(root).resolve()
29
+ for parent in [path] + list(path.parents):
30
+ candidate = parent / ".codegraph" / "workspace.graph.json"
31
+ if candidate.exists():
32
+ return candidate
33
+ return None
34
+
35
+
36
+ def _load_query(root: str) -> WorkspaceQuery:
37
+ graph_path = Path(root) / ".codegraph" / "workspace.graph.json"
38
+ if not graph_path.exists():
39
+ nearest = _find_nearest_graph(root)
40
+ if nearest:
41
+ typer.echo(
42
+ f"Error: no graph found at {graph_path}.\n"
43
+ f"Found one at {nearest} — did you mean "
44
+ f"'--root {nearest.parent.parent}'?"
45
+ )
46
+ else:
47
+ typer.echo(
48
+ f"Error: no graph found at {graph_path}.\n"
49
+ f"Run 'codegraph build' in your workspace root first."
50
+ )
51
+ raise typer.Exit(1)
52
+ graph = read_graph(graph_path)
53
+ return WorkspaceQuery(graph, root=root)
54
+
55
+
56
+ @app.command()
57
+ def status(
58
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
59
+ ) -> None:
60
+ """Detect and display workspace entries"""
61
+ run_status(root)
62
+
63
+
64
+ @app.command()
65
+ def build(
66
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
67
+ entry: str | None = typer.Option(
68
+ None, "--entry", help="Build only a specific entry by name"
69
+ ),
70
+ ) -> None:
71
+ """Build graph for all (or one) workspace entries"""
72
+ run_build(root, entry)
73
+
74
+
75
+ @app.command()
76
+ def clean(
77
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
78
+ ) -> None:
79
+ """Remove .codegraph/ output directory"""
80
+ run_clean(root)
81
+
82
+
83
+ @app.command()
84
+ def query(
85
+ pattern: str,
86
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
87
+ ) -> None:
88
+ """Search symbols by pattern (regex or substring)"""
89
+ q = _load_query(root)
90
+ run_query(q, pattern)
91
+
92
+
93
+ @app.command()
94
+ def callers(
95
+ name: str,
96
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
97
+ ) -> None:
98
+ """Show who calls the given symbol"""
99
+ q = _load_query(root)
100
+ run_callers(q, name)
101
+
102
+
103
+ @app.command()
104
+ def callees(
105
+ name: str,
106
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
107
+ ) -> None:
108
+ """Show what the given symbol calls"""
109
+ q = _load_query(root)
110
+ run_callees(q, name)
111
+
112
+
113
+ @app.command()
114
+ def routes(
115
+ entry: str | None = typer.Option(
116
+ None, "--entry", help="Filter by entry name"
117
+ ),
118
+ type: str | None = typer.Option(
119
+ None, "--type", help="Filter by entry type (service, frontend, etc.)"
120
+ ),
121
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
122
+ ) -> None:
123
+ """List all HTTP routes across the workspace"""
124
+ q = _load_query(root)
125
+ run_routes(q, entry_filter=entry, type_filter=type)
126
+
127
+
128
+ @app.command()
129
+ def impact(
130
+ name: str,
131
+ max_depth: int | None = typer.Option(
132
+ None, "--max-depth", "-d", help="Maximum depth for BFS traversal"
133
+ ),
134
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
135
+ ) -> None:
136
+ """Show downstream impact (BFS from symbol)"""
137
+ q = _load_query(root)
138
+ run_impact(q, name, max_depth=max_depth)
139
+
140
+
141
+ @app.command()
142
+ def orphans(
143
+ all: bool = typer.Option(
144
+ False, "--all", help="Include public uncalled symbols"
145
+ ),
146
+ exclude_type: str | None = typer.Option(
147
+ None, "--exclude-type", help="Exclude entries of a given type"
148
+ ),
149
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
150
+ ) -> None:
151
+ """List unreachable symbols (dead code)"""
152
+ q = _load_query(root)
153
+ run_orphans(q, include_public=all, exclude_type=exclude_type)
154
+
155
+
156
+ @app.command()
157
+ def context(
158
+ name: str,
159
+ source: bool = typer.Option(
160
+ False, "--source", help="Include full source code"
161
+ ),
162
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
163
+ ) -> None:
164
+ """Show symbol with callers, callees, tests"""
165
+ q = _load_query(root)
166
+ run_context(q, name, show_source=source)
167
+
168
+
169
+ @app.command()
170
+ def trace(
171
+ message: str,
172
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
173
+ ) -> None:
174
+ """Find error messages and trace their call paths"""
175
+ q = _load_query(root)
176
+ run_trace(q, message)
177
+
178
+
179
+ @app.command(name="add-opencode-plugin")
180
+ def add_opencode_plugin(
181
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
182
+ ) -> None:
183
+ """Create .opencode.json with codegraph MCP config + architect agent"""
184
+ q = _load_query(root)
185
+ run_opencode_plugin(q, root)
186
+
187
+
188
+ @app.command(name="cross-service")
189
+ def cross_service(
190
+ source_entry: str | None = typer.Option(
191
+ None, "--source-entry", "-s", help="Filter by source entry name"
192
+ ),
193
+ target_entry: str | None = typer.Option(
194
+ None, "--target-entry", "-t", help="Filter by target entry name"
195
+ ),
196
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
197
+ ) -> None:
198
+ """Show cross-service HTTP call edges"""
199
+ q = _load_query(root)
200
+ run_cross_service(q, source_entry=source_entry, target_entry=target_entry)
201
+
202
+
203
+ @app.command()
204
+ def mcp(
205
+ root: str = typer.Option(".", "--root", help="Workspace root directory"),
206
+ ) -> None:
207
+ """Start MCP stdio server for AI agent integration"""
208
+ run_server(root)
File without changes
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from codegraph.builder import build_and_write
4
+
5
+
6
+ def run(root: str, entry_name: str | None = None) -> None:
7
+ try:
8
+ out_path = build_and_write(root, entry_name=entry_name)
9
+ print(f"Built {out_path}")
10
+ except FileNotFoundError as e:
11
+ print(f"Error: {e}")
12
+ except Exception as e:
13
+ print(f"Build failed: {e}")
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from codegraph.query import WorkspaceQuery
4
+
5
+
6
+ def run(query: WorkspaceQuery, name: str) -> None:
7
+ results = query.get_callees(name)
8
+ if not results:
9
+ print(f"No callees found for '{name}'")
10
+ return
11
+
12
+ print(f"Callees of '{name}':")
13
+ print()
14
+ header = f"{'Callee':<30} {'Entry':<16} {'File':<40} {'Line'}"
15
+ print(header)
16
+ print("-" * len(header))
17
+ for callee_sym, edge in results:
18
+ callee_name = callee_sym.get("name", "") if callee_sym else edge.get("callee_raw", "")
19
+ entry = edge.get("entry_name", "")
20
+ file_path = edge.get("file", "")
21
+ line = edge.get("line", 0)
22
+ print(f"{callee_name:<30} {entry:<16} {file_path:<40} {line}")
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from codegraph.query import WorkspaceQuery
4
+
5
+
6
+ def run(query: WorkspaceQuery, name: str) -> None:
7
+ results = query.get_callers(name)
8
+ if not results:
9
+ print(f"No callers found for '{name}'")
10
+ return
11
+
12
+ print(f"Callers of '{name}':")
13
+ print()
14
+ header = f"{'Caller':<30} {'Entry':<16} {'File':<40} {'Line'}"
15
+ print(header)
16
+ print("-" * len(header))
17
+ for caller_sym, edge in results:
18
+ caller_name = caller_sym.get("name", "")
19
+ entry = caller_sym.get("entry_name", "")
20
+ file_path = caller_sym.get("file", "")
21
+ line = edge.get("line", 0)
22
+ print(f"{caller_name:<30} {entry:<16} {file_path:<40} {line}")
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+
7
+ def run(root: str) -> None:
8
+ root_path = Path(root).resolve()
9
+ out_dir = root_path / ".codegraph"
10
+
11
+ if not out_dir.exists():
12
+ print("Nothing to clean — .codegraph/ does not exist.")
13
+ return
14
+
15
+ shutil.rmtree(out_dir)
16
+ print(f"Removed {out_dir}")
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from codegraph.query import WorkspaceQuery
4
+
5
+
6
+ def run(query: WorkspaceQuery, name: str, show_source: bool = False) -> None:
7
+ ctx = query.get_context(name, include_source=show_source)
8
+ symbol = ctx.get("symbol")
9
+
10
+ if not symbol:
11
+ print(f"Symbol '{name}' not found")
12
+ return
13
+
14
+ print(f"Symbol: {symbol.get('name', '')}")
15
+ print(f" Kind: {symbol.get('kind', '')}")
16
+ print(f" Entry: {symbol.get('entry_name', '')}")
17
+ print(f" Language: {symbol.get('language', '')}")
18
+ print(f" Type: {symbol.get('type', '')}")
19
+ print(f" File: {symbol.get('file', '')}")
20
+ print(f" Line: {symbol.get('line', 0)}")
21
+ print(f" Exported: {symbol.get('is_exported', False)}")
22
+ print()
23
+
24
+ callers = ctx.get("callers", [])
25
+ print(f"Callers ({len(callers)}):")
26
+ if callers:
27
+ for c in callers[:10]:
28
+ entry = c.get("entry_name", "")
29
+ print(f" {c.get('caller', '')} — {c.get('file', '')}:{c.get('line', '')} [{entry}]")
30
+ print()
31
+
32
+ callees = ctx.get("callees", [])
33
+ print(f"Callees ({len(callees)}):")
34
+ if callees:
35
+ for c in callees[:10]:
36
+ entry = c.get("entry_name", "")
37
+ print(f" {c.get('callee', '')} — {c.get('file', '')}:{c.get('line', '')} [{entry}]")
38
+ print()
39
+
40
+ tests = ctx.get("tests", [])
41
+ print(f"Tests ({len(tests)}):")
42
+ if tests:
43
+ for t in tests[:10]:
44
+ entry = t.get("entry_name", "")
45
+ print(f" {t.get('test_func', '')} — {t.get('file', '')}:{t.get('line', '')} [{entry}]")
46
+
47
+ source = ctx.get("source")
48
+ if source:
49
+ print()
50
+ print("Source:")
51
+ print(source)
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from codegraph.query import WorkspaceQuery
4
+
5
+
6
+ def run(
7
+ query: WorkspaceQuery,
8
+ source_entry: str | None = None,
9
+ target_entry: str | None = None,
10
+ ) -> None:
11
+ edges = query.get_cross_service_edges(
12
+ source_entry=source_entry, target_entry=target_entry,
13
+ )
14
+
15
+ if not edges:
16
+ msg = "No cross-service edges found"
17
+ if source_entry:
18
+ msg += f" from '{source_entry}'"
19
+ if target_entry:
20
+ msg += f" to '{target_entry}'"
21
+ print(msg)
22
+ return
23
+
24
+ print(f"Cross-service edges ({len(edges)} total):")
25
+ print()
26
+ hdr = (
27
+ f"{'Source Entry':<16} {'Source Symbol':<24} "
28
+ f"{'Method':<8} {'Target Entry':<16} {'Target Route':<40} "
29
+ f"{'Confidence':<10}"
30
+ )
31
+ print(hdr)
32
+ print("-" * len(hdr))
33
+ for e in edges:
34
+ print(
35
+ f"{e.get('source_entry', ''):<16} "
36
+ f"{e.get('source_symbol', ''):<24} "
37
+ f"{e.get('method', ''):<8} "
38
+ f"{e.get('target_entry', ''):<16} "
39
+ f"{e.get('target_route_path', ''):<40} "
40
+ f"{e.get('confidence', ''):<10}"
41
+ )