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 +371 -0
- graphlm/_html_template.html +297 -0
- graphlm/cli.py +343 -0
- graphlm/config.py +73 -0
- graphlm/context.py +387 -0
- graphlm/cycles.py +144 -0
- graphlm/diff.py +438 -0
- graphlm/html_render.py +176 -0
- graphlm/llm.py +393 -0
- graphlm/models.py +209 -0
- graphlm/parser.py +645 -0
- graphlm/prompts.py +19 -0
- graphlm/provenance.py +89 -0
- graphlm/render.py +330 -0
- graphlm/scanner.py +637 -0
- graphlm/skills.py +201 -0
- graphlm-0.1.0.dist-info/METADATA +367 -0
- graphlm-0.1.0.dist-info/RECORD +21 -0
- graphlm-0.1.0.dist-info/WHEEL +4 -0
- graphlm-0.1.0.dist-info/entry_points.txt +2 -0
- graphlm-0.1.0.dist-info/licenses/LICENSE +674 -0
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>
|