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/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()