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/buildlock.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Cross-process build lock so only one builder touches a repo at a time.
|
|
2
|
+
|
|
3
|
+
The daemon was historically the sole builder per repo, serialized by its own
|
|
4
|
+
cycle, so nothing needed a lock. Git-hook-triggered builds introduce the first
|
|
5
|
+
genuine concurrency, and a manual `graphite build` during a daemon build
|
|
6
|
+
already raced before them.
|
|
7
|
+
|
|
8
|
+
Staleness is TTL-only, deliberately. `os.kill(pid, 0)` is the obvious liveness
|
|
9
|
+
idiom and is WRONG here: on Windows, Python's `os.kill` ignores signal 0 and
|
|
10
|
+
calls TerminateProcess, so the portable-looking probe would kill the very
|
|
11
|
+
process it was checking.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
import time
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Callable, Iterator
|
|
22
|
+
|
|
23
|
+
# 2x the daemon's own build_timeout_seconds default (240.0). A build still
|
|
24
|
+
# holding the lock past double the daemon's give-up point is dead by definition.
|
|
25
|
+
DEFAULT_TTL_SECONDS = 600.0
|
|
26
|
+
|
|
27
|
+
# Set by a parent that already holds the lock, so its child does not deadlock
|
|
28
|
+
# against it. Mirrors the existing GRAPHITE_DAEMON_CHILD convention.
|
|
29
|
+
ENV_LOCK_HELD = "GRAPHITE_BUILD_LOCK_HELD"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def lock_path(cache_dir: Path) -> Path:
|
|
33
|
+
return Path(cache_dir) / ".build.lock"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_record(path: Path) -> dict[str, Any] | None:
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
39
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
40
|
+
return None
|
|
41
|
+
return data if isinstance(data, dict) else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_stale(record: dict[str, Any] | None, now: float, ttl_seconds: float) -> bool:
|
|
45
|
+
if record is None:
|
|
46
|
+
return True # unreadable or garbage: never block builds forever
|
|
47
|
+
started = record.get("started_at")
|
|
48
|
+
if not isinstance(started, (int, float)) or isinstance(started, bool):
|
|
49
|
+
return True
|
|
50
|
+
return (now - float(started)) > ttl_seconds
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _create_exclusive(path: Path, now: float) -> bool:
|
|
54
|
+
"""Atomically create the lock file. False if someone else already has it."""
|
|
55
|
+
try:
|
|
56
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
57
|
+
except FileExistsError:
|
|
58
|
+
return False
|
|
59
|
+
except OSError:
|
|
60
|
+
return False
|
|
61
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
62
|
+
json.dump(
|
|
63
|
+
{"pid": os.getpid(), "started_at": now, "host": socket.gethostname()},
|
|
64
|
+
handle,
|
|
65
|
+
)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@contextmanager
|
|
70
|
+
def build_lock(
|
|
71
|
+
cache_dir: Path,
|
|
72
|
+
*,
|
|
73
|
+
ttl_seconds: float = DEFAULT_TTL_SECONDS,
|
|
74
|
+
clock: Callable[[], float] = time.time,
|
|
75
|
+
) -> Iterator[bool]:
|
|
76
|
+
"""Yield True if this process acquired the build lock, else False.
|
|
77
|
+
|
|
78
|
+
The lock is released on exit ONLY when it was acquired here -- a refused
|
|
79
|
+
builder must never unlink a lock it does not own.
|
|
80
|
+
"""
|
|
81
|
+
path = lock_path(cache_dir)
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
acquired = _create_exclusive(path, clock())
|
|
84
|
+
|
|
85
|
+
if not acquired and _is_stale(_read_record(path), clock(), ttl_seconds):
|
|
86
|
+
try:
|
|
87
|
+
path.unlink()
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
acquired = _create_exclusive(path, clock())
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
yield acquired
|
|
94
|
+
finally:
|
|
95
|
+
if acquired:
|
|
96
|
+
try:
|
|
97
|
+
path.unlink()
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
graphite/cache.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Content-addressed JSON cache for deterministic incremental builds."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# The two partition shapes that exist on disk. Both are matched because a rule
|
|
12
|
+
# covering only the engine-suffixed form would strand the legacy ones and still
|
|
13
|
+
# appear to work:
|
|
14
|
+
# v11-0570918cea69669b post-#21, `{version}-{engine[:16]}`
|
|
15
|
+
# v10 pre-#21, bare version
|
|
16
|
+
# Lowercase hex and an exact width of 16 are required so a directory that
|
|
17
|
+
# merely resembles a partition is left alone -- `.cache/graphite/` is
|
|
18
|
+
# graphite-owned, but a rule loose enough to delete an unrelated directory is
|
|
19
|
+
# not worth the reclaimed megabytes.
|
|
20
|
+
_PARTITION_RE = re.compile(r"^(?:v\d+|.+-[0-9a-f]{16})$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_partition_dir(path: Path) -> bool:
|
|
24
|
+
try:
|
|
25
|
+
if not path.is_dir():
|
|
26
|
+
return False
|
|
27
|
+
except OSError:
|
|
28
|
+
return False
|
|
29
|
+
return bool(_PARTITION_RE.match(path.name))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Cache:
|
|
33
|
+
"""Simple content-addressed cache on disk."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, root: Path, version: str, *, engine: str):
|
|
36
|
+
# Partitioned on engine identity as well as `cache_version` (#21).
|
|
37
|
+
# `engine_identity()` is strictly finer-grained -- it hashes the packaged
|
|
38
|
+
# engine files and takes `cache_version` as an input -- so keying on the
|
|
39
|
+
# version alone let an extraction change serve the previous engine's
|
|
40
|
+
# cached result while the graph's recorded fingerprint moved to the new
|
|
41
|
+
# value, which made `check` report the stale graph as fresh.
|
|
42
|
+
#
|
|
43
|
+
# `engine` is a required keyword rather than an optional one on purpose:
|
|
44
|
+
# a caller that forgets it must fail loudly, not silently fall back to a
|
|
45
|
+
# shared partition and reintroduce the defect.
|
|
46
|
+
self.root = root / f"{version}-{engine[:16]}"
|
|
47
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
def _key(self, *parts: str) -> str:
|
|
50
|
+
"""Stable cache key from parts."""
|
|
51
|
+
return hashlib.sha256("::".join(parts).encode()).hexdigest()
|
|
52
|
+
|
|
53
|
+
def _path(self, key: str) -> Path:
|
|
54
|
+
return self.root / key[:2] / f"{key}.json"
|
|
55
|
+
|
|
56
|
+
def read(self, *parts: str) -> Any | None:
|
|
57
|
+
path = self._path(self._key(*parts))
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
62
|
+
return json.load(f)
|
|
63
|
+
except (json.JSONDecodeError, OSError):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def write(self, value: Any, *parts: str) -> None:
|
|
67
|
+
path = self._path(self._key(*parts))
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
70
|
+
json.dump(value, f, ensure_ascii=False, separators=(",", ":"))
|
|
71
|
+
|
|
72
|
+
def clear(self) -> None:
|
|
73
|
+
if self.root.exists():
|
|
74
|
+
for item in self.root.rglob("*.json"):
|
|
75
|
+
item.unlink()
|
|
76
|
+
|
|
77
|
+
def prune_other_partitions(self) -> list[str]:
|
|
78
|
+
"""Delete sibling partitions this cache can never read again (#23).
|
|
79
|
+
|
|
80
|
+
Only one partition is reachable at a time -- the one whose name matches
|
|
81
|
+
the current `cache_version` and engine -- so every other one is dead
|
|
82
|
+
weight. Nothing ever removed them, and since #21 a new one appears per
|
|
83
|
+
distinct engine build rather than per manual version bump, so the leak
|
|
84
|
+
grew from bounded to unbounded.
|
|
85
|
+
|
|
86
|
+
Reachability is the rule, deliberately, rather than a count or an age.
|
|
87
|
+
"Keep the newest N" is a guess that can delete a partition another
|
|
88
|
+
build is mid-read on; "is not the partition I am using" is a fact.
|
|
89
|
+
Callers hold the repo build lock, which serialises builds of the same
|
|
90
|
+
repo, so no concurrent build of this cache can be reading a sibling.
|
|
91
|
+
|
|
92
|
+
Failures are swallowed. A partition can be locked by another process
|
|
93
|
+
(routine on Windows), and cache content is regenerable, so a failed
|
|
94
|
+
reclaim is a non-event that must never fail a build.
|
|
95
|
+
|
|
96
|
+
Returns the names actually removed.
|
|
97
|
+
"""
|
|
98
|
+
parent = self.root.parent
|
|
99
|
+
removed: list[str] = []
|
|
100
|
+
try:
|
|
101
|
+
entries = list(parent.iterdir())
|
|
102
|
+
except OSError:
|
|
103
|
+
return removed
|
|
104
|
+
for entry in entries:
|
|
105
|
+
if entry.name == self.root.name or not _is_partition_dir(entry):
|
|
106
|
+
continue
|
|
107
|
+
try:
|
|
108
|
+
shutil.rmtree(entry)
|
|
109
|
+
except OSError:
|
|
110
|
+
continue
|
|
111
|
+
removed.append(entry.name)
|
|
112
|
+
return removed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def file_hash(path: Path) -> str:
|
|
116
|
+
"""Hash a file's content. Empty/missing files return empty string."""
|
|
117
|
+
if not path.exists():
|
|
118
|
+
return ""
|
|
119
|
+
h = hashlib.sha256()
|
|
120
|
+
try:
|
|
121
|
+
with open(path, "rb") as f:
|
|
122
|
+
while chunk := f.read(65536):
|
|
123
|
+
h.update(chunk)
|
|
124
|
+
except OSError:
|
|
125
|
+
return ""
|
|
126
|
+
return h.hexdigest()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def content_hash(text: str) -> str:
|
|
130
|
+
"""Hash arbitrary text."""
|
|
131
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|