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
graphite/freshness.py ADDED
@@ -0,0 +1,127 @@
1
+ """Graph artifact freshness checks shared by CLI and diagnostics."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .config import Config
9
+ from .engine_identity import EngineIdentityError, engine_identity
10
+ from .ingest import collect_files
11
+
12
+ _DOCTOR_FILE_LIMIT = 10_000
13
+
14
+
15
+ class FreshnessLimitError(RuntimeError):
16
+ """A stable, path-free freshness resource-limit failure."""
17
+
18
+
19
+ def _manifest_size(path: Path) -> int:
20
+ return path.stat().st_size
21
+
22
+
23
+ def _read_manifest_bounded(path: Path, limit: int) -> str:
24
+ try:
25
+ with path.open("rb") as handle:
26
+ data = handle.read(limit + 1)
27
+ except OSError as exc:
28
+ raise FreshnessLimitError("freshness manifest limit check failed") from exc
29
+ if len(data) > limit:
30
+ raise FreshnessLimitError("freshness manifest limit exceeded")
31
+ try:
32
+ return data.decode("utf-8")
33
+ except UnicodeDecodeError as exc:
34
+ raise FreshnessLimitError("freshness manifest is not valid UTF-8") from exc
35
+
36
+
37
+ def manifest_map(manifest: dict[str, Any]) -> dict[str, str]:
38
+ return {f["rel_path"]: f.get("hash", "") for f in manifest.get("files", []) if "rel_path" in f}
39
+
40
+
41
+ def graph_engine_changed(cfg: Config, current_engine: dict[str, Any]) -> bool:
42
+ """True when a built graph records an engine identity other than the current one.
43
+
44
+ Narrow by design, for the daemon (#18): it reads the manifest and nothing
45
+ else. `check_graph_freshness` re-scans the whole repo, which is far too
46
+ expensive to run per supervised project on every discovery cycle.
47
+
48
+ A missing, unreadable, or engine-less manifest is deliberately NOT an engine
49
+ change. Those are the initial-build path's job; reporting them here would
50
+ queue a rebuild every cycle, forever, for any project without a graph.
51
+
52
+ The comparison is on the WHOLE identity, not the fingerprint alone, so a
53
+ `version` or `cache_version` bump counts as a change even when the packaged
54
+ engine files are byte-identical. That is deliberate: it is exactly what
55
+ `check_graph_freshness` treats as `engine_changed`, and the daemon reporting
56
+ a different notion of staleness than `graphite check` is how the two drift
57
+ apart.
58
+ """
59
+ manifest_path = cfg.output_dir / ".graphite_manifest.json"
60
+ try:
61
+ previous = json.loads(manifest_path.read_text(encoding="utf-8"))
62
+ except (OSError, json.JSONDecodeError, ValueError):
63
+ return False
64
+ if not isinstance(previous, dict):
65
+ return False
66
+ recorded = previous.get("engine")
67
+ if not recorded:
68
+ return False
69
+ return bool(recorded != current_engine)
70
+
71
+
72
+ def check_graph_freshness(
73
+ root: Path,
74
+ cfg: Config,
75
+ *,
76
+ max_manifest_bytes: int | None = None,
77
+ ignore_engine: bool = False,
78
+ ) -> dict[str, Any]:
79
+ manifest_path = cfg.output_dir / ".graphite_manifest.json"
80
+ if not manifest_path.exists():
81
+ return {"stale": True, "reason": "missing manifest", "manifest": manifest_path.as_posix(), "added": [], "changed": [], "removed": []}
82
+ try:
83
+ if max_manifest_bytes is None:
84
+ manifest_text = manifest_path.read_text(encoding="utf-8")
85
+ else:
86
+ if max_manifest_bytes <= 0:
87
+ raise FreshnessLimitError("freshness manifest limit exceeded")
88
+ try:
89
+ size = _manifest_size(manifest_path)
90
+ except OSError as exc:
91
+ raise FreshnessLimitError("freshness manifest limit check failed") from exc
92
+ if size > max_manifest_bytes:
93
+ raise FreshnessLimitError("freshness manifest limit exceeded")
94
+ manifest_text = _read_manifest_bounded(manifest_path, max_manifest_bytes)
95
+ previous = json.loads(manifest_text)
96
+ except (OSError, json.JSONDecodeError) as exc:
97
+ return {"stale": True, "reason": f"unreadable manifest: {exc}", "added": [], "changed": [], "removed": []}
98
+ old = manifest_map(previous)
99
+ if max_manifest_bytes is not None and len(old) > _DOCTOR_FILE_LIMIT:
100
+ raise FreshnessLimitError("freshness file limit exceeded")
101
+ entries = collect_files(root, cfg)
102
+ if max_manifest_bytes is not None and len(entries) > _DOCTOR_FILE_LIMIT:
103
+ raise FreshnessLimitError("freshness file limit exceeded")
104
+ if not ignore_engine:
105
+ try:
106
+ current_engine = engine_identity(cfg.cache_version)
107
+ except EngineIdentityError:
108
+ return {
109
+ "stale": True,
110
+ "reason": "engine_identity_unavailable",
111
+ "added": [],
112
+ "changed": [],
113
+ "removed": [],
114
+ }
115
+ if previous.get("engine") != current_engine:
116
+ return {
117
+ "stale": True,
118
+ "reason": "engine_changed",
119
+ "added": [],
120
+ "changed": [],
121
+ "removed": [],
122
+ }
123
+ current = {e.rel_path: e.content_hash for e in entries}
124
+ added = sorted(set(current) - set(old))
125
+ removed = sorted(set(old) - set(current))
126
+ changed = sorted(p for p in set(current).intersection(old) if current[p] != old[p])
127
+ return {"stale": bool(added or removed or changed), "file_count": len(current), "manifest_file_count": len(old), "added": added, "changed": changed, "removed": removed}
graphite/git.py ADDED
@@ -0,0 +1,406 @@
1
+ """Hardened Git process execution for Graphite."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import threading
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Sequence
11
+
12
+
13
+ DEFAULT_GIT_STDOUT_MAX_BYTES = 16 * 1024 * 1024
14
+ _MINIMUM_GIT_VERSION = (2, 38, 0)
15
+ _GIT_VERSION_TIMEOUT_SECONDS = 2.0
16
+ _GIT_VERSION_STDOUT_MAX_BYTES = 256
17
+ _READ_CHUNK_BYTES = 64 * 1024
18
+ _PROCESS_CLEANUP_TIMEOUT_SECONDS = 0.1
19
+
20
+ #: Sanitized operator-facing message per version-check failure `reason`.
21
+ #:
22
+ #: Every value is a module constant and none is derived from Git's output, so
23
+ #: looking a message up here can never leak a checkout path -- which is what
24
+ #: lets `review` surface an accurate cause without abandoning the sanitising
25
+ #: boundary it deliberately keeps between Git's text and a user's terminal.
26
+ #:
27
+ #: The table exists because there was only ever ONE message for three unrelated
28
+ #: conditions, and it asserted a version requirement in the two cases where no
29
+ #: version had been read. An operator acting on "Git 2.38 or newer is required"
30
+ #: after a probe timeout goes and upgrades a working Git.
31
+ _GIT_VERSION_FAILURE_MESSAGES = {
32
+ "too_old": "Git 2.38 or newer is required",
33
+ "unreadable": "Git version output could not be read",
34
+ "probe_GitTimeoutError": "Git version check timed out",
35
+ "probe_GitLaunchError": "Git version check could not be launched",
36
+ "probe_GitOutputLimitError": "Git version check output limit exceeded",
37
+ }
38
+ #: For a reason this table does not know. Deliberately admits ignorance rather
39
+ #: than falling back to the version sentence, which is the failure being fixed:
40
+ #: a new probe failure must not silently inherit "upgrade your Git".
41
+ _GIT_VERSION_FAILURE_FALLBACK = "Git version could not be verified"
42
+
43
+
44
+ def git_version_failure_message(reason: str | None) -> str:
45
+ """Map a version-check `reason` to its sanitized operator-facing message."""
46
+ return _GIT_VERSION_FAILURE_MESSAGES.get(reason, _GIT_VERSION_FAILURE_FALLBACK)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class GitResult:
51
+ """Bounded output from one Git process."""
52
+
53
+ returncode: int
54
+ stdout: bytes
55
+
56
+
57
+ class GitError(RuntimeError):
58
+ """Base class for sanitized Git execution failures.
59
+
60
+ The MESSAGE is a path-free constant, deliberately: Git's own text routinely
61
+ embeds a checkout path, which is why every raise site here is re-raised
62
+ `from None` upstream. That sanitising works, and it also left `git_unavailable`
63
+ carrying one bit of information -- the class name -- for faults with entirely
64
+ different fixes.
65
+
66
+ `diagnostic()` is the seam. It carries the step that failed and the OS error
67
+ number beside the message rather than inside it, so `str(exc)` stays exactly
68
+ the constant its tests assert. The vocabulary is fixed tokens and integers;
69
+ an `OSError`'s `.filename` is a path and must never reach it.
70
+ """
71
+
72
+ reason: str | None = None
73
+ os_error: int | None = None
74
+
75
+ def diagnostic(self) -> str:
76
+ """Path-free identity: which class, which step, which errno."""
77
+ parts = [type(self).__name__]
78
+ if self.reason:
79
+ parts.append(self.reason)
80
+ if self.os_error is not None:
81
+ parts.append(f"os={self.os_error}")
82
+ return ":".join(parts)
83
+
84
+
85
+ class _StepGitError(GitError):
86
+ """A `GitError` that knows which step raised it and what the OS said."""
87
+
88
+ def __init__(
89
+ self, message: str, *, reason: str | None = None, os_error: int | None = None
90
+ ) -> None:
91
+ super().__init__(message)
92
+ self.reason = reason
93
+ # `None` stays `None`. Four launch sites hold no `OSError` at all, and an
94
+ # `os=None` would read as an errno that simply is not one.
95
+ self.os_error = os_error
96
+
97
+
98
+ class GitUnavailableError(GitError):
99
+ """Raised when no trusted external Git executable is available."""
100
+
101
+
102
+ class GitTimeoutError(GitError):
103
+ """Raised when a Git command exceeds its deadline."""
104
+
105
+
106
+ class GitLaunchError(_StepGitError):
107
+ """Raised when the trusted Git process cannot be launched.
108
+
109
+ Seven distinct sites raise this inside `_run_bounded` alone -- root
110
+ resolution, spawn, wait, a missing stdout pipe, a reader thread that would
111
+ not join, a read that failed, and a close that failed. They are the reason
112
+ graphite#37's single sighting could not be taken past the bucket name, so
113
+ each one passes its own `reason`.
114
+ """
115
+
116
+
117
+ class GitOutputLimitError(GitError):
118
+ """Raised when Git stdout exceeds the configured bound."""
119
+
120
+
121
+ class GitUnsupportedVersionError(_StepGitError):
122
+ """Raised when Git's protected command configuration is unavailable.
123
+
124
+ Three unrelated conditions land here, and the failure this class is NAMED
125
+ for is the rarest of them. Git really being older than 2.38 is a permanent,
126
+ obvious, one-machine fact. The other two are transient: the `--version`
127
+ probe timed out inside `_GIT_VERSION_TIMEOUT_SECONDS`, or it could not be
128
+ launched at all. Both then widen into `git_unavailable` upstream, which is
129
+ how graphite#37 got a CI log saying an installed, working Git was
130
+ unavailable.
131
+
132
+ The probe's budget is two seconds for one process spawn, and `_version` is
133
+ cached per RUNNER -- `collect_diff_evidence`, `create_task_worktree` and
134
+ `accept` each build their own -- so a single routing operation pays that
135
+ race several times over. On a loaded Windows runner that is a plausible
136
+ intermittent failure, and `reason` is what lets the next sighting say so
137
+ instead of blaming the Git version.
138
+ """
139
+
140
+
141
+ class GitRunner:
142
+ """Run Git from one canonical external executable in an isolated environment."""
143
+
144
+ def __init__(self, project_root: Path, *, platform_name: str | None = None) -> None:
145
+ try:
146
+ self.root = project_root.resolve()
147
+ except OSError as exc:
148
+ raise GitLaunchError(
149
+ "unable to run Git command", reason="resolve", os_error=exc.errno
150
+ ) from exc
151
+ self.executable = _resolve_git_executable(
152
+ self.root, platform_name=platform_name
153
+ )
154
+ self._environment = _isolated_environment()
155
+ self._version: tuple[int, int, int] | None = None
156
+ self._version_lock = threading.Lock()
157
+
158
+ def run(
159
+ self,
160
+ arguments: Sequence[str],
161
+ *,
162
+ timeout_seconds: float,
163
+ max_stdout_bytes: int = DEFAULT_GIT_STDOUT_MAX_BYTES,
164
+ ) -> GitResult:
165
+ """Run one bounded Git command with fixed process security controls."""
166
+ if max_stdout_bytes <= 0:
167
+ raise GitOutputLimitError("Git command output limit exceeded")
168
+ self._ensure_supported_version()
169
+ command = [
170
+ str(self.executable),
171
+ "--no-optional-locks",
172
+ "-c",
173
+ "core.fsmonitor=false",
174
+ "-c",
175
+ f"safe.directory={self.root}",
176
+ *arguments,
177
+ ]
178
+ return self._run_bounded(
179
+ command,
180
+ timeout_seconds=timeout_seconds,
181
+ max_stdout_bytes=max_stdout_bytes,
182
+ )
183
+
184
+ def _ensure_supported_version(self) -> None:
185
+ if self._version is not None:
186
+ return
187
+ with self._version_lock:
188
+ if self._version is not None:
189
+ return
190
+ try:
191
+ result = self._run_bounded(
192
+ [str(self.executable), "--version"],
193
+ timeout_seconds=_GIT_VERSION_TIMEOUT_SECONDS,
194
+ max_stdout_bytes=_GIT_VERSION_STDOUT_MAX_BYTES,
195
+ )
196
+ except (GitTimeoutError, GitLaunchError, GitOutputLimitError) as exc:
197
+ # The probe never returned a version, so nothing here is
198
+ # evidence about Git's version -- carry what actually happened.
199
+ # `os_error` rides along when the inner failure had one; the
200
+ # class name alone cannot separate "spawn took longer than two
201
+ # seconds" from "spawn was refused".
202
+ probe_reason = f"probe_{type(exc).__name__}"
203
+ raise GitUnsupportedVersionError(
204
+ git_version_failure_message(probe_reason),
205
+ reason=probe_reason,
206
+ os_error=getattr(exc, "os_error", None),
207
+ ) from exc
208
+ version = _parse_git_version(result.stdout) if result.returncode == 0 else None
209
+ if version is None:
210
+ # Ran, and said something this parser does not recognise. Not the
211
+ # same finding as a version that parsed and was too low.
212
+ raise GitUnsupportedVersionError(
213
+ git_version_failure_message("unreadable"), reason="unreadable"
214
+ )
215
+ if version < _MINIMUM_GIT_VERSION:
216
+ raise GitUnsupportedVersionError(
217
+ git_version_failure_message("too_old"), reason="too_old"
218
+ )
219
+ self._version = version
220
+
221
+ def _run_bounded(
222
+ self,
223
+ command: list[str],
224
+ *,
225
+ timeout_seconds: float,
226
+ max_stdout_bytes: int,
227
+ ) -> GitResult:
228
+ try:
229
+ process = subprocess.Popen(
230
+ command,
231
+ cwd=self.root,
232
+ stdin=subprocess.DEVNULL,
233
+ stdout=subprocess.PIPE,
234
+ stderr=subprocess.DEVNULL,
235
+ shell=False,
236
+ env=self._environment,
237
+ )
238
+ except FileNotFoundError as exc:
239
+ raise GitUnavailableError("Git executable was not found") from exc
240
+ except subprocess.TimeoutExpired as exc:
241
+ raise GitTimeoutError("Git command timeout") from exc
242
+ except OSError as exc:
243
+ raise GitLaunchError(
244
+ "unable to run Git command", reason="popen", os_error=exc.errno
245
+ ) from exc
246
+
247
+ if process.stdout is None:
248
+ _bounded_cleanup(process)
249
+ raise GitLaunchError("unable to run Git command", reason="no_stdout")
250
+
251
+ stdout = bytearray()
252
+ overflow = threading.Event()
253
+ read_failed = threading.Event()
254
+ # The reader runs on its own thread, so its exception cannot propagate --
255
+ # it can only leave a trace here. An `Event` alone records THAT the read
256
+ # failed and never why, which is how a bounded read fault and a pipe torn
257
+ # down by the peer became the same log line. One slot, written once,
258
+ # before the flag any waiter keys on.
259
+ read_error: list[int | None] = []
260
+
261
+ def read_stdout() -> None:
262
+ try:
263
+ while True:
264
+ chunk = process.stdout.read(_READ_CHUNK_BYTES)
265
+ if not chunk:
266
+ return
267
+ remaining = max_stdout_bytes - len(stdout)
268
+ if len(chunk) > remaining:
269
+ stdout.extend(chunk[:remaining])
270
+ overflow.set()
271
+ try:
272
+ process.kill()
273
+ except OSError:
274
+ pass
275
+ return
276
+ stdout.extend(chunk)
277
+ except OSError as exc:
278
+ read_error.append(exc.errno)
279
+ read_failed.set()
280
+
281
+ reader = threading.Thread(target=read_stdout, daemon=True)
282
+ reader.start()
283
+ try:
284
+ returncode = process.wait(timeout=timeout_seconds)
285
+ except subprocess.TimeoutExpired as exc:
286
+ _bounded_cleanup(process, reader)
287
+ raise GitTimeoutError("Git command timeout") from exc
288
+ except OSError as exc:
289
+ _bounded_cleanup(process, reader)
290
+ raise GitLaunchError(
291
+ "unable to run Git command", reason="wait", os_error=exc.errno
292
+ ) from exc
293
+
294
+ reader.join(timeout=_PROCESS_CLEANUP_TIMEOUT_SECONDS)
295
+ if reader.is_alive():
296
+ _bounded_cleanup(process, reader)
297
+ raise GitLaunchError("unable to run Git command", reason="reader_hang")
298
+ if overflow.is_set():
299
+ _bounded_cleanup(process, reader)
300
+ raise GitOutputLimitError("Git command output limit exceeded")
301
+ if read_failed.is_set():
302
+ _close_stdout(process)
303
+ raise GitLaunchError(
304
+ "unable to run Git command",
305
+ reason="read",
306
+ os_error=read_error[0] if read_error else None,
307
+ )
308
+ if not _close_stdout(process):
309
+ raise GitLaunchError("unable to run Git command", reason="close")
310
+ return GitResult(returncode=returncode, stdout=bytes(stdout))
311
+
312
+
313
+ def _parse_git_version(output: bytes) -> tuple[int, int, int] | None:
314
+ match = re.fullmatch(
315
+ rb"git version ([0-9]+)\.([0-9]+)(?:\.([0-9]+))?"
316
+ rb"(?:\.windows\.[0-9]+| \(Apple Git-[0-9A-Za-z][0-9A-Za-z.-]*\))?"
317
+ rb"\r?\n?",
318
+ output,
319
+ )
320
+ if match is None:
321
+ return None
322
+ try:
323
+ return (
324
+ int(match.group(1)),
325
+ int(match.group(2)),
326
+ int(match.group(3) or b"0"),
327
+ )
328
+ except ValueError:
329
+ return None
330
+
331
+
332
+ def _bounded_cleanup(
333
+ process: subprocess.Popen[bytes], reader: threading.Thread | None = None
334
+ ) -> bool:
335
+ """Best-effort cleanup that never waits beyond the fixed cleanup deadline."""
336
+ try:
337
+ process.kill()
338
+ except OSError:
339
+ pass
340
+ wait_confirmed = True
341
+ try:
342
+ process.wait(timeout=_PROCESS_CLEANUP_TIMEOUT_SECONDS)
343
+ except (subprocess.TimeoutExpired, OSError):
344
+ wait_confirmed = False
345
+ reader_stopped = True
346
+ if reader is not None:
347
+ reader.join(timeout=_PROCESS_CLEANUP_TIMEOUT_SECONDS)
348
+ reader_stopped = not reader.is_alive()
349
+ if not reader_stopped:
350
+ return False
351
+ return wait_confirmed and _close_stdout(process)
352
+
353
+
354
+ def _close_stdout(process: subprocess.Popen[bytes]) -> bool:
355
+ if process.stdout is None:
356
+ return True
357
+ try:
358
+ process.stdout.close()
359
+ except OSError:
360
+ return False
361
+ return True
362
+
363
+
364
+ def _resolve_git_executable(
365
+ resolved_project_root: Path, *, platform_name: str | None = None
366
+ ) -> Path:
367
+ selected_platform = os.name if platform_name is None else platform_name
368
+ executable_name = "git.exe" if selected_platform == "nt" else "git"
369
+
370
+ for raw_directory in os.environ.get("PATH", "").split(os.pathsep):
371
+ if not raw_directory:
372
+ continue
373
+ directory = Path(raw_directory)
374
+ if not directory.is_absolute():
375
+ continue
376
+ candidate = directory / executable_name
377
+ try:
378
+ if not candidate.is_file():
379
+ continue
380
+ resolved_candidate = candidate.resolve(strict=True)
381
+ if not resolved_candidate.is_file():
382
+ continue
383
+ if selected_platform != "nt" and not os.access(resolved_candidate, os.X_OK):
384
+ continue
385
+ try:
386
+ resolved_candidate.relative_to(resolved_project_root)
387
+ except ValueError:
388
+ pass
389
+ else:
390
+ continue
391
+ except OSError:
392
+ continue
393
+ return resolved_candidate
394
+
395
+ raise GitUnavailableError("Git executable was not found")
396
+
397
+
398
+ def _isolated_environment() -> dict[str, str]:
399
+ environment = {
400
+ name: value
401
+ for name, value in os.environ.items()
402
+ if not name.casefold().startswith("git_")
403
+ }
404
+ environment["GIT_OPTIONAL_LOCKS"] = "0"
405
+ environment["GIT_TERMINAL_PROMPT"] = "0"
406
+ return environment
graphite/graph.py ADDED
@@ -0,0 +1,117 @@
1
+ """Graph construction and normalization."""
2
+ from __future__ import annotations
3
+
4
+ import unicodedata
5
+ from typing import Any
6
+
7
+ import networkx as nx
8
+
9
+
10
+ def normalize_id(value: str) -> str:
11
+ """Stable, portable node ID. Same rules as extract.ast._make_id."""
12
+ text = unicodedata.normalize("NFKC", value)
13
+ cleaned = "".join(c if c.isalnum() or c in ("_", ".") else "_" for c in text)
14
+ cleaned = cleaned.strip("_.")
15
+ cleaned = cleaned.replace(".", "_")
16
+ while "__" in cleaned:
17
+ cleaned = cleaned.replace("__", "_")
18
+ return cleaned.casefold()
19
+
20
+
21
+ def edge_relations(data: dict[str, Any]) -> tuple[str, ...]:
22
+ """Every relation an edge carries.
23
+
24
+ A DiGraph holds one edge per node pair, so when edges with different
25
+ relations share a pair they are merged and `relations` lists all of them.
26
+ Read this rather than `relation` alone whenever the question is "does this
27
+ pair have relation X" -- `relation` is only the first-sorted value (#1).
28
+ """
29
+ relations = data.get("relations")
30
+ if relations:
31
+ return tuple(r for r in relations if r)
32
+ relation = data.get("relation")
33
+ return (relation,) if relation else ()
34
+
35
+
36
+ def build_graph(nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> nx.DiGraph:
37
+ """Build a normalized directed graph from extraction results."""
38
+ g = nx.DiGraph()
39
+
40
+ # Deterministic order.
41
+ sorted_nodes = sorted(nodes, key=lambda n: n.get("id", ""))
42
+ for n in sorted_nodes:
43
+ nid = normalize_id(n["id"])
44
+ attrs = dict(n)
45
+ attrs["id"] = nid
46
+ attrs["label"] = attrs.get("name", nid)
47
+ g.add_node(nid, **attrs)
48
+
49
+ sorted_edges = sorted(
50
+ edges,
51
+ key=lambda e: (e.get("source", ""), e.get("target", ""), e.get("relation", "")),
52
+ )
53
+ for e in sorted_edges:
54
+ src = normalize_id(e["source"])
55
+ tgt = normalize_id(e["target"])
56
+ if not src or not tgt or src == tgt:
57
+ continue
58
+ if not g.has_node(src):
59
+ g.add_node(src, id=src, kind="unknown", name=src, label=src)
60
+ if not g.has_node(tgt):
61
+ g.add_node(tgt, id=tgt, kind="unknown", name=tgt, label=tgt)
62
+ if g.has_edge(src, tgt):
63
+ data = g[src][tgt]
64
+ # Fold the incoming edge's own weight; a flat +1.0 discarded it
65
+ # (extraction-level _merge emits weights >= 1.0).
66
+ data["weight"] = data.get("weight", 1.0) + float(e.get("weight", 1.0) or 1.0)
67
+ # A DiGraph cannot hold parallel edges, so a second edge between the
68
+ # same pair with a DIFFERENT relation used to be dropped outright,
69
+ # losing its relation entirely (#1). Record it instead: `relation`
70
+ # keeps the first-sorted value for display, `relations` carries every
71
+ # relation this pair actually has.
72
+ relation = e.get("relation")
73
+ if relation and relation != data.get("relation"):
74
+ relations = data.setdefault("relations", [data.get("relation")])
75
+ if relation not in relations:
76
+ relations.append(relation)
77
+ else:
78
+ attrs = dict(e)
79
+ attrs["source"] = src
80
+ attrs["target"] = tgt
81
+ attrs["weight"] = attrs.get("weight", 1.0)
82
+ g.add_edge(src, tgt, **attrs)
83
+
84
+ return g
85
+
86
+
87
+ def graph_to_json(g: nx.DiGraph) -> dict[str, Any]:
88
+ """Convert networkx graph to graphify-compatible JSON."""
89
+ return {
90
+ "nodes": [
91
+ {"id": n, **{k: v for k, v in data.items() if k != "id"}}
92
+ for n, data in g.nodes(data=True)
93
+ ],
94
+ "edges": [
95
+ {
96
+ "source": u,
97
+ "target": v,
98
+ **{k: val for k, val in data.items() if k not in ("source", "target")},
99
+ }
100
+ for u, v, data in g.edges(data=True)
101
+ ],
102
+ "metadata": {
103
+ "node_count": g.number_of_nodes(),
104
+ "edge_count": g.number_of_edges(),
105
+ },
106
+ }
107
+
108
+
109
+ def graph_from_json(data: dict[str, Any]) -> nx.DiGraph:
110
+ """Load graph from JSON."""
111
+ g = nx.DiGraph()
112
+ for n in data.get("nodes", []):
113
+ nid = n.get("id")
114
+ g.add_node(nid, **n)
115
+ for e in data.get("edges", []):
116
+ g.add_edge(e["source"], e["target"], **e)
117
+ return g