graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Deterministic, bounded identity for the installed Graphite engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from importlib import metadata
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
# Bumped to 2 when the grammars joined the identity (#52): a fingerprint computed
|
|
13
|
+
# under schema 1 covered graphite's own files only, so the two are not comparable.
|
|
14
|
+
ENGINE_SCHEMA_VERSION: Final = "2"
|
|
15
|
+
|
|
16
|
+
# The tree-sitter distributions that actually produce the ASTs. Kept sorted so the
|
|
17
|
+
# record is order-independent, and hyphenated because these are DISTRIBUTION names
|
|
18
|
+
# (what importlib.metadata resolves), not the underscored import names used in
|
|
19
|
+
# `extract.ast`.
|
|
20
|
+
PARSER_DISTRIBUTIONS: Final = (
|
|
21
|
+
"tree-sitter",
|
|
22
|
+
"tree-sitter-go",
|
|
23
|
+
"tree-sitter-javascript",
|
|
24
|
+
"tree-sitter-python",
|
|
25
|
+
"tree-sitter-rust",
|
|
26
|
+
"tree-sitter-typescript",
|
|
27
|
+
)
|
|
28
|
+
MAX_ENGINE_FILES: Final = 512
|
|
29
|
+
MAX_ENGINE_FILE_BYTES: Final = 8 * 1024 * 1024
|
|
30
|
+
MAX_ENGINE_TOTAL_BYTES: Final = 64 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
_SOURCE_SUFFIXES = frozenset({".py", ".mjs"})
|
|
33
|
+
_EXCLUDED_DIRECTORIES = frozenset(
|
|
34
|
+
{"__pycache__", ".cache", ".mypy_cache", ".pytest_cache", ".ruff_cache"}
|
|
35
|
+
)
|
|
36
|
+
_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EngineIdentityError(RuntimeError):
|
|
40
|
+
"""A fixed, path-free engine inventory or read failure."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, code: str) -> None:
|
|
43
|
+
self.code = code
|
|
44
|
+
super().__init__(code)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_reparse_point(metadata: os.stat_result) -> bool:
|
|
48
|
+
return bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _stable_signature(metadata: os.stat_result) -> tuple[int, int, int, int]:
|
|
52
|
+
return (
|
|
53
|
+
int(metadata.st_dev),
|
|
54
|
+
int(metadata.st_ino),
|
|
55
|
+
int(metadata.st_size),
|
|
56
|
+
int(metadata.st_mtime_ns),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _contained_relative(path: Path, root: Path) -> str:
|
|
61
|
+
try:
|
|
62
|
+
resolved = path.resolve(strict=True)
|
|
63
|
+
relative = resolved.relative_to(root)
|
|
64
|
+
except (OSError, ValueError) as exc:
|
|
65
|
+
raise EngineIdentityError("engine_root_crossing") from exc
|
|
66
|
+
normalized = relative.as_posix()
|
|
67
|
+
if not normalized or normalized.startswith("../") or normalized.startswith("/"):
|
|
68
|
+
raise EngineIdentityError("engine_root_crossing")
|
|
69
|
+
return normalized
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _collect_engine_files(root: Path, *, max_files: int) -> list[Path]:
|
|
73
|
+
if isinstance(max_files, bool) or max_files <= 0:
|
|
74
|
+
raise EngineIdentityError("engine_file_limit")
|
|
75
|
+
try:
|
|
76
|
+
root_metadata = root.lstat()
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
raise EngineIdentityError("engine_root_invalid") from exc
|
|
79
|
+
if _is_reparse_point(root_metadata) or stat.S_ISLNK(root_metadata.st_mode):
|
|
80
|
+
raise EngineIdentityError("engine_root_reparse")
|
|
81
|
+
if not stat.S_ISDIR(root_metadata.st_mode):
|
|
82
|
+
raise EngineIdentityError("engine_root_invalid")
|
|
83
|
+
|
|
84
|
+
files: list[Path] = []
|
|
85
|
+
pending = [root]
|
|
86
|
+
try:
|
|
87
|
+
while pending:
|
|
88
|
+
directory = pending.pop()
|
|
89
|
+
with os.scandir(directory) as entries:
|
|
90
|
+
for entry in entries:
|
|
91
|
+
metadata = entry.stat(follow_symlinks=False)
|
|
92
|
+
if entry.is_symlink() or _is_reparse_point(metadata):
|
|
93
|
+
raise EngineIdentityError("engine_path_reparse")
|
|
94
|
+
entry_path = Path(entry.path)
|
|
95
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
96
|
+
if entry.name not in _EXCLUDED_DIRECTORIES:
|
|
97
|
+
pending.append(entry_path)
|
|
98
|
+
continue
|
|
99
|
+
if entry_path.suffix.lower() not in _SOURCE_SUFFIXES:
|
|
100
|
+
continue
|
|
101
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
102
|
+
raise EngineIdentityError("engine_non_regular")
|
|
103
|
+
files.append(entry_path)
|
|
104
|
+
if len(files) > max_files:
|
|
105
|
+
raise EngineIdentityError("engine_file_limit")
|
|
106
|
+
except EngineIdentityError:
|
|
107
|
+
raise
|
|
108
|
+
except OSError as exc:
|
|
109
|
+
raise EngineIdentityError("engine_file_unreadable") from exc
|
|
110
|
+
return files
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read_stable_file(path: Path, root: Path, limit: int) -> bytes:
|
|
114
|
+
_contained_relative(path, root)
|
|
115
|
+
try:
|
|
116
|
+
before = path.lstat()
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
raise EngineIdentityError("engine_file_unreadable") from exc
|
|
119
|
+
if stat.S_ISLNK(before.st_mode) or _is_reparse_point(before):
|
|
120
|
+
raise EngineIdentityError("engine_path_reparse")
|
|
121
|
+
if not stat.S_ISREG(before.st_mode):
|
|
122
|
+
raise EngineIdentityError("engine_non_regular")
|
|
123
|
+
if before.st_size > limit:
|
|
124
|
+
raise EngineIdentityError("engine_file_too_large")
|
|
125
|
+
|
|
126
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
|
127
|
+
try:
|
|
128
|
+
descriptor = os.open(path, flags)
|
|
129
|
+
try:
|
|
130
|
+
opened = os.fstat(descriptor)
|
|
131
|
+
if _stable_signature(opened) != _stable_signature(before):
|
|
132
|
+
raise EngineIdentityError("engine_file_changed")
|
|
133
|
+
chunks: list[bytes] = []
|
|
134
|
+
remaining = limit + 1
|
|
135
|
+
while remaining > 0:
|
|
136
|
+
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
|
137
|
+
if not chunk:
|
|
138
|
+
break
|
|
139
|
+
chunks.append(chunk)
|
|
140
|
+
remaining -= len(chunk)
|
|
141
|
+
after = os.fstat(descriptor)
|
|
142
|
+
finally:
|
|
143
|
+
os.close(descriptor)
|
|
144
|
+
except EngineIdentityError:
|
|
145
|
+
raise
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
raise EngineIdentityError("engine_file_unreadable") from exc
|
|
148
|
+
|
|
149
|
+
data = b"".join(chunks)
|
|
150
|
+
if len(data) > limit:
|
|
151
|
+
raise EngineIdentityError("engine_file_too_large")
|
|
152
|
+
if _stable_signature(after) != _stable_signature(opened):
|
|
153
|
+
raise EngineIdentityError("engine_file_changed")
|
|
154
|
+
return data
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parser_inventory() -> str:
|
|
158
|
+
"""Record the grammar versions that will do the parsing, deterministically.
|
|
159
|
+
|
|
160
|
+
An absent grammar is recorded as `absent` rather than omitted: "go was
|
|
161
|
+
installed" and "go was not" are different engines and must not share a
|
|
162
|
+
fingerprint. Missing a grammar degrades that one language, so it must not
|
|
163
|
+
make computing an identity fail.
|
|
164
|
+
"""
|
|
165
|
+
return ",".join(_parser_record(name) for name in PARSER_DISTRIBUTIONS)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parser_record(name: str) -> str:
|
|
169
|
+
try:
|
|
170
|
+
return f"{name}={metadata.version(name)}"
|
|
171
|
+
except metadata.PackageNotFoundError:
|
|
172
|
+
return f"{name}=absent"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def engine_identity(
|
|
176
|
+
cache_version: str,
|
|
177
|
+
*,
|
|
178
|
+
package_root: Path | None = None,
|
|
179
|
+
parsers: str | None = None,
|
|
180
|
+
version: str | None = None,
|
|
181
|
+
max_files: int = MAX_ENGINE_FILES,
|
|
182
|
+
max_file_bytes: int = MAX_ENGINE_FILE_BYTES,
|
|
183
|
+
max_total_bytes: int = MAX_ENGINE_TOTAL_BYTES,
|
|
184
|
+
) -> dict[str, str]:
|
|
185
|
+
"""Return a public, path-free identity for trusted packaged engine files."""
|
|
186
|
+
if isinstance(max_file_bytes, bool) or max_file_bytes <= 0:
|
|
187
|
+
raise EngineIdentityError("engine_file_too_large")
|
|
188
|
+
if isinstance(max_total_bytes, bool) or max_total_bytes <= 0:
|
|
189
|
+
raise EngineIdentityError("engine_total_too_large")
|
|
190
|
+
if not isinstance(cache_version, str) or not cache_version:
|
|
191
|
+
raise EngineIdentityError("engine_cache_version_invalid")
|
|
192
|
+
|
|
193
|
+
if version is None:
|
|
194
|
+
from . import __version__
|
|
195
|
+
|
|
196
|
+
version = __version__
|
|
197
|
+
if not isinstance(version, str) or not version:
|
|
198
|
+
raise EngineIdentityError("engine_version_invalid")
|
|
199
|
+
|
|
200
|
+
if parsers is None:
|
|
201
|
+
parsers = parser_inventory()
|
|
202
|
+
if not isinstance(parsers, str) or not parsers:
|
|
203
|
+
raise EngineIdentityError("engine_parsers_invalid")
|
|
204
|
+
|
|
205
|
+
candidate_root = package_root if package_root is not None else Path(__file__).parent
|
|
206
|
+
try:
|
|
207
|
+
root = candidate_root.resolve(strict=True)
|
|
208
|
+
except OSError as exc:
|
|
209
|
+
raise EngineIdentityError("engine_root_invalid") from exc
|
|
210
|
+
paths = _collect_engine_files(root, max_files=max_files)
|
|
211
|
+
|
|
212
|
+
header = {
|
|
213
|
+
"cache_version": cache_version,
|
|
214
|
+
"parsers": parsers,
|
|
215
|
+
"schema_version": ENGINE_SCHEMA_VERSION,
|
|
216
|
+
"version": version,
|
|
217
|
+
}
|
|
218
|
+
digest = hashlib.sha256()
|
|
219
|
+
digest.update(json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8"))
|
|
220
|
+
total_bytes = 0
|
|
221
|
+
inventory: list[tuple[str, Path]] = [(_contained_relative(path, root), path) for path in paths]
|
|
222
|
+
for relative, path in sorted(inventory, key=lambda item: item[0]):
|
|
223
|
+
try:
|
|
224
|
+
data = _read_stable_file(path, root, max_file_bytes)
|
|
225
|
+
except EngineIdentityError:
|
|
226
|
+
raise
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
raise EngineIdentityError("engine_file_unreadable") from exc
|
|
229
|
+
total_bytes += len(data)
|
|
230
|
+
if total_bytes > max_total_bytes:
|
|
231
|
+
raise EngineIdentityError("engine_total_too_large")
|
|
232
|
+
encoded_name = relative.encode("utf-8")
|
|
233
|
+
digest.update(len(encoded_name).to_bytes(4, "big"))
|
|
234
|
+
digest.update(encoded_name)
|
|
235
|
+
digest.update(len(data).to_bytes(8, "big"))
|
|
236
|
+
digest.update(data)
|
|
237
|
+
|
|
238
|
+
return {**header, "fingerprint": digest.hexdigest()}
|
graphite/export/html.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Self-contained interactive HTML graph viewer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..io import atomic_write_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8">
|
|
17
|
+
<title>Graphite — {{title}}</title>
|
|
18
|
+
<style>
|
|
19
|
+
:root { color-scheme: light dark; }
|
|
20
|
+
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; overflow: hidden; background: #0f172a; color: #e2e8f0; }
|
|
21
|
+
#toolbar { position: fixed; top: 12px; left: 12px; z-index: 10; display: flex; gap: 8px; }
|
|
22
|
+
#toolbar button, #toolbar label { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
|
23
|
+
#toolbar input { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 6px 10px; border-radius: 6px; width: 180px; }
|
|
24
|
+
#info { position: fixed; top: 12px; right: 12px; z-index: 10; background: #1e293b; border: 1px solid #334155; padding: 12px; border-radius: 8px; max-width: 320px; font-size: 13px; }
|
|
25
|
+
#info h3 { margin: 0 0 6px; font-size: 14px; }
|
|
26
|
+
#info p { margin: 4px 0; }
|
|
27
|
+
canvas { display: block; cursor: grab; }
|
|
28
|
+
canvas:active { cursor: grabbing; }
|
|
29
|
+
#tooltip { position: fixed; pointer-events: none; background: #020617; border: 1px solid #334155; padding: 6px 8px; border-radius: 4px; font-size: 12px; z-index: 20; display: none; }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<div id="toolbar">
|
|
34
|
+
<input id="search" placeholder="Find node..." />
|
|
35
|
+
<button id="reset">Reset view</button>
|
|
36
|
+
<button id="pause">Pause sim</button>
|
|
37
|
+
<label><input type="checkbox" id="labels" checked /> Labels</label>
|
|
38
|
+
</div>
|
|
39
|
+
<div id="info">
|
|
40
|
+
<h3>Graphite</h3>
|
|
41
|
+
<p>{{node_count}} nodes · {{edge_count}} edges · {{cluster_count}} clusters</p>
|
|
42
|
+
<p id="selection">Hover or click a node.</p>
|
|
43
|
+
</div>
|
|
44
|
+
<div id="tooltip"></div>
|
|
45
|
+
<canvas id="canvas"></canvas>
|
|
46
|
+
<script>
|
|
47
|
+
const DATA = {{data}};
|
|
48
|
+
const nodes = DATA.nodes.map((n, i) => ({
|
|
49
|
+
id: n.id,
|
|
50
|
+
label: n.name || n.id,
|
|
51
|
+
kind: n.kind || 'unknown',
|
|
52
|
+
cluster: DATA.clusters.findIndex(c => c.members.includes(n.id)),
|
|
53
|
+
x: Math.random() * 800 - 400,
|
|
54
|
+
y: Math.random() * 600 - 300,
|
|
55
|
+
vx: 0, vy: 0,
|
|
56
|
+
...n
|
|
57
|
+
}));
|
|
58
|
+
const edges = DATA.edges.map(e => ({ source: e.source, target: e.target, ...e }));
|
|
59
|
+
const clusters = DATA.clusters;
|
|
60
|
+
const palette = ['#f87171','#fb923c','#facc15','#4ade80','#22d3ee','#818cf8','#c084fc','#f472b6'];
|
|
61
|
+
const canvas = document.getElementById('canvas');
|
|
62
|
+
const ctx = canvas.getContext('2d');
|
|
63
|
+
let width, height, transform = {x:0, y:0, k:1}, dragging = null, hovered = null, paused = false, showLabels = true;
|
|
64
|
+
|
|
65
|
+
function resize() { width = canvas.width = window.innerWidth; height = canvas.height = window.innerHeight; }
|
|
66
|
+
window.addEventListener('resize', resize); resize();
|
|
67
|
+
|
|
68
|
+
function clusterColor(c) { return palette[c % palette.length] || '#94a3b8'; }
|
|
69
|
+
|
|
70
|
+
function step() {
|
|
71
|
+
if (!paused) {
|
|
72
|
+
// Repulsion
|
|
73
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
74
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
75
|
+
const a = nodes[i], b = nodes[j];
|
|
76
|
+
let dx = a.x - b.x, dy = a.y - b.y;
|
|
77
|
+
let d = Math.sqrt(dx*dx + dy*dy) || 1;
|
|
78
|
+
const f = 120 / (d * d);
|
|
79
|
+
const fx = (dx / d) * f, fy = (dy / d) * f;
|
|
80
|
+
a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Attraction along edges
|
|
84
|
+
edges.forEach(e => {
|
|
85
|
+
const a = nodes.find(n => n.id === e.source);
|
|
86
|
+
const b = nodes.find(n => n.id === e.target);
|
|
87
|
+
if (!a || !b) return;
|
|
88
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
89
|
+
let d = Math.sqrt(dx*dx + dy*dy) || 1;
|
|
90
|
+
const f = d * 0.0003;
|
|
91
|
+
const fx = (dx / d) * f, fy = (dy / d) * f;
|
|
92
|
+
a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
|
|
93
|
+
});
|
|
94
|
+
// Center gravity + damp
|
|
95
|
+
nodes.forEach(n => {
|
|
96
|
+
n.vx -= n.x * 0.00005;
|
|
97
|
+
n.vy -= n.y * 0.00005;
|
|
98
|
+
n.vx *= 0.92; n.vy *= 0.92;
|
|
99
|
+
n.x += n.vx; n.y += n.vy;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
render();
|
|
103
|
+
requestAnimationFrame(step);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function render() {
|
|
107
|
+
ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, width, height);
|
|
108
|
+
ctx.save();
|
|
109
|
+
ctx.translate(transform.x + width/2, transform.y + height/2);
|
|
110
|
+
ctx.scale(transform.k, transform.k);
|
|
111
|
+
// Edges
|
|
112
|
+
ctx.strokeStyle = 'rgba(148,163,184,0.25)'; ctx.lineWidth = 1 / transform.k;
|
|
113
|
+
edges.forEach(e => {
|
|
114
|
+
const a = nodes.find(n => n.id === e.source);
|
|
115
|
+
const b = nodes.find(n => n.id === e.target);
|
|
116
|
+
if (!a || !b) return;
|
|
117
|
+
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
|
118
|
+
});
|
|
119
|
+
// Nodes
|
|
120
|
+
nodes.forEach(n => {
|
|
121
|
+
const r = n.kind === 'file' ? 6 : 4;
|
|
122
|
+
ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
|
123
|
+
ctx.fillStyle = clusterColor(n.cluster);
|
|
124
|
+
ctx.fill();
|
|
125
|
+
if (showLabels && (n.kind === 'file' || hovered === n)) {
|
|
126
|
+
ctx.fillStyle = '#e2e8f0'; ctx.font = `${12/transform.k}px sans-serif`;
|
|
127
|
+
ctx.fillText(n.label, n.x + r + 2, n.y + 3);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
ctx.restore();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function worldPos(evt) {
|
|
134
|
+
return {
|
|
135
|
+
x: (evt.clientX - transform.x - width/2) / transform.k,
|
|
136
|
+
y: (evt.clientY - transform.y - height/2) / transform.k
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
canvas.addEventListener('mousedown', e => {
|
|
141
|
+
const p = worldPos(e);
|
|
142
|
+
hovered = nodes.find(n => Math.hypot(n.x - p.x, n.y - p.y) < 10) || null;
|
|
143
|
+
dragging = hovered;
|
|
144
|
+
});
|
|
145
|
+
canvas.addEventListener('mousemove', e => {
|
|
146
|
+
const p = worldPos(e);
|
|
147
|
+
const near = nodes.find(n => Math.hypot(n.x - p.x, n.y - p.y) < 10) || null;
|
|
148
|
+
hovered = near;
|
|
149
|
+
const tt = document.getElementById('tooltip');
|
|
150
|
+
if (near) {
|
|
151
|
+
tt.style.display = 'block'; tt.style.left = (e.clientX + 12) + 'px'; tt.style.top = (e.clientY + 12) + 'px';
|
|
152
|
+
tt.textContent = `${near.label} (${near.kind})`;
|
|
153
|
+
} else { tt.style.display = 'none'; }
|
|
154
|
+
if (dragging) { dragging.x = p.x; dragging.y = p.y; dragging.vx = 0; dragging.vy = 0; }
|
|
155
|
+
updateSelection();
|
|
156
|
+
});
|
|
157
|
+
canvas.addEventListener('mouseup', () => dragging = null);
|
|
158
|
+
canvas.addEventListener('wheel', e => {
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
161
|
+
transform.k *= factor;
|
|
162
|
+
render();
|
|
163
|
+
}, {passive: false});
|
|
164
|
+
|
|
165
|
+
let panning = false, lastPan = {x:0,y:0};
|
|
166
|
+
canvas.addEventListener('contextmenu', e => { e.preventDefault(); panning = true; lastPan = {x:e.clientX, y:e.clientY}; });
|
|
167
|
+
window.addEventListener('mouseup', () => panning = false);
|
|
168
|
+
window.addEventListener('mousemove', e => {
|
|
169
|
+
if (!panning) return;
|
|
170
|
+
transform.x += e.clientX - lastPan.x; transform.y += e.clientY - lastPan.y;
|
|
171
|
+
lastPan = {x:e.clientX, y:e.clientY}; render();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
document.getElementById('reset').onclick = () => { transform = {x:0,y:0,k:1}; };
|
|
175
|
+
document.getElementById('pause').onclick = () => { paused = !paused; };
|
|
176
|
+
document.getElementById('labels').onchange = e => { showLabels = e.target.checked; };
|
|
177
|
+
document.getElementById('search').addEventListener('input', e => {
|
|
178
|
+
const q = e.target.value.toLowerCase();
|
|
179
|
+
const match = nodes.find(n => n.label.toLowerCase().includes(q));
|
|
180
|
+
if (match) { hovered = match; updateSelection(); }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function updateSelection() {
|
|
184
|
+
const el = document.getElementById('selection');
|
|
185
|
+
if (!hovered) { el.textContent = 'Hover or click a node.'; return; }
|
|
186
|
+
const incoming = edges.filter(e => e.target === hovered.id).length;
|
|
187
|
+
const outgoing = edges.filter(e => e.source === hovered.id).length;
|
|
188
|
+
const cluster = clusters[hovered.cluster];
|
|
189
|
+
const label = document.createElement('b');
|
|
190
|
+
label.textContent = hovered.label;
|
|
191
|
+
el.replaceChildren(
|
|
192
|
+
label,
|
|
193
|
+
document.createTextNode(` (${hovered.kind})`),
|
|
194
|
+
document.createElement('br'),
|
|
195
|
+
document.createTextNode(`cluster: ${cluster ? cluster.labels.join(', ') : 'none'}`),
|
|
196
|
+
document.createElement('br'),
|
|
197
|
+
document.createTextNode(`in: ${incoming} / out: ${outgoing}`)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
step();
|
|
202
|
+
</script>
|
|
203
|
+
</body>
|
|
204
|
+
</html>
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _json_for_script(value: Any) -> str:
|
|
209
|
+
return (
|
|
210
|
+
json.dumps(value, ensure_ascii=False)
|
|
211
|
+
.replace("&", r"\u0026")
|
|
212
|
+
.replace("<", r"\u003c")
|
|
213
|
+
.replace(">", r"\u003e")
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def to_html(
|
|
218
|
+
graph_data: dict[str, Any],
|
|
219
|
+
clusters: dict[str, Any],
|
|
220
|
+
analysis: dict[str, Any],
|
|
221
|
+
manifest: dict[str, Any],
|
|
222
|
+
output_path: Path,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Write a self-contained interactive HTML viewer."""
|
|
225
|
+
bundle = {
|
|
226
|
+
"nodes": graph_data.get("nodes", []),
|
|
227
|
+
"edges": graph_data.get("edges", []),
|
|
228
|
+
"clusters": clusters.get("clusters", []),
|
|
229
|
+
"analysis": analysis,
|
|
230
|
+
}
|
|
231
|
+
substitutions = {
|
|
232
|
+
"data": _json_for_script(bundle),
|
|
233
|
+
"title": html.escape(str(manifest.get("root", "codebase"))),
|
|
234
|
+
"node_count": str(graph_data.get("metadata", {}).get("node_count", 0)),
|
|
235
|
+
"edge_count": str(graph_data.get("metadata", {}).get("edge_count", 0)),
|
|
236
|
+
"cluster_count": str(clusters.get("count", 0)),
|
|
237
|
+
}
|
|
238
|
+
document = re.sub(
|
|
239
|
+
r"\{\{(data|title|node_count|edge_count|cluster_count)\}\}",
|
|
240
|
+
lambda match: substitutions[match.group(1)],
|
|
241
|
+
_HTML_TEMPLATE,
|
|
242
|
+
)
|
|
243
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
atomic_write_text(output_path, document)
|
graphite/export/json.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""JSON export of the full graph bundle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..io import atomic_write_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_bundle(
|
|
11
|
+
graph_data: dict[str, Any],
|
|
12
|
+
clusters: dict[str, Any],
|
|
13
|
+
analysis: dict[str, Any],
|
|
14
|
+
manifest: dict[str, Any],
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
"""Build the public graph JSON bundle used by viewers and tools."""
|
|
17
|
+
return {
|
|
18
|
+
"nodes": graph_data.get("nodes", []),
|
|
19
|
+
"edges": graph_data.get("edges", []),
|
|
20
|
+
"clusters": clusters.get("clusters", []),
|
|
21
|
+
"analysis": analysis,
|
|
22
|
+
"metadata": {
|
|
23
|
+
"node_count": graph_data.get("metadata", {}).get("node_count", 0),
|
|
24
|
+
"edge_count": graph_data.get("metadata", {}).get("edge_count", 0),
|
|
25
|
+
"community_count": clusters.get("count", 0),
|
|
26
|
+
**{k: v for k, v in manifest.items() if k not in ("files",)},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_json(
|
|
32
|
+
graph_data: dict[str, Any],
|
|
33
|
+
clusters: dict[str, Any],
|
|
34
|
+
analysis: dict[str, Any],
|
|
35
|
+
manifest: dict[str, Any],
|
|
36
|
+
output_path: Path,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Write the bundled graph JSON used by the HTML viewer and external tools."""
|
|
39
|
+
atomic_write_json(output_path, build_bundle(graph_data, clusters, analysis, manifest), indent=2)
|
graphite/export/md.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Markdown report generation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..io import atomic_write_text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_markdown(
|
|
11
|
+
graph_data: dict[str, Any],
|
|
12
|
+
clusters: dict[str, Any],
|
|
13
|
+
analysis: dict[str, Any],
|
|
14
|
+
manifest: dict[str, Any],
|
|
15
|
+
output_path: Path,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Write a human-readable Markdown audit report."""
|
|
18
|
+
lines: list[str] = []
|
|
19
|
+
lines.append(f"# Graphite Report: `{manifest.get('root', 'codebase')}`")
|
|
20
|
+
lines.append("")
|
|
21
|
+
lines.append("## Summary")
|
|
22
|
+
lines.append(f"- **Files scanned:** {manifest.get('file_count', 0)}")
|
|
23
|
+
lines.append(f"- **Total nodes:** {graph_data.get('metadata', {}).get('node_count', 0)}")
|
|
24
|
+
lines.append(f"- **Total edges:** {graph_data.get('metadata', {}).get('edge_count', 0)}")
|
|
25
|
+
lines.append(f"- **Communities detected:** {clusters.get('count', 0)}")
|
|
26
|
+
lines.append("")
|
|
27
|
+
|
|
28
|
+
lines.append("## Top Files by Connectivity")
|
|
29
|
+
for item in analysis.get("top_files_by_links", [])[:10]:
|
|
30
|
+
lines.append(f"- `{item['name']}` — degree {item['degree']}")
|
|
31
|
+
lines.append("")
|
|
32
|
+
|
|
33
|
+
lines.append("## God Nodes")
|
|
34
|
+
for item in analysis.get("god_nodes", [])[:10]:
|
|
35
|
+
lines.append(
|
|
36
|
+
f"- `{item['name']}` ({item['kind']}) — "
|
|
37
|
+
f"in {item['in_degree']} / out {item['out_degree']}"
|
|
38
|
+
)
|
|
39
|
+
lines.append("")
|
|
40
|
+
|
|
41
|
+
lines.append("## Entry Points")
|
|
42
|
+
for item in analysis.get("entry_points", [])[:10]:
|
|
43
|
+
lines.append(
|
|
44
|
+
f"- `{item['name']}` — out {item['out_degree']} / "
|
|
45
|
+
f"in {item['in_degree']} (ratio {item['ratio']:.1f})"
|
|
46
|
+
)
|
|
47
|
+
lines.append("")
|
|
48
|
+
|
|
49
|
+
lines.append("## Surprising Connections")
|
|
50
|
+
for item in analysis.get("surprising_connections", [])[:10]:
|
|
51
|
+
lines.append(f"- `{item['source']}` -> `{item['target']}` ({item['relation']})")
|
|
52
|
+
lines.append("")
|
|
53
|
+
|
|
54
|
+
lines.append("## Communities")
|
|
55
|
+
for c in clusters.get("clusters", [])[:20]:
|
|
56
|
+
labels = ", ".join(c.get("labels", [])) or "mixed"
|
|
57
|
+
lines.append(f"### Community {c['id']} ({labels})")
|
|
58
|
+
lines.append(
|
|
59
|
+
f"- size: {c['size']} (files: {c['file_count']}, "
|
|
60
|
+
f"functions: {c['function_count']}, classes: {c['class_count']})"
|
|
61
|
+
)
|
|
62
|
+
file_members = [m for m in c.get("members", []) if any(chr.isalnum() for chr in m)]
|
|
63
|
+
suffix = " ..." if len(file_members) > 15 else ""
|
|
64
|
+
lines.append(f"- members: {', '.join(f'`{m}`' for m in file_members[:15])}{suffix}")
|
|
65
|
+
lines.append("")
|
|
66
|
+
|
|
67
|
+
atomic_write_text(output_path, "\n".join(lines))
|
|
68
|
+
|