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
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
|