graphlm 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.
graphlm/__init__.py ADDED
@@ -0,0 +1,371 @@
1
+ """graphLM — Generate codebase graphs from any project directory.
2
+
3
+ Usage as a library:
4
+
5
+ from graphlm import generate_graph
6
+
7
+ result = generate_graph("/path/to/project")
8
+ md_path, json_path, html_path = result.write("./output")
9
+
10
+ Usage as a CLI:
11
+
12
+ graphlm /path/to/project -o ./output
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import cast
19
+
20
+ import logging
21
+
22
+ from graphlm.config import Settings
23
+ from graphlm.context import (
24
+ Pass2Context,
25
+ assemble_pass1_prompt,
26
+ assemble_pass2_prompt,
27
+ filter_requested_files,
28
+ )
29
+ from graphlm.cycles import compute_sloc_map, detect_cycles
30
+ from graphlm.llm import (
31
+ CodebaseGraph,
32
+ GraphLLError,
33
+ call_llm,
34
+ )
35
+ from graphlm.models import ArchitectureNote, GraphMeta, ImportEdge
36
+ from graphlm.parser import build_dependency_graph
37
+ from graphlm.prompts import SYSTEM_PROMPT
38
+ from graphlm.provenance import git_commit_sha, graphlm_version, now_utc_iso
39
+ from graphlm.render import WriteResult, write_outputs
40
+ from graphlm.scanner import ScanResult, scan_project
41
+
42
+
43
+ def _build_meta(project_path: Path) -> GraphMeta:
44
+ """Build the provenance stamp for a run against ``project_path``.
45
+
46
+ Failure-tolerant throughout: a non-git project yields ``commit_sha=None``,
47
+ a non-installed checkout yields ``graphlm_version=None``. Never raises.
48
+ """
49
+ return GraphMeta(
50
+ created_at=now_utc_iso(),
51
+ commit_sha=git_commit_sha(project_path),
52
+ graphlm_version=graphlm_version(),
53
+ )
54
+
55
+
56
+ class GraphResult:
57
+ """Output artifacts from a graph generation run."""
58
+
59
+ def __init__(
60
+ self,
61
+ graph: CodebaseGraph,
62
+ pass1_context_tokens: int,
63
+ pass2_context_tokens: int,
64
+ files_analyzed: int,
65
+ ) -> None:
66
+ self.graph = graph
67
+ self.pass1_context_tokens = pass1_context_tokens
68
+ self.pass2_context_tokens = pass2_context_tokens
69
+ self.files_analyzed = files_analyzed
70
+
71
+ def write(
72
+ self,
73
+ output_dir: str | Path,
74
+ *,
75
+ include_html: bool = True,
76
+ include_diff: bool = True,
77
+ ) -> WriteResult:
78
+ """Write .md, .json (and optionally .html + the diff) to output_dir.
79
+
80
+ Returns a ``WriteResult`` — the ``(md, json, html)`` path tuple, with
81
+ ``.diff_md`` / ``.diff_json`` attributes (``None`` when
82
+ ``include_diff=False``). The diff (``GRAPH_DIFF.*``) reads the prior
83
+ ``GRAPH.json`` in ``output_dir`` before overwriting it; see ADR-002.
84
+ """
85
+ return write_outputs(
86
+ self.graph, Path(output_dir), html=include_html, diff=include_diff
87
+ )
88
+
89
+
90
+ def generate_graph(
91
+ project_dir: str | Path,
92
+ *,
93
+ base_url: str | None = None,
94
+ api_key: str | None = None,
95
+ model: str | None = None,
96
+ output_dir: str | Path | None = None,
97
+ max_file_chars: int = 4000,
98
+ max_files: int = 200,
99
+ max_pass2_files: int = 80,
100
+ max_context: int | None = None,
101
+ timeout: float | None = None,
102
+ max_output_tokens: int | None = None,
103
+ include_tests: bool = True,
104
+ exclude_patterns: tuple[str, ...] = (),
105
+ dry_run: bool = False,
106
+ redact_secrets: bool = True,
107
+ ast: bool = True,
108
+ show_cycles: bool = True,
109
+ cycle_threshold: float = 0.0,
110
+ include_html: bool = True,
111
+ include_diff: bool = True,
112
+ ) -> GraphResult:
113
+ """Generate a codebase graph for a project directory.
114
+
115
+ Two-pass strategy:
116
+ 1. Send directory tree to LLM → LLM identifies key files to read
117
+ 2. Send tree + key files to LLM → LLM produces the final graph
118
+
119
+ Args:
120
+ project_dir: Path to the project directory to analyze.
121
+ base_url: LLM API base URL (falls back to GRAPHLM_BASE_URL env var).
122
+ api_key: LLM API key (falls back to GRAPHLM_API_KEY env var).
123
+ model: Model name (falls back to GRAPHLM_MODEL env var).
124
+ output_dir: Where to write GRAPH.md/json/html. None means do not write
125
+ (the CLI defaults to the scanned project directory).
126
+ max_file_chars: Maximum characters to read per file.
127
+ max_files: Maximum files to scan initially.
128
+ max_pass2_files: Maximum files to include in pass 2 context.
129
+ max_context: Maximum context window in tokens. If None, falls back to
130
+ the GRAPHLM_MAX_CONTEXT env var, then to 120000. An explicit value
131
+ (e.g. from the CLI --max-context flag) takes precedence over both.
132
+ timeout: LLM request timeout in seconds. If None, falls back to the
133
+ GRAPHLM_TIMEOUT env var, then to 300. An explicit value (the CLI
134
+ --timeout flag) takes precedence. Pass 2 is streamed, so a large
135
+ project's generation can legitimately take minutes (#18).
136
+ max_output_tokens: Max tokens the model may emit for the graph — the
137
+ `max_tokens` the client requests. A ceiling, not a reservation: it is
138
+ NOT taken out of the input budget (max_context), because input and
139
+ output ceilings are independent on the target endpoint (#25). If None,
140
+ falls back to GRAPHLM_MAX_OUTPUT_TOKENS env, then LLM_MAX_OUTPUT_TOKENS
141
+ (the model's practical max). Truncation past even this raises a clear
142
+ GraphLLErrorTruncated.
143
+ include_tests: Whether to include test files in the analysis.
144
+ exclude_patterns: Additional glob patterns to exclude.
145
+ dry_run: If True, return the scan context without calling the LLM.
146
+ redact_secrets: If True, redact secret-like patterns from file content.
147
+ ast: If True (default), run AST-based deterministic import detection,
148
+ attach those edges to the graph, and pass them to the LLM as
149
+ ground truth. Pass False / --no-ast to skip.
150
+ include_html: If output_dir is set, whether to also write GRAPH.html.
151
+ include_diff: If output_dir is set, whether to also write the
152
+ graph-vs-graph diff (GRAPH_DIFF.md/json) against the prior
153
+ GRAPH.json in that directory. On by default; see ADR-002. Never
154
+ reached on a --dry-run (the dry-run branch returns before any write).
155
+
156
+ Returns:
157
+ GraphResult with the graph and output metadata.
158
+
159
+ Raises:
160
+ ValueError: If configuration is invalid.
161
+ GraphLLError: If the LLM call fails.
162
+ """
163
+ project_path = Path(project_dir).resolve()
164
+
165
+ if not project_path.exists():
166
+ raise FileNotFoundError(f"Project directory not found: {project_dir}")
167
+ if not project_path.is_dir():
168
+ raise NotADirectoryError(f"Not a directory: {project_dir}")
169
+
170
+ # Resolve the context budget: explicit arg > GRAPHLM_MAX_CONTEXT env > 120000.
171
+ # Passing max_context=None (the CLI default when --max-context is unset) lets
172
+ # the env var take effect; an explicit value always wins.
173
+ if max_context is None:
174
+ import os
175
+
176
+ max_context = int(os.environ.get("GRAPHLM_MAX_CONTEXT", "120000"))
177
+
178
+ # Resolve the output-token reserve: explicit arg > GRAPHLM_MAX_OUTPUT_TOKENS
179
+ # env > LLM_MAX_OUTPUT_TOKENS default. Needed even in dry-run so the pass-2
180
+ # estimate reserves the same budget the real call would request. This value
181
+ # is passed to BOTH assemble_pass2_prompt (the input reserve) and call_llm
182
+ # (the max_tokens requested), keeping the two in lock-step (#17/#18).
183
+ if max_output_tokens is None:
184
+ import os
185
+
186
+ from graphlm.llm import LLM_MAX_OUTPUT_TOKENS
187
+
188
+ max_output_tokens = int(
189
+ os.environ.get("GRAPHLM_MAX_OUTPUT_TOKENS", str(LLM_MAX_OUTPUT_TOKENS))
190
+ )
191
+
192
+ # Resolve configuration (not needed for dry run)
193
+ if dry_run:
194
+ settings = None
195
+ elif base_url or api_key or model:
196
+ if not base_url or not api_key or not model:
197
+ raise ValueError(
198
+ "If any of base_url/api_key/model are provided, "
199
+ "all three must be provided."
200
+ )
201
+ settings = Settings(base_url=base_url, api_key=api_key, model=model)
202
+ else:
203
+ try:
204
+ settings = Settings.from_env()
205
+ except ValueError as e:
206
+ raise ValueError(str(e)) from None
207
+
208
+ # Resolve the request timeout: explicit arg > (settings, which already
209
+ # carries GRAPHLM_TIMEOUT env > default when built via from_env). When
210
+ # settings is built from explicit base_url/api_key/model it uses the default
211
+ # timeout; an explicit `timeout` arg (the CLI --timeout flag) overrides.
212
+ resolved_timeout = timeout if timeout is not None else (
213
+ settings.timeout if settings is not None else None
214
+ )
215
+
216
+ # Phase 1: Scan the project
217
+ scan = scan_project(
218
+ project_path,
219
+ max_file_chars=max_file_chars,
220
+ max_files=max_files,
221
+ include_tests=include_tests,
222
+ exclude_patterns=exclude_patterns,
223
+ redact_secrets=redact_secrets,
224
+ )
225
+
226
+ # Deterministic import edges from AST parsing (on by default)
227
+ deterministic_edges: list[ImportEdge] | None = None
228
+ if ast:
229
+ try:
230
+ deterministic_edges = build_dependency_graph(
231
+ scan.file_fragments, project_dir=project_path, max_files=max_files,
232
+ )
233
+ except Exception as e:
234
+ logging.warning("AST parsing failed, continuing without it: %s", e)
235
+
236
+ sloc_map = compute_sloc_map(scan.file_fragments)
237
+
238
+ if dry_run:
239
+ # Don't call the LLM, just show context stats
240
+ # Simulate pass 1 selecting all scanned files
241
+ pass2_files = scan.file_fragments[:max_pass2_files]
242
+ pass2_prompt, pass2_tokens, _truncated = assemble_pass2_prompt(
243
+ scan.tree,
244
+ pass2_files,
245
+ max_context=max_context,
246
+ deterministic_edges=deterministic_edges,
247
+ )
248
+ graph = CodebaseGraph(
249
+ directory_tree=scan.tree,
250
+ architecture_notes=[
251
+ ArchitectureNote(
252
+ note=f"DRY RUN: {len(scan.file_fragments)} files scanned, "
253
+ f"{len(pass2_files)} files selected for analysis, "
254
+ f"{pass2_tokens} estimated pass-2 tokens"
255
+ ),
256
+ ],
257
+ deterministic_edges=deterministic_edges,
258
+ )
259
+ if show_cycles:
260
+ graph.import_cycles = [
261
+ c
262
+ for c in detect_cycles(
263
+ deterministic_edges or [], sloc_map=sloc_map
264
+ )
265
+ if c.risk_score >= cycle_threshold
266
+ ]
267
+ # Stamp the dry-run graph too, so its provenance is consistent with a
268
+ # real run (a --dry-run write would otherwise carry no directive).
269
+ graph.meta = _build_meta(project_path)
270
+ return GraphResult(
271
+ graph=graph,
272
+ pass1_context_tokens=pass1_tokens(scan.tree),
273
+ pass2_context_tokens=pass2_tokens,
274
+ files_analyzed=len(pass2_files),
275
+ )
276
+
277
+ # Phase 1: LLM identifies key files from tree only
278
+ assert settings is not None
279
+
280
+ pass1_prompt = assemble_pass1_prompt(scan.tree)
281
+ pass1_result_json = call_llm(
282
+ base_url=settings.base_url,
283
+ api_key=settings.api_key,
284
+ model=settings.model,
285
+ system_prompt=SYSTEM_PROMPT,
286
+ user_prompt=pass1_prompt,
287
+ timeout=resolved_timeout,
288
+ )
289
+ pass1_result_json = cast(str, pass1_result_json)
290
+
291
+ # Parse pass 1 result
292
+ import json as _json
293
+
294
+ try:
295
+ pass1_data = _json.loads(pass1_result_json)
296
+ requested_files = pass1_data.get("requested_files", [])
297
+ except (_json.JSONDecodeError, TypeError, KeyError) as e:
298
+ raise GraphLLError(
299
+ f"Pass 1 LLM response was not valid JSON: {e}\n"
300
+ f"Response: {pass1_result_json[:200]}"
301
+ ) from e
302
+
303
+ # Phase 2: Filter requested files and assemble context
304
+ pass2_files = filter_requested_files(scan, requested_files, max_pass2_files)
305
+ pass2_prompt, pass2_tokens, _truncated = assemble_pass2_prompt(
306
+ scan.tree,
307
+ pass2_files,
308
+ max_context=max_context,
309
+ deterministic_edges=deterministic_edges,
310
+ )
311
+
312
+ # Phase 2: LLM produces the final graph
313
+ assert settings is not None
314
+ graph_result = call_llm(
315
+ base_url=settings.base_url,
316
+ api_key=settings.api_key,
317
+ model=settings.model,
318
+ system_prompt=SYSTEM_PROMPT,
319
+ user_prompt=pass2_prompt,
320
+ response_format=CodebaseGraph,
321
+ timeout=resolved_timeout,
322
+ max_output_tokens=max_output_tokens,
323
+ )
324
+ graph = cast(CodebaseGraph, graph_result)
325
+ # Fill the tree locally rather than making the model echo it back — the echo
326
+ # alone can exceed the output-token ceiling on a large repo (argus's tree is
327
+ # ~20k output tokens), truncating the graph before any module is described
328
+ # (#18). The pass-2 prompt now asks the model for an empty directory_tree.
329
+ graph.directory_tree = scan.tree
330
+ graph.deterministic_edges = deterministic_edges
331
+ if show_cycles:
332
+ cycle_edges = (
333
+ deterministic_edges
334
+ if deterministic_edges is not None
335
+ else graph.import_edges
336
+ )
337
+ graph.import_cycles = [
338
+ c
339
+ for c in detect_cycles(cycle_edges, sloc_map=sloc_map)
340
+ if c.risk_score >= cycle_threshold
341
+ ]
342
+ else:
343
+ graph.import_cycles = []
344
+
345
+ # Stamp provenance locally, overwriting anything the model may have emitted
346
+ # for `meta` (like directory_tree, meta is filled here, never trusted from
347
+ # the LLM). The GRAPH.md refresh directive is rendered from this.
348
+ graph.meta = _build_meta(project_path)
349
+
350
+ # Write outputs if output_dir specified
351
+ if output_dir is not None:
352
+ write_outputs(graph, Path(output_dir), html=include_html, diff=include_diff)
353
+
354
+ return GraphResult(
355
+ graph=graph,
356
+ pass1_context_tokens=pass1_tokens(scan.tree),
357
+ pass2_context_tokens=pass2_tokens,
358
+ files_analyzed=len(pass2_files),
359
+ )
360
+
361
+
362
+ def pass1_tokens(tree: str) -> int:
363
+ """Estimate token count for pass 1 prompt (tree + instructions)."""
364
+ from graphlm.context import estimate_tokens
365
+
366
+ instruction_tokens = estimate_tokens(
367
+ "You are analyzing a project directory to determine which files "
368
+ "are most important to read for a comprehensive codebase analysis. "
369
+ "Return a JSON object with requested_files list."
370
+ )
371
+ return instruction_tokens + estimate_tokens(tree)
@@ -0,0 +1,297 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Codebase Graph</title>
7
+ <script src="https://d3js.org/d3.v7.min.js"></script>
8
+ <style>
9
+ :root {
10
+ --bg: #1a1b26; --fg: #a9b1d6; --surface: #24283b;
11
+ --border: #33364a; --accent: #7aa2f7;
12
+ }
13
+ .light {
14
+ --bg: #eff1f5; --fg: #5c5f77; --surface: #ffffff;
15
+ --border: #ccd0da; --accent: #1e66f5;
16
+ }
17
+ * { margin: 0; padding: 0; box-sizing: border-box; }
18
+ body {
19
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
20
+ background: var(--bg); color: var(--fg);
21
+ overflow: hidden; height: 100vh;
22
+ transition: background 0.3s, color 0.3s;
23
+ }
24
+ #controls {
25
+ position: absolute; top: 12px; left: 12px; z-index: 10;
26
+ display: flex; gap: 8px; align-items: center;
27
+ }
28
+ #search {
29
+ padding: 6px 12px; border: 1px solid var(--border);
30
+ border-radius: 6px; background: var(--surface); color: var(--fg);
31
+ font-size: 14px; width: 220px; outline: none;
32
+ transition: border-color 0.2s;
33
+ }
34
+ #search:focus { border-color: var(--accent); }
35
+ #search::placeholder { color: var(--fg); opacity: 0.4; }
36
+ #theme-toggle {
37
+ padding: 6px 12px; border: 1px solid var(--border);
38
+ border-radius: 6px; background: var(--surface); color: var(--fg);
39
+ cursor: pointer; font-size: 14px; transition: border-color 0.2s;
40
+ }
41
+ #theme-toggle:hover { border-color: var(--accent); }
42
+ #legend {
43
+ position: absolute; top: 12px; right: 12px; z-index: 10;
44
+ background: var(--surface); border: 1px solid var(--border);
45
+ border-radius: 8px; padding: 12px 16px; font-size: 12px;
46
+ max-height: 80vh; overflow-y: auto; min-width: 120px;
47
+ }
48
+ #legend h3 { margin-bottom: 8px; color: var(--accent); font-size: 13px; }
49
+ #legend .item { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
50
+ #legend .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
51
+ #legend .rect { width: 10px; height: 10px; flex-shrink: 0; }
52
+ #legend .line { width: 20px; height: 0; border-top: 2px solid; flex-shrink: 0; }
53
+ #tooltip {
54
+ position: absolute; pointer-events: none; background: var(--surface);
55
+ border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px;
56
+ font-size: 12px; max-width: 280px; display: none; z-index: 20;
57
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
58
+ }
59
+ #tooltip .tt-name { font-weight: 600; color: var(--accent); }
60
+ #tooltip .tt-path { opacity: 0.7; margin-top: 2px; }
61
+ #tooltip .tt-desc { margin-top: 4px; }
62
+ #graph {
63
+ width: 100%; height: 100vh;
64
+ }
65
+ svg { display: block; width: 100%; height: 100%; }
66
+ .link { fill: none; stroke-width: 1.5; transition: stroke-opacity 0.3s; }
67
+ .node { cursor: pointer; }
68
+ .label {
69
+ font-size: 9px; fill: var(--fg); pointer-events: none;
70
+ text-anchor: middle; transition: opacity 0.3s;
71
+ }
72
+ #stats {
73
+ position: absolute; bottom: 12px; left: 12px;
74
+ font-size: 11px; opacity: 0.6; z-index: 10;
75
+ }
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <div id="controls">
80
+ <input type="text" id="search" placeholder="Search nodes...">
81
+ <button id="theme-toggle">Toggle Theme</button>
82
+ </div>
83
+ <div id="legend"></div>
84
+ <div id="tooltip"></div>
85
+ <div id="stats"></div>
86
+ <div id="graph"></div>
87
+ <script>
88
+ const graphData = {EMBEDDED_JSON};
89
+ const _PALETTE = {_PALETTE};
90
+ const colorScale = d3.scaleOrdinal(_PALETTE);
91
+ function nodeColor(d) {
92
+ const raw = d.path || d.name || '';
93
+ const parts = raw.split('/');
94
+ const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : parts[0];
95
+ return colorScale(dir);
96
+ }
97
+ function escapeHtml(s) {
98
+ const div = document.createElement('div');
99
+ div.textContent = s || '';
100
+ return div.innerHTML;
101
+ }
102
+ function buildLegend() {
103
+ const el = document.getElementById('legend');
104
+ el.innerHTML = '<h3>Legend</h3>';
105
+ const items = [
106
+ {label:'Module', html:'<span class="dot"></span>'},
107
+ {label:'Entry Point', html:'<span class="rect"></span>'},
108
+ {label:'File Summary', html:'<span class="dot" style="width:6px;height:6px"></span>'},
109
+ {label:'Import Edge', html:'<span class="line" style="border-color:#888"></span>'},
110
+ {label:'Data Flow', html:'<span class="line" style="border-color:#c69;border-style:dashed"></span>'},
111
+ ];
112
+ for (const item of items) {
113
+ const div = document.createElement('div');
114
+ div.className = 'item';
115
+ div.innerHTML = item.html + ' ' + item.label;
116
+ el.appendChild(div);
117
+ }
118
+ const dirs = [...new Set(graphData.nodes.map(n => {
119
+ const parts = n.path.split('/');
120
+ return parts.length > 1 ? parts.slice(0, -1).join('/') : parts[0];
121
+ }))];
122
+ if (dirs.length > 1) {
123
+ const h = document.createElement('div');
124
+ h.style.marginTop = '8px'; h.style.marginBottom = '4px';
125
+ h.style.fontWeight = '600'; h.textContent = 'Directories';
126
+ el.appendChild(h);
127
+ for (const dir of dirs.slice(0, 20)) {
128
+ const c = colorScale(dir);
129
+ const div = document.createElement('div');
130
+ div.className = 'item';
131
+ div.innerHTML = '<span class="dot" style="background:' + c + '"></span> ' + escapeHtml(dir);
132
+ el.appendChild(div);
133
+ }
134
+ if (dirs.length > 20) {
135
+ const more = document.createElement('div');
136
+ more.style.opacity = '0.5'; more.style.marginTop = '4px';
137
+ more.textContent = dirs.length - 20 + ' more...';
138
+ el.appendChild(more);
139
+ }
140
+ }
141
+ }
142
+ function initGraph() {
143
+ const container = document.getElementById('graph');
144
+ const width = container.clientWidth || 1200;
145
+ const height = container.clientHeight || 800;
146
+ const nodes = graphData.nodes || [];
147
+ nodes.forEach(n => { n.id = n.id || n.path || n.name; });
148
+ const nodeById = new Map();
149
+ nodes.forEach(n => {
150
+ nodeById.set(n.id, n);
151
+ if (n.path) nodeById.set(n.path, n);
152
+ });
153
+ // Pre-resolve to node objects and drop unknown endpoints. D3 forceLink
154
+ // throws "node not found" if a link id is missing, which blanked the graph.
155
+ const links = [];
156
+ (graphData.links || []).forEach((l, i) => {
157
+ const s = nodeById.get(l.source);
158
+ const t = nodeById.get(l.target);
159
+ if (!s || !t) return;
160
+ links.push({...l, id: 'link_' + i, source: s, target: t});
161
+ });
162
+ const simulation = d3.forceSimulation(nodes)
163
+ .force('link', d3.forceLink(links).distance(80))
164
+ .force('charge', d3.forceManyBody().strength(-200))
165
+ .force('center', d3.forceCenter(width / 2, height / 2))
166
+ .force('collision', d3.forceCollide().radius(d => d.r + 8))
167
+ .force('x', d3.forceX(width / 2).strength(0.06))
168
+ .force('y', d3.forceY(height / 2).strength(0.06));
169
+ const svg = d3.select('#graph').append('svg')
170
+ .attr('viewBox', [0, 0, width, height]);
171
+ const g = svg.append('g');
172
+ const zoom = d3.zoom().scaleExtent([0.2, 4])
173
+ .on('zoom', (e) => g.attr('transform', e.transform));
174
+ svg.call(zoom);
175
+ const link = g.append('g').selectAll('path').data(links)
176
+ .join('path').attr('class', 'link')
177
+ .attr('stroke', d => d.stroke || '#888')
178
+ .attr('stroke-dasharray', d => d.dash || null)
179
+ .attr('stroke-opacity', 0.5);
180
+ const node = g.append('g').selectAll('g').data(nodes)
181
+ .join('g').attr('class', 'node');
182
+ node.each(function(d) {
183
+ const sel = d3.select(this);
184
+ if (d.type === 'entry_point') {
185
+ sel.append('rect')
186
+ .attr('x', -d.r).attr('y', -d.r)
187
+ .attr('width', d.r * 2).attr('height', d.r * 2)
188
+ .attr('rx', 3).attr('fill', nodeColor(d))
189
+ .attr('stroke', '#fff').attr('stroke-width', 1.5)
190
+ .attr('stroke-opacity', 0.3);
191
+ } else {
192
+ sel.append('circle')
193
+ .attr('r', d.r).attr('fill', nodeColor(d))
194
+ .attr('stroke', '#fff').attr('stroke-width', 1.5)
195
+ .attr('stroke-opacity', 0.3);
196
+ }
197
+ if (d.r >= 8) {
198
+ const parts = (d.name || d.path).split('.');
199
+ sel.append('text')
200
+ .attr('class', 'label')
201
+ .attr('dy', d.r + 12)
202
+ .text(parts.length > 1 ? parts[parts.length - 1] : (d.name || ''))
203
+ .attr('opacity', 0.7);
204
+ }
205
+ });
206
+ const labels = node.selectAll('text.label');
207
+ simulation.on('tick', () => {
208
+ link.attr('d', d => {
209
+ if (d.source.x == null || d.target.x == null) return null;
210
+ const x1 = d.source.x, y1 = d.source.y, x2 = d.target.x, y2 = d.target.y;
211
+ const dx = x2 - x1, dy = y2 - y1;
212
+ const dr = Math.hypot(dx, dy) || 1;
213
+ return 'M' + x1 + ',' + y1 + 'A' + dr + ',' + dr + ' 0 0,1 ' + x2 + ',' + y2;
214
+ });
215
+ node.attr('transform', d => 'translate(' + (d.x ?? 0) + ',' + (d.y ?? 0) + ')');
216
+ });
217
+ const tooltip = d3.select('#tooltip');
218
+ node.on('mouseover', function(event, d) {
219
+ tooltip.style('display', 'block').html(
220
+ '<div class="tt-name">' + escapeHtml(d.name || d.path) + '</div>' +
221
+ '<div class="tt-path">' + escapeHtml(d.path) + '</div>' +
222
+ (d.description ? '<div class="tt-desc">' + escapeHtml(d.description) + '</div>' : '') +
223
+ '<div style="opacity:0.5;margin-top:4px">Type: ' + escapeHtml(d.type) + '</div>'
224
+ );
225
+ d3.select(this).select('circle, rect')
226
+ .transition().duration(100)
227
+ .attr('stroke-opacity', 1).attr('stroke', nodeColor(d));
228
+ }).on('mousemove', function(event) {
229
+ tooltip.style('left', (event.pageX + 12) + 'px')
230
+ .style('top', (event.pageY - 12) + 'px');
231
+ }).on('mouseout', function() {
232
+ tooltip.style('display', 'none');
233
+ d3.select(this).select('circle, rect')
234
+ .transition().duration(100)
235
+ .attr('stroke-opacity', 0.3).attr('stroke', '#fff');
236
+ });
237
+ let highlighted = new Set();
238
+ node.on('click', function(event, d) {
239
+ event.stopPropagation();
240
+ if (highlighted.has(d.path)) {
241
+ highlighted.clear();
242
+ node.transition().duration(300).attr('opacity', 1);
243
+ link.transition().duration(300).attr('stroke-opacity', 0.5);
244
+ labels.transition().duration(300).attr('opacity', 0.7);
245
+ return;
246
+ }
247
+ const connected = new Set();
248
+ connected.add(d.path);
249
+ links.forEach(l => {
250
+ const s = l.source.path || l.source;
251
+ const t = l.target.path || l.target;
252
+ if (s === d.path) connected.add(t);
253
+ if (t === d.path) connected.add(s);
254
+ });
255
+ highlighted = connected;
256
+ node.transition().duration(300)
257
+ .attr('opacity', n => connected.has(n.path) ? 1 : 0.15);
258
+ link.transition().duration(300)
259
+ .attr('stroke-opacity', l => {
260
+ const s = l.source.path || l.source;
261
+ const t = l.target.path || l.target;
262
+ return (connected.has(s) && connected.has(t)) ? 0.8 : 0.05;
263
+ });
264
+ labels.transition().duration(300)
265
+ .attr('opacity', d => connected.has(d.path) ? 0.7 : 0.1);
266
+ });
267
+ d3.select('body').on('click', () => {
268
+ highlighted.clear();
269
+ node.transition().duration(300).attr('opacity', 1);
270
+ link.transition().duration(300).attr('stroke-opacity', 0.5);
271
+ labels.transition().duration(300).attr('opacity', 0.7);
272
+ });
273
+ document.getElementById('search').addEventListener('input', function() {
274
+ const q = this.value.toLowerCase();
275
+ node.transition().duration(200)
276
+ .attr('opacity', n => !q || (n.name||'').toLowerCase().includes(q) || (n.path||'').toLowerCase().includes(q) ? 1 : 0.15);
277
+ link.transition().duration(200)
278
+ .attr('stroke-opacity', 0.5);
279
+ labels.transition().duration(200)
280
+ .attr('opacity', d => {
281
+ if (!q) return 0.7;
282
+ return ((d.name||'').toLowerCase().includes(q) || (d.path||'').toLowerCase().includes(q)) ? 0.7 : 0.1;
283
+ });
284
+ });
285
+ document.getElementById('theme-toggle').addEventListener('click', () => {
286
+ document.body.classList.toggle('light');
287
+ });
288
+ buildLegend();
289
+ }
290
+ window.addEventListener('DOMContentLoaded', () => {
291
+ document.getElementById('stats').textContent =
292
+ graphData.nodes.length + ' nodes, ' + graphData.links.length + ' edges';
293
+ initGraph();
294
+ });
295
+ </script>
296
+ </body>
297
+ </html>