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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. 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()}
@@ -0,0 +1,6 @@
1
+ """Export subpackage: JSON, HTML, Markdown, MCP."""
2
+ from .json import to_json
3
+ from .html import to_html
4
+ from .md import to_markdown
5
+
6
+ __all__ = ["to_json", "to_html", "to_markdown"]
@@ -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)
@@ -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
+
@@ -0,0 +1,4 @@
1
+ """Extraction subpackage: AST + comments + optional LLM."""
2
+ from .ast import extract_file
3
+
4
+ __all__ = ["extract_file"]