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/graph_io.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Bounded, validated reads for untrusted Graphite graph artifacts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Final
|
|
9
|
+
|
|
10
|
+
from .graph import graph_from_json
|
|
11
|
+
from .validation import validate_graph_bundle
|
|
12
|
+
|
|
13
|
+
MAX_GRAPH_BYTES: Final = 128 * 1024 * 1024
|
|
14
|
+
_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
15
|
+
_ERROR_CODES = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"graph_root_invalid",
|
|
18
|
+
"graph_outside_root",
|
|
19
|
+
"graph_missing",
|
|
20
|
+
"graph_reparse",
|
|
21
|
+
"graph_not_regular",
|
|
22
|
+
"graph_too_large",
|
|
23
|
+
"graph_changed",
|
|
24
|
+
"graph_unreadable",
|
|
25
|
+
"graph_invalid_utf8",
|
|
26
|
+
"graph_invalid_json",
|
|
27
|
+
"graph_invalid",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GraphReadError(RuntimeError):
|
|
33
|
+
"""A stable, path-free graph-read failure."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, code: str) -> None:
|
|
36
|
+
if code not in _ERROR_CODES:
|
|
37
|
+
code = "graph_unreadable"
|
|
38
|
+
self.code = code
|
|
39
|
+
super().__init__(code)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_reparse_point(metadata: os.stat_result) -> bool:
|
|
43
|
+
return bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _stable_signature(metadata: os.stat_result) -> tuple[int, int, int, int]:
|
|
47
|
+
return (
|
|
48
|
+
int(metadata.st_dev),
|
|
49
|
+
int(metadata.st_ino),
|
|
50
|
+
int(metadata.st_size),
|
|
51
|
+
int(metadata.st_mtime_ns),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_root(root: Path) -> Path:
|
|
56
|
+
try:
|
|
57
|
+
metadata = root.lstat()
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
raise GraphReadError("graph_root_invalid") from exc
|
|
60
|
+
if stat.S_ISLNK(metadata.st_mode) or _is_reparse_point(metadata):
|
|
61
|
+
raise GraphReadError("graph_root_invalid")
|
|
62
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
63
|
+
raise GraphReadError("graph_root_invalid")
|
|
64
|
+
try:
|
|
65
|
+
return root.resolve(strict=True)
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
raise GraphReadError("graph_root_invalid") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_candidate(path: Path, root: Path) -> Path:
|
|
71
|
+
candidate = path if path.is_absolute() else root / path
|
|
72
|
+
try:
|
|
73
|
+
lexical_relative = candidate.absolute().relative_to(root)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise GraphReadError("graph_outside_root") from exc
|
|
76
|
+
if ".." in lexical_relative.parts:
|
|
77
|
+
raise GraphReadError("graph_outside_root")
|
|
78
|
+
current = root
|
|
79
|
+
for part in lexical_relative.parts[:-1]:
|
|
80
|
+
current /= part
|
|
81
|
+
try:
|
|
82
|
+
parent_metadata = current.lstat()
|
|
83
|
+
except FileNotFoundError as exc:
|
|
84
|
+
raise GraphReadError("graph_missing") from exc
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
raise GraphReadError("graph_unreadable") from exc
|
|
87
|
+
if stat.S_ISLNK(parent_metadata.st_mode) or _is_reparse_point(parent_metadata):
|
|
88
|
+
raise GraphReadError("graph_reparse")
|
|
89
|
+
try:
|
|
90
|
+
lexical_metadata = candidate.lstat()
|
|
91
|
+
except FileNotFoundError as exc:
|
|
92
|
+
raise GraphReadError("graph_missing") from exc
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise GraphReadError("graph_unreadable") from exc
|
|
95
|
+
if stat.S_ISLNK(lexical_metadata.st_mode) or _is_reparse_point(lexical_metadata):
|
|
96
|
+
raise GraphReadError("graph_reparse")
|
|
97
|
+
try:
|
|
98
|
+
resolved = candidate.resolve(strict=True)
|
|
99
|
+
resolved.relative_to(root)
|
|
100
|
+
except ValueError as exc:
|
|
101
|
+
raise GraphReadError("graph_outside_root") from exc
|
|
102
|
+
except FileNotFoundError as exc:
|
|
103
|
+
raise GraphReadError("graph_missing") from exc
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
raise GraphReadError("graph_unreadable") from exc
|
|
106
|
+
return resolved
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read_stable_bytes(path: Path, *, max_bytes: int) -> bytes:
|
|
110
|
+
try:
|
|
111
|
+
before = path.lstat()
|
|
112
|
+
except FileNotFoundError as exc:
|
|
113
|
+
raise GraphReadError("graph_missing") from exc
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
raise GraphReadError("graph_unreadable") from exc
|
|
116
|
+
if stat.S_ISLNK(before.st_mode) or _is_reparse_point(before):
|
|
117
|
+
raise GraphReadError("graph_reparse")
|
|
118
|
+
if not stat.S_ISREG(before.st_mode):
|
|
119
|
+
raise GraphReadError("graph_not_regular")
|
|
120
|
+
if before.st_size > max_bytes:
|
|
121
|
+
raise GraphReadError("graph_too_large")
|
|
122
|
+
|
|
123
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
|
124
|
+
try:
|
|
125
|
+
descriptor = os.open(path, flags)
|
|
126
|
+
try:
|
|
127
|
+
opened = os.fstat(descriptor)
|
|
128
|
+
if _stable_signature(opened) != _stable_signature(before):
|
|
129
|
+
raise GraphReadError("graph_changed")
|
|
130
|
+
chunks: list[bytes] = []
|
|
131
|
+
remaining = max_bytes + 1
|
|
132
|
+
while remaining > 0:
|
|
133
|
+
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
|
134
|
+
if not chunk:
|
|
135
|
+
break
|
|
136
|
+
chunks.append(chunk)
|
|
137
|
+
remaining -= len(chunk)
|
|
138
|
+
after = os.fstat(descriptor)
|
|
139
|
+
finally:
|
|
140
|
+
os.close(descriptor)
|
|
141
|
+
except GraphReadError:
|
|
142
|
+
raise
|
|
143
|
+
except FileNotFoundError as exc:
|
|
144
|
+
raise GraphReadError("graph_missing") from exc
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
raise GraphReadError("graph_unreadable") from exc
|
|
147
|
+
|
|
148
|
+
data = b"".join(chunks)
|
|
149
|
+
if len(data) > max_bytes:
|
|
150
|
+
raise GraphReadError("graph_too_large")
|
|
151
|
+
if _stable_signature(after) != _stable_signature(opened):
|
|
152
|
+
raise GraphReadError("graph_changed")
|
|
153
|
+
return data
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def load_validated_graph_bundle(
|
|
157
|
+
path: Path,
|
|
158
|
+
*,
|
|
159
|
+
root: Path,
|
|
160
|
+
max_bytes: int = MAX_GRAPH_BYTES,
|
|
161
|
+
) -> tuple[dict[str, Any], Any]:
|
|
162
|
+
"""Read, validate, and construct a graph inside the selected root."""
|
|
163
|
+
if isinstance(max_bytes, bool) or max_bytes <= 0 or max_bytes > MAX_GRAPH_BYTES:
|
|
164
|
+
raise GraphReadError("graph_too_large")
|
|
165
|
+
selected_root = _resolve_root(root)
|
|
166
|
+
graph_path = _resolve_candidate(path, selected_root)
|
|
167
|
+
raw = _read_stable_bytes(graph_path, max_bytes=max_bytes)
|
|
168
|
+
try:
|
|
169
|
+
text = raw.decode("utf-8")
|
|
170
|
+
except UnicodeDecodeError as exc:
|
|
171
|
+
raise GraphReadError("graph_invalid_utf8") from exc
|
|
172
|
+
try:
|
|
173
|
+
bundle = json.loads(text)
|
|
174
|
+
except (json.JSONDecodeError, RecursionError) as exc:
|
|
175
|
+
raise GraphReadError("graph_invalid_json") from exc
|
|
176
|
+
if not isinstance(bundle, dict):
|
|
177
|
+
raise GraphReadError("graph_invalid")
|
|
178
|
+
try:
|
|
179
|
+
report = validate_graph_bundle(bundle)
|
|
180
|
+
except (AttributeError, KeyError, RecursionError, TypeError, ValueError) as exc:
|
|
181
|
+
raise GraphReadError("graph_invalid") from exc
|
|
182
|
+
if not report.get("ok"):
|
|
183
|
+
raise GraphReadError("graph_invalid")
|
|
184
|
+
try:
|
|
185
|
+
graph = graph_from_json(bundle)
|
|
186
|
+
except (AttributeError, KeyError, RecursionError, TypeError, ValueError) as exc:
|
|
187
|
+
raise GraphReadError("graph_invalid") from exc
|
|
188
|
+
return bundle, graph
|
graphite/health.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Resolution-health ("trust") signal computed from the canonical graph.
|
|
2
|
+
|
|
3
|
+
Pure arithmetic over the loaded graph — no inference, no I/O in
|
|
4
|
+
resolution_health itself. persisted_resolution is the fail-open reader for
|
|
5
|
+
consumers that must not pay a full graph load (check, strict hook).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Any, Callable, Final
|
|
12
|
+
|
|
13
|
+
import networkx as nx
|
|
14
|
+
|
|
15
|
+
from .graph import edge_relations
|
|
16
|
+
|
|
17
|
+
RESOLUTION_HEALTHY_RATIO: Final = 0.8
|
|
18
|
+
|
|
19
|
+
_COUNTED_RELATIONS: Final = ("calls", "imports")
|
|
20
|
+
|
|
21
|
+
# Per-relation marker for "this edge leaves the repo". An edge counts as
|
|
22
|
+
# external ONLY when it also failed to bind -- externality excuses an unbound
|
|
23
|
+
# edge, it never removes a bound one (spec §5).
|
|
24
|
+
_EXTERNAL_CONFIDENCE: Final = {
|
|
25
|
+
"imports": "EXTERNAL_IMPORT",
|
|
26
|
+
"calls": "EXTERNAL_CALL",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_EXTENSION_LANGUAGES: Final = {
|
|
30
|
+
".py": "python",
|
|
31
|
+
".ts": "typescript",
|
|
32
|
+
".tsx": "typescript",
|
|
33
|
+
".mts": "typescript",
|
|
34
|
+
".cts": "typescript",
|
|
35
|
+
".js": "javascript",
|
|
36
|
+
".jsx": "javascript",
|
|
37
|
+
".mjs": "javascript",
|
|
38
|
+
".cjs": "javascript",
|
|
39
|
+
".go": "go",
|
|
40
|
+
".rs": "rust",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_MAX_ANALYSIS_BYTES: Final = 64 * 1024 * 1024
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _edge_language(source_file: object) -> str:
|
|
47
|
+
if not isinstance(source_file, str) or not source_file:
|
|
48
|
+
return "other"
|
|
49
|
+
suffix = PurePosixPath(source_file).suffix.lower()
|
|
50
|
+
return _EXTENSION_LANGUAGES.get(suffix, "other")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cell(bound: int, total: int, external: int) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"total": total,
|
|
56
|
+
"bound": bound,
|
|
57
|
+
"ratio": None if total == 0 else round(bound / total, 3),
|
|
58
|
+
"external": external,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolution_health(g: nx.DiGraph) -> dict[str, Any]:
|
|
63
|
+
"""Measured resolver health: bound-edge ratios per relation and language."""
|
|
64
|
+
node_total = g.number_of_nodes()
|
|
65
|
+
unknown_nodes = sum(
|
|
66
|
+
1 for _n, data in g.nodes(data=True) if data.get("kind", "unknown") == "unknown"
|
|
67
|
+
)
|
|
68
|
+
relation_counts = {rel: [0, 0, 0] for rel in _COUNTED_RELATIONS} # [bound, total, external]
|
|
69
|
+
language_counts: dict[str, dict[str, list[int]]] = {}
|
|
70
|
+
for _u, v, data in g.edges(data=True):
|
|
71
|
+
# A merged edge carries every relation of its node pair; counting only
|
|
72
|
+
# `relation` would drop a measured relation that collided with an
|
|
73
|
+
# earlier-sorting one (#1).
|
|
74
|
+
for relation in edge_relations(data):
|
|
75
|
+
counts = relation_counts.get(relation)
|
|
76
|
+
if counts is None:
|
|
77
|
+
continue
|
|
78
|
+
language = _edge_language(data.get("source_file"))
|
|
79
|
+
buckets = language_counts.setdefault(
|
|
80
|
+
language, {rel: [0, 0, 0] for rel in _COUNTED_RELATIONS}
|
|
81
|
+
)
|
|
82
|
+
bound = int(g.nodes[v].get("kind", "unknown") != "unknown")
|
|
83
|
+
if not bound and data.get("confidence") == _EXTERNAL_CONFIDENCE.get(relation):
|
|
84
|
+
counts[2] += 1
|
|
85
|
+
buckets[relation][2] += 1
|
|
86
|
+
continue
|
|
87
|
+
counts[0] += bound
|
|
88
|
+
counts[1] += 1
|
|
89
|
+
buckets[relation][0] += bound
|
|
90
|
+
buckets[relation][1] += 1
|
|
91
|
+
by_relation = {rel: _cell(c[0], c[1], c[2]) for rel, c in relation_counts.items()}
|
|
92
|
+
by_language = {
|
|
93
|
+
language: {rel: _cell(c[0], c[1], c[2]) for rel, c in buckets.items()}
|
|
94
|
+
for language, buckets in sorted(language_counts.items())
|
|
95
|
+
}
|
|
96
|
+
ratios = [cell["ratio"] for cell in by_relation.values() if cell["ratio"] is not None]
|
|
97
|
+
return {
|
|
98
|
+
"schema": 3,
|
|
99
|
+
"placeholder_nodes": {
|
|
100
|
+
"total": node_total,
|
|
101
|
+
"unknown": unknown_nodes,
|
|
102
|
+
"share": None if node_total == 0 else round(unknown_nodes / node_total, 3),
|
|
103
|
+
},
|
|
104
|
+
"by_relation": by_relation,
|
|
105
|
+
"by_language": by_language,
|
|
106
|
+
"healthy": all(ratio >= RESOLUTION_HEALTHY_RATIO for ratio in ratios),
|
|
107
|
+
"threshold": RESOLUTION_HEALTHY_RATIO,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ratio_percent(block: dict[str, Any], relation: str) -> str:
|
|
112
|
+
"""Human rendering of one relation's bound ratio: '4.6%' or 'n/a'."""
|
|
113
|
+
try:
|
|
114
|
+
ratio = block["by_relation"][relation]["ratio"]
|
|
115
|
+
except (KeyError, TypeError):
|
|
116
|
+
return "n/a"
|
|
117
|
+
if not isinstance(ratio, (int, float)):
|
|
118
|
+
return "n/a"
|
|
119
|
+
return f"{ratio * 100:.1f}%"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def persisted_resolution(
|
|
123
|
+
root: Path, on_error: Callable[[Exception], None] | None = None
|
|
124
|
+
) -> dict[str, Any] | None:
|
|
125
|
+
"""Fail-open read of the persisted block from graph-out/.graphite_analysis.json.
|
|
126
|
+
|
|
127
|
+
``on_error`` fires only for MALFORMED content (ValueError/RecursionError) —
|
|
128
|
+
absence or unreadability is not malformation and stays silent.
|
|
129
|
+
"""
|
|
130
|
+
path = root / "graph-out" / ".graphite_analysis.json"
|
|
131
|
+
try:
|
|
132
|
+
if not path.is_file() or path.stat().st_size > _MAX_ANALYSIS_BYTES:
|
|
133
|
+
return None
|
|
134
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
135
|
+
except OSError:
|
|
136
|
+
return None
|
|
137
|
+
except (ValueError, RecursionError) as exc:
|
|
138
|
+
if on_error is not None:
|
|
139
|
+
try:
|
|
140
|
+
on_error(exc)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
return None
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
return None
|
|
146
|
+
block = data.get("resolution_health")
|
|
147
|
+
return block if isinstance(block, dict) else None
|
graphite/hook_entry.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The fast path a git-hook trampoline execs.
|
|
2
|
+
|
|
3
|
+
**This module MUST NOT import `graphite.cli`.** Measured on Windows, best of 3:
|
|
4
|
+
|
|
5
|
+
bare python 79 ms
|
|
6
|
+
import graphite 86 ms (__init__ is nearly free)
|
|
7
|
+
import graphite.detach 137 ms
|
|
8
|
+
import graphite.cli 1281 ms
|
|
9
|
+
|
|
10
|
+
A trampoline that reached the CLI would put ~1.2s on *every commit* purely to
|
|
11
|
+
spawn a background build. The cost is specifically `cli.py`, not "importing
|
|
12
|
+
graphite at all", which is what makes a Python hook entry viable in the first
|
|
13
|
+
place. `tests/test_hook_entry.py` enforces this from a subprocess -- an
|
|
14
|
+
in-process check would false-pass, since pytest imports the CLI elsewhere.
|
|
15
|
+
|
|
16
|
+
Deliberately does NOT take the build lock. `cmd_build` already acquires it in
|
|
17
|
+
the child; holding it here would mean the detached child finds the lock held by
|
|
18
|
+
its own parent and exits with "build skipped", so no build would ever run.
|
|
19
|
+
"""
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .detach import spawn_detached
|
|
24
|
+
|
|
25
|
+
# The committed marker that means "this repo is onboarded to graphite". Chosen
|
|
26
|
+
# over `.git` on purpose: the machine-wide `init.templateDir` shim runs in every
|
|
27
|
+
# new clone on the machine, so the guard must be something a repo opts into.
|
|
28
|
+
MARKER = "GRAPHITE.md"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_repo_root(start: Path) -> Path | None:
|
|
32
|
+
"""Nearest ancestor of `start` (inclusive) carrying the marker."""
|
|
33
|
+
for directory in (start, *start.parents):
|
|
34
|
+
if (directory / MARKER).exists():
|
|
35
|
+
return directory
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
"""Spawn a detached graph build for the repo containing `argv[0]`.
|
|
41
|
+
|
|
42
|
+
Always returns 0. Git ignores `post-*` exit codes anyway, but the shim also
|
|
43
|
+
ends with an explicit `exit 0`, and a graph refresh must never be able to
|
|
44
|
+
fail a developer's commit.
|
|
45
|
+
"""
|
|
46
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
47
|
+
start = Path(args[0]) if args else Path.cwd()
|
|
48
|
+
try:
|
|
49
|
+
start = start.resolve()
|
|
50
|
+
except OSError:
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
root = find_repo_root(start)
|
|
54
|
+
if root is None:
|
|
55
|
+
return 0 # not an onboarded repo -- fail open, silently
|
|
56
|
+
|
|
57
|
+
# The root is passed explicitly AND used as cwd. Both are load-bearing:
|
|
58
|
+
# `Config.output_dir` / `cache_dir` default to RELATIVE paths that
|
|
59
|
+
# `cmd_build` resolves against the process CWD, so a child launched from
|
|
60
|
+
# elsewhere would write the graph into the wrong directory.
|
|
61
|
+
spawn_detached(
|
|
62
|
+
[sys.executable, "-B", "-P", "-m", "graphite", "build", str(root)], root
|
|
63
|
+
)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
raise SystemExit(main())
|
graphite/hookinstall.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Installing, migrating and removing graphite's git hooks.
|
|
2
|
+
|
|
3
|
+
All filesystem and git side effects live here; `hookshim` stays pure.
|
|
4
|
+
|
|
5
|
+
**The interop rule this module exists to honour:** a hook graphite does not
|
|
6
|
+
trigger on is *relocated byte-identically* -- moved, unchanged, with no
|
|
7
|
+
graphite marker and no `.local` sibling. It is never wrapped in a pass-through
|
|
8
|
+
trampoline.
|
|
9
|
+
|
|
10
|
+
An earlier design did wrap them. aramid's `install()` treats any hook carrying
|
|
11
|
+
another tool's `# >>> <tool> managed >>>` marker as foreign-managed and refuses
|
|
12
|
+
it outright (aramid `0f24609`), skipping its own gate for that hook. Since the
|
|
13
|
+
old design stamped graphite's marker onto *every* migrated hook including
|
|
14
|
+
`pre-commit`/`pre-push`, `graphite init` would have left aramid's gates
|
|
15
|
+
un-refreshable in every repo it touched. Relocation avoids this entirely:
|
|
16
|
+
aramid's shim keeps its own marker, so `_is_aramid_shim` is true, its
|
|
17
|
+
foreign-hook branch never runs, and it regenerates in place. aramid's agent
|
|
18
|
+
independently confirmed this against `hooks.py:333,338-342` and
|
|
19
|
+
`init.py:198-206` before it shipped.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import stat
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .config import default_projects_root
|
|
28
|
+
from .hookshim import CHAINED_SUFFIX, MARKER_START, TRIGGERS, render_trigger_shim
|
|
29
|
+
|
|
30
|
+
DEFAULT_HOOKS_DIRNAME = ".githooks"
|
|
31
|
+
|
|
32
|
+
# Distinct from the unrelated "template" used for GRAPHITE.md/instruction-doc
|
|
33
|
+
# versioning (`init.py`'s DOC_VERSION) -- "hooks" is spelled out so the two
|
|
34
|
+
# concepts never look like the same thing on disk.
|
|
35
|
+
DEFAULT_TEMPLATE_DIRNAME = ".graphite-hooks-template"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
39
|
+
# Explicit codec and error handler: `text=True` alone decodes with the
|
|
40
|
+
# locale codec, and git echoes repository paths and branch names that are
|
|
41
|
+
# not required to be Latin-1. A failure decodes on subprocess's reader
|
|
42
|
+
# thread, so it yields `stdout is None` rather than raising.
|
|
43
|
+
return subprocess.run(
|
|
44
|
+
["git", "-C", str(root), *args],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
errors="replace",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def hooks_dir(root: Path) -> Path:
|
|
53
|
+
"""Where hooks live for this repo.
|
|
54
|
+
|
|
55
|
+
Honours an existing `core.hooksPath` rather than taking it over: if husky
|
|
56
|
+
(or anything else) already owns hook policy here, graphite installs into
|
|
57
|
+
*their* directory. Relative values resolve against the repo root, matching
|
|
58
|
+
git's own rule and aramid's `hooks_dir`.
|
|
59
|
+
"""
|
|
60
|
+
configured = _git(root, "config", "--get", "core.hooksPath").stdout.strip()
|
|
61
|
+
if not configured:
|
|
62
|
+
return root / DEFAULT_HOOKS_DIRNAME
|
|
63
|
+
candidate = Path(configured)
|
|
64
|
+
return candidate if candidate.is_absolute() else root / candidate
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def hook_shim_present(path: Path) -> bool:
|
|
68
|
+
"""Did graphite write this hook file? Decided by the in-file marker.
|
|
69
|
+
|
|
70
|
+
The single marker predicate for the whole codebase. It lived in `doctor`
|
|
71
|
+
until 2026-07-31; two copies in two modules is how the relocated-hook rule
|
|
72
|
+
below drifts apart from the code that enforces it.
|
|
73
|
+
"""
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
return MARKER_START.encode() in path.read_bytes()
|
|
78
|
+
except OSError:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _make_executable(path: Path) -> None:
|
|
83
|
+
"""Best-effort on a bare Windows filesystem, but it matters the moment the
|
|
84
|
+
repo is cloned onto WSL or a Linux CI runner."""
|
|
85
|
+
try:
|
|
86
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_graphite_shim(path: Path) -> bool:
|
|
92
|
+
if not path.exists():
|
|
93
|
+
return False
|
|
94
|
+
try:
|
|
95
|
+
return MARKER_START.encode() in path.read_bytes()
|
|
96
|
+
except OSError:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _legacy_hooks(root: Path) -> list[Path]:
|
|
101
|
+
legacy = root / ".git" / "hooks"
|
|
102
|
+
if not legacy.is_dir():
|
|
103
|
+
return []
|
|
104
|
+
return [p for p in sorted(legacy.iterdir()) if p.is_file() and p.suffix != ".sample"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def install_hooks(root: Path, interpreter: Path) -> list[str]:
|
|
108
|
+
"""Install graphite's trampolines, migrating whatever was already there.
|
|
109
|
+
|
|
110
|
+
Returns the names of hooks that were relocated (not chained), so callers
|
|
111
|
+
can report them -- a silent migration of someone else's hook is exactly the
|
|
112
|
+
kind of surprise this design is trying to avoid.
|
|
113
|
+
|
|
114
|
+
Ordering is load-bearing: `core.hooksPath` is written **last**, so no
|
|
115
|
+
window exists in which it redirects to a directory that has no hooks yet.
|
|
116
|
+
"""
|
|
117
|
+
hdir = hooks_dir(root)
|
|
118
|
+
hdir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
already_configured = bool(
|
|
120
|
+
_git(root, "config", "--get", "core.hooksPath").stdout.strip()
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
relocated: list[str] = []
|
|
124
|
+
for legacy in _legacy_hooks(root):
|
|
125
|
+
name = legacy.name
|
|
126
|
+
target = hdir / (f"{name}{CHAINED_SUFFIX}" if name in TRIGGERS else name)
|
|
127
|
+
if target.exists() or (name not in TRIGGERS and (hdir / name).exists()):
|
|
128
|
+
# Already migrated by a previous run; leave both copies alone
|
|
129
|
+
# rather than clobbering a live hook.
|
|
130
|
+
continue
|
|
131
|
+
target.write_bytes(legacy.read_bytes())
|
|
132
|
+
_make_executable(target)
|
|
133
|
+
legacy.unlink()
|
|
134
|
+
if name not in TRIGGERS:
|
|
135
|
+
relocated.append(name)
|
|
136
|
+
|
|
137
|
+
for hook in TRIGGERS:
|
|
138
|
+
slot = hdir / hook
|
|
139
|
+
# A pre-existing NON-graphite file in a trigger slot (e.g. one written
|
|
140
|
+
# straight into .githooks/) becomes the chained original. A graphite
|
|
141
|
+
# shim is simply regenerated -- never chained to itself.
|
|
142
|
+
if slot.exists() and not _is_graphite_shim(slot):
|
|
143
|
+
chained = hdir / f"{hook}{CHAINED_SUFFIX}"
|
|
144
|
+
if not chained.exists():
|
|
145
|
+
slot.replace(chained)
|
|
146
|
+
_make_executable(chained)
|
|
147
|
+
else:
|
|
148
|
+
slot.unlink()
|
|
149
|
+
slot.write_bytes(render_trigger_shim(hook, interpreter))
|
|
150
|
+
_make_executable(slot)
|
|
151
|
+
|
|
152
|
+
if not already_configured:
|
|
153
|
+
_git(root, "config", "core.hooksPath", DEFAULT_HOOKS_DIRNAME)
|
|
154
|
+
return relocated
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def uninstall_hooks(root: Path) -> list[str]:
|
|
158
|
+
"""Remove graphite's trampolines, restoring anything they chained.
|
|
159
|
+
|
|
160
|
+
Only graphite's own shims are touched. A relocated foreign hook is left
|
|
161
|
+
exactly where it is: graphite moved it but never owned it, and deleting it
|
|
162
|
+
would take out another tool's live gate.
|
|
163
|
+
"""
|
|
164
|
+
hdir = hooks_dir(root)
|
|
165
|
+
removed: list[str] = []
|
|
166
|
+
for hook in TRIGGERS:
|
|
167
|
+
slot = hdir / hook
|
|
168
|
+
if not _is_graphite_shim(slot):
|
|
169
|
+
continue
|
|
170
|
+
chained = hdir / f"{hook}{CHAINED_SUFFIX}"
|
|
171
|
+
slot.unlink()
|
|
172
|
+
if chained.exists():
|
|
173
|
+
chained.replace(slot)
|
|
174
|
+
_make_executable(slot)
|
|
175
|
+
removed.append(hook)
|
|
176
|
+
return removed
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def default_template_root() -> Path:
|
|
180
|
+
"""Where `graphite hooks --install-template` writes by default.
|
|
181
|
+
|
|
182
|
+
Mirrors the machine-state convention already used for daemon state --
|
|
183
|
+
`<default_projects_root>/.graphite-daemon` (see `config.default_projects_root`,
|
|
184
|
+
`cli._incidents_ledger_dir`) -- rather than inventing a new location.
|
|
185
|
+
Never reads or writes real global git config; that stays the human's step.
|
|
186
|
+
"""
|
|
187
|
+
return default_projects_root() / DEFAULT_TEMPLATE_DIRNAME
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def install_template(template_root: Path, interpreter: Path) -> list[Path]:
|
|
191
|
+
"""Write graphite's trigger shims into a git `init.templateDir` layout.
|
|
192
|
+
|
|
193
|
+
Git copies `<templateDir>/hooks/<name>` into `.git/hooks/<name>` on every
|
|
194
|
+
future `git init`/`git clone` on this machine once a human points
|
|
195
|
+
`init.templateDir` at `template_root` -- the `hooks/` subdirectory is
|
|
196
|
+
required, since that is what git actually copies from. This function
|
|
197
|
+
itself never touches git config, real or otherwise, and only ever writes
|
|
198
|
+
under `template_root`.
|
|
199
|
+
|
|
200
|
+
Unlike `install_hooks`, there is no relocation, no `.local` chaining and
|
|
201
|
+
no `core.hooksPath` write: there is nothing pre-existing to migrate in a
|
|
202
|
+
template directory, and git's own template-copy mechanism is what wires a
|
|
203
|
+
fresh repo up. The written bytes are exactly `render_trigger_shim`'s --
|
|
204
|
+
no separate rendering path for the template case.
|
|
205
|
+
|
|
206
|
+
This shim runs in *every* new clone on the machine, onboarded with
|
|
207
|
+
`GRAPHITE.md` or not, so it must fail open. `hook_entry.main()` already
|
|
208
|
+
does: it returns 0 silently when no `GRAPHITE.md` is found walking up
|
|
209
|
+
from cwd. That existing behaviour is what makes reusing the same shim
|
|
210
|
+
here safe -- a machine-wide template hook that errors would break every
|
|
211
|
+
unrelated repo on the machine.
|
|
212
|
+
|
|
213
|
+
Regeneration is idempotent for the same `(template_root, interpreter)`:
|
|
214
|
+
`render_trigger_shim` is pure and there is no chaining state to disturb.
|
|
215
|
+
"""
|
|
216
|
+
hooks_subdir = template_root / "hooks"
|
|
217
|
+
hooks_subdir.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
written: list[Path] = []
|
|
219
|
+
for hook in TRIGGERS:
|
|
220
|
+
path = hooks_subdir / hook
|
|
221
|
+
path.write_bytes(render_trigger_shim(hook, interpreter))
|
|
222
|
+
_make_executable(path)
|
|
223
|
+
written.append(path)
|
|
224
|
+
return written
|