sidegraph 0.1.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.
- sidegraph/__init__.py +37 -0
- sidegraph/anchoring.py +246 -0
- sidegraph/bootstrap/__init__.py +49 -0
- sidegraph/bootstrap/apply.py +603 -0
- sidegraph/bootstrap/catalog.py +92 -0
- sidegraph/bootstrap/cli.py +827 -0
- sidegraph/bootstrap/integrations.py +184 -0
- sidegraph/bootstrap/model.py +277 -0
- sidegraph/bootstrap/planner.py +400 -0
- sidegraph/bootstrap/proof.py +103 -0
- sidegraph/bootstrap/review.py +331 -0
- sidegraph/bootstrap/scan.py +289 -0
- sidegraph/capture.py +1794 -0
- sidegraph/cli.py +1902 -0
- sidegraph/config.py +148 -0
- sidegraph/doc_import.py +2099 -0
- sidegraph/doctor.py +1429 -0
- sidegraph/domains.py +902 -0
- sidegraph/engine/__init__.py +7 -0
- sidegraph/engine/reader.py +353 -0
- sidegraph/gitio.py +572 -0
- sidegraph/host/__init__.py +7 -0
- sidegraph/host/hooks.py +770 -0
- sidegraph/importer.py +239 -0
- sidegraph/okf.py +471 -0
- sidegraph/profiles.py +459 -0
- sidegraph/retrieval.py +1657 -0
- sidegraph/schema.py +386 -0
- sidegraph/server.py +2608 -0
- sidegraph/store.py +3363 -0
- sidegraph/sync.py +885 -0
- sidegraph/verify.py +1040 -0
- sidegraph/viz/__init__.py +4 -0
- sidegraph/viz/assets/vis-network.min.js +33 -0
- sidegraph/viz/model.py +248 -0
- sidegraph/viz/render.py +110 -0
- sidegraph/viz/template.html +131 -0
- sidegraph-0.1.0.dist-info/METADATA +392 -0
- sidegraph-0.1.0.dist-info/RECORD +42 -0
- sidegraph-0.1.0.dist-info/WHEEL +4 -0
- sidegraph-0.1.0.dist-info/entry_points.txt +18 -0
- sidegraph-0.1.0.dist-info/licenses/LICENSE +201 -0
sidegraph/gitio.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""Git bindings seam (design/superpowers/specs/2026-08-07-git-bindings-design.md) --
|
|
2
|
+
commit trailers (П1, ``sidegraph-prepare-commit-msg``) + line-level blame (П2,
|
|
3
|
+
``sidegraph-blame``).
|
|
4
|
+
|
|
5
|
+
The ONLY module that runs git subprocesses FOR THESE TWO FEATURES (``capture.
|
|
6
|
+
_capture_commit`` and ``verify._find_repo_root`` already shell out for their own needs --
|
|
7
|
+
review M9; this module never reaches into either). Portable core (``schema``), ``engine/``,
|
|
8
|
+
and ``host/`` stay untouched -- CLI entry points live in ``cli.py``.
|
|
9
|
+
|
|
10
|
+
**Read-only over RECORDS** (§0): neither feature ever creates, mutates, or supersedes a
|
|
11
|
+
store record. Opening a :class:`~sidegraph.store.Store` is NOT filesystem-read-only --
|
|
12
|
+
its ``__init__`` can run an index rebuild (``_refresh_freshness`` ->
|
|
13
|
+
``_reload_index_from_canonical``, a real write) -- so this module never constructs one.
|
|
14
|
+
Instead :func:`open_index_ro` opens ``index.db`` directly, read-only, via a
|
|
15
|
+
``file:...?mode=ro&immutable=0`` URI with a short busy-timeout fallback (``timeout=0.5``,
|
|
16
|
+
vs. Python's ``sqlite3`` default of 5.0s against a concurrent writer's lock -- a 5-second
|
|
17
|
+
``git commit`` stall is the uninstall failure mode as surely as an error). No index
|
|
18
|
+
rebuild ever happens here: if the index is absent or the ro-open fails (including timing
|
|
19
|
+
out), every caller degrades to writing/resolving nothing rather than raising.
|
|
20
|
+
|
|
21
|
+
The prepare-commit-msg hook additionally bounds itself to a hard wall-clock budget (2s,
|
|
22
|
+
:data:`HOOK_WALL_CLOCK_BUDGET_SECONDS`) -- see :func:`collect_prepare_commit_candidates`
|
|
23
|
+
and :func:`apply_prepare_commit_msg`, both of which re-check the deadline between stages
|
|
24
|
+
and give up (write nothing) rather than run over. ``sidegraph-blame`` (П2) is an explicit,
|
|
25
|
+
interactive user command, not a commit-path hook, so it carries no such budget -- but it
|
|
26
|
+
still never opens a writable ``Store()``, for the same read-only-over-records reason.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
import sqlite3
|
|
34
|
+
import subprocess
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from datetime import datetime
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from urllib.parse import quote
|
|
40
|
+
|
|
41
|
+
# Per-subprocess ceiling (several git calls run per hook/blame invocation) -- never the
|
|
42
|
+
# unbounded default.
|
|
43
|
+
_GIT_TIMEOUT_SECONDS = 1.0
|
|
44
|
+
|
|
45
|
+
# §0: sqlite ro-open busy-timeout fallback -- default Python sqlite3 timeout is 5.0s.
|
|
46
|
+
RO_OPEN_TIMEOUT_SECONDS = 0.5
|
|
47
|
+
|
|
48
|
+
# §0/§1 step 3: the hook's hard wall-clock budget -- after this, write nothing, exit 0.
|
|
49
|
+
HOOK_WALL_CLOCK_BUDGET_SECONDS = 2.0
|
|
50
|
+
|
|
51
|
+
# §1 step 1(b): "cap at the 5 strongest by weight."
|
|
52
|
+
STAGED_CANDIDATE_CAP = 5
|
|
53
|
+
|
|
54
|
+
# §1 step 2: candidate title/statement truncation.
|
|
55
|
+
LABEL_MAX_CHARS = 60
|
|
56
|
+
|
|
57
|
+
# §1 step 2: the fixed trailer key П2's blame join literal resolves against.
|
|
58
|
+
TRAILER_KEY = "Sidegraph-Decision"
|
|
59
|
+
|
|
60
|
+
_HEADER_LINE = "Sidegraph: uncomment the trailers this commit actually implements"
|
|
61
|
+
|
|
62
|
+
# П2 output cap (review M8): 50 hunk rows / 6000 chars, whichever comes first.
|
|
63
|
+
BLAME_ROW_CAP = 50
|
|
64
|
+
BLAME_CHAR_CAP = 6000
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run_git(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str] | None:
|
|
68
|
+
"""Run one git subprocess, never raising. ``None`` on any failure (git missing, cwd
|
|
69
|
+
unusable, timeout) -- every caller treats that the same as a clean "nothing to
|
|
70
|
+
report" degrade (G9)."""
|
|
71
|
+
try:
|
|
72
|
+
return subprocess.run(
|
|
73
|
+
["git", *args],
|
|
74
|
+
cwd=cwd,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
timeout=_GIT_TIMEOUT_SECONDS,
|
|
78
|
+
)
|
|
79
|
+
except (OSError, subprocess.SubprocessError):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def head_commit(cwd: Path) -> str | None:
|
|
84
|
+
"""``git rev-parse HEAD`` -- ``None`` on an empty repo (no commits yet) or any
|
|
85
|
+
failure (review M7: rule (a) and its fallback are skipped entirely when this is
|
|
86
|
+
``None``)."""
|
|
87
|
+
result = _run_git(["rev-parse", "HEAD"], cwd)
|
|
88
|
+
if result is None or result.returncode != 0:
|
|
89
|
+
return None
|
|
90
|
+
sha = result.stdout.strip()
|
|
91
|
+
return sha or None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def head_commit_time(cwd: Path) -> datetime | None:
|
|
95
|
+
"""HEAD's committer timestamp, for rule (a)'s pre-П0 fallback (records with
|
|
96
|
+
``provenance.commit is None`` created after this count as captured-since-last-commit
|
|
97
|
+
too)."""
|
|
98
|
+
result = _run_git(["log", "-1", "--format=%cI", "HEAD"], cwd)
|
|
99
|
+
if result is None or result.returncode != 0:
|
|
100
|
+
return None
|
|
101
|
+
raw = result.stdout.strip()
|
|
102
|
+
if not raw:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
return datetime.fromisoformat(raw)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def staged_files(cwd: Path) -> list[str]:
|
|
111
|
+
"""``git diff --cached --name-only`` -- repo-relative paths, deduped order-preserving."""
|
|
112
|
+
result = _run_git(["diff", "--cached", "--name-only"], cwd)
|
|
113
|
+
if result is None or result.returncode != 0:
|
|
114
|
+
return []
|
|
115
|
+
seen: dict[str, None] = {}
|
|
116
|
+
for line in result.stdout.splitlines():
|
|
117
|
+
line = line.strip()
|
|
118
|
+
if line:
|
|
119
|
+
seen.setdefault(line, None)
|
|
120
|
+
return list(seen)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolved_comment_char(cwd: Path) -> str | None:
|
|
124
|
+
"""Resolve the repo's OWN comment char (§1 step 2, review Major 3). Default ``#``
|
|
125
|
+
when ``core.commentChar`` is unset; a custom char (e.g. ``;``) is used as-is.
|
|
126
|
+
|
|
127
|
+
``auto`` is special-cased to ``None`` (round-2 corrected rationale): git resolves
|
|
128
|
+
``auto`` to a real character BEFORE this hook ever runs, but ``git config --get
|
|
129
|
+
core.commentChar`` still echoes back the literal string ``"auto"`` -- the actually
|
|
130
|
+
resolved character is UNKNOWABLE from here, and guessing risks unchosen candidate
|
|
131
|
+
lines surviving as permanent commit content (measured: a ``commit.template`` with a
|
|
132
|
+
leading ``#`` line makes git pick a non-``#`` char, so a ``#``-writing hook's lines
|
|
133
|
+
survive uncommented). Degrade beats guessing -- callers must write nothing when this
|
|
134
|
+
returns ``None``."""
|
|
135
|
+
result = _run_git(["config", "--get", "core.commentChar"], cwd)
|
|
136
|
+
value = result.stdout.strip() if result is not None and result.returncode == 0 else ""
|
|
137
|
+
if not value:
|
|
138
|
+
return "#"
|
|
139
|
+
if value == "auto":
|
|
140
|
+
return None
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def open_index_ro(store_dir: Path) -> sqlite3.Connection | None:
|
|
145
|
+
"""Open ``<store_dir>/index.db`` strictly read-only, URI ``mode=ro&immutable=0``,
|
|
146
|
+
with :data:`RO_OPEN_TIMEOUT_SECONDS` as the busy-timeout fallback (§0). ``None`` on
|
|
147
|
+
ANY failure -- index absent, ro-open denied, or a concurrent writer's lock not
|
|
148
|
+
released inside the timeout -- so every caller degrades to writing/resolving
|
|
149
|
+
nothing rather than blocking or raising. Never rebuilds the index; never write-opens
|
|
150
|
+
the store."""
|
|
151
|
+
index_path = Path(store_dir) / "index.db"
|
|
152
|
+
uri = f"file:{quote(str(index_path))}?mode=ro&immutable=0"
|
|
153
|
+
try:
|
|
154
|
+
conn = sqlite3.connect(uri, uri=True, timeout=RO_OPEN_TIMEOUT_SECONDS)
|
|
155
|
+
conn.row_factory = sqlite3.Row
|
|
156
|
+
# Force the lock check now (connect() itself doesn't touch the file), inside the
|
|
157
|
+
# timeout window above, rather than deferring it silently to the caller's first
|
|
158
|
+
# query.
|
|
159
|
+
conn.execute("SELECT 1")
|
|
160
|
+
except sqlite3.Error:
|
|
161
|
+
return None
|
|
162
|
+
return conn
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ============================================================================
|
|
166
|
+
# П1 -- sidegraph-prepare-commit-msg
|
|
167
|
+
# ============================================================================
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class RecordCandidate:
|
|
172
|
+
id: str
|
|
173
|
+
kind: str # "decision" | "fact"
|
|
174
|
+
label: str # title (decision) or statement (fact), already truncated
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _truncate(text: str, limit: int = LABEL_MAX_CHARS) -> str:
|
|
178
|
+
text = " ".join(text.split())
|
|
179
|
+
if len(text) <= limit:
|
|
180
|
+
return text
|
|
181
|
+
return text[: max(limit - 1, 0)].rstrip() + "…"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _load_json(raw: str) -> dict | None:
|
|
185
|
+
try:
|
|
186
|
+
return json.loads(raw)
|
|
187
|
+
except (TypeError, ValueError):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _rule_a_candidates(conn: sqlite3.Connection, cwd: Path) -> dict[str, RecordCandidate]:
|
|
192
|
+
"""Rule (a): records (decisions AND facts -- П0) whose ``provenance.commit == HEAD``,
|
|
193
|
+
plus the pre-П0 timestamp fallback for records with no commit stamp at all. Skipped
|
|
194
|
+
entirely when there is no HEAD yet (empty repo, review M7)."""
|
|
195
|
+
out: dict[str, RecordCandidate] = {}
|
|
196
|
+
head = head_commit(cwd)
|
|
197
|
+
if head is None:
|
|
198
|
+
return out
|
|
199
|
+
head_time = head_commit_time(cwd)
|
|
200
|
+
for table, kind, label_field in (
|
|
201
|
+
("decisions", "decision", "title"),
|
|
202
|
+
("facts", "fact", "statement"),
|
|
203
|
+
):
|
|
204
|
+
for row in conn.execute(f"SELECT data FROM {table}"):
|
|
205
|
+
rec = _load_json(row["data"])
|
|
206
|
+
if rec is None:
|
|
207
|
+
continue
|
|
208
|
+
provenance = rec.get("provenance") or {}
|
|
209
|
+
commit = provenance.get("commit")
|
|
210
|
+
matched = False
|
|
211
|
+
if commit == head:
|
|
212
|
+
matched = True
|
|
213
|
+
elif commit is None and head_time is not None:
|
|
214
|
+
valid_from_raw = rec.get("valid_from")
|
|
215
|
+
if valid_from_raw:
|
|
216
|
+
try:
|
|
217
|
+
valid_from = datetime.fromisoformat(valid_from_raw)
|
|
218
|
+
except ValueError:
|
|
219
|
+
valid_from = None
|
|
220
|
+
if valid_from is not None and valid_from > head_time:
|
|
221
|
+
matched = True
|
|
222
|
+
if matched:
|
|
223
|
+
rid = rec.get("id")
|
|
224
|
+
if rid:
|
|
225
|
+
out[rid] = RecordCandidate(
|
|
226
|
+
id=rid, kind=kind, label=_truncate(rec.get(label_field) or "")
|
|
227
|
+
)
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _rule_b_candidates(conn: sqlite3.Connection, cwd: Path) -> dict[str, RecordCandidate]:
|
|
232
|
+
"""Rule (b): staged-file bindings (review Major 4). The entity scan runs ONCE,
|
|
233
|
+
bucketed by path -- never per staged file. Entities with no ``descriptor.file_path``
|
|
234
|
+
(Tier-0/Tier-1, abstract/domain) never enter the bucket map at all, so their
|
|
235
|
+
bindings are never even looked up -- they cannot consume the cap. Decisions-only
|
|
236
|
+
(rule (b) never offers a fact): a matched binding whose ``record_id`` isn't in the
|
|
237
|
+
``decisions`` table is silently skipped."""
|
|
238
|
+
out: dict[str, RecordCandidate] = {}
|
|
239
|
+
paths = staged_files(cwd)
|
|
240
|
+
if not paths:
|
|
241
|
+
return out
|
|
242
|
+
staged = set(paths)
|
|
243
|
+
|
|
244
|
+
# ONE scan, bucketed by path -- filter to path-carrying entities FIRST.
|
|
245
|
+
by_path: dict[str, list[str]] = {}
|
|
246
|
+
for row in conn.execute("SELECT data FROM entities"):
|
|
247
|
+
entity = _load_json(row["data"])
|
|
248
|
+
if entity is None:
|
|
249
|
+
continue
|
|
250
|
+
descriptor = entity.get("descriptor") or {}
|
|
251
|
+
file_path = descriptor.get("file_path")
|
|
252
|
+
if not file_path:
|
|
253
|
+
continue # path-less (Tier-0/Tier-1) -- never enters the map
|
|
254
|
+
entity_id = entity.get("entity_id")
|
|
255
|
+
if entity_id:
|
|
256
|
+
by_path.setdefault(file_path, []).append(entity_id)
|
|
257
|
+
|
|
258
|
+
matched_entity_ids: set[str] = set()
|
|
259
|
+
for p in staged:
|
|
260
|
+
matched_entity_ids.update(by_path.get(p, []))
|
|
261
|
+
if not matched_entity_ids:
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
# Gather bindings ONLY from matched (path-carrying, staged) entities -- a Tier-0/1
|
|
265
|
+
# binding was never fetched in the first place, so it can never consume the cap.
|
|
266
|
+
best_weight: dict[str, float] = {}
|
|
267
|
+
for entity_id in matched_entity_ids:
|
|
268
|
+
for row in conn.execute(
|
|
269
|
+
"SELECT data FROM anchor_bindings WHERE entity_id = ?", (entity_id,)
|
|
270
|
+
):
|
|
271
|
+
binding = _load_json(row["data"])
|
|
272
|
+
if binding is None:
|
|
273
|
+
continue
|
|
274
|
+
record_id = binding.get("record_id")
|
|
275
|
+
if not record_id:
|
|
276
|
+
continue
|
|
277
|
+
weight = binding.get("weight", 1.0)
|
|
278
|
+
if record_id not in best_weight or weight > best_weight[record_id]:
|
|
279
|
+
best_weight[record_id] = weight
|
|
280
|
+
|
|
281
|
+
# THEN cap -- filtering already happened above; this must never run before it.
|
|
282
|
+
top_ids = sorted(best_weight, key=lambda rid: best_weight[rid], reverse=True)[
|
|
283
|
+
:STAGED_CANDIDATE_CAP
|
|
284
|
+
]
|
|
285
|
+
for record_id in top_ids:
|
|
286
|
+
row = conn.execute("SELECT data FROM decisions WHERE id = ?", (record_id,)).fetchone()
|
|
287
|
+
if row is None:
|
|
288
|
+
continue # rule (b) is decisions-only
|
|
289
|
+
decision = _load_json(row["data"])
|
|
290
|
+
if decision is None:
|
|
291
|
+
continue
|
|
292
|
+
out[record_id] = RecordCandidate(
|
|
293
|
+
id=record_id, kind="decision", label=_truncate(decision.get("title") or "")
|
|
294
|
+
)
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def collect_prepare_commit_candidates(
|
|
299
|
+
store_dir: Path, cwd: Path, deadline: float
|
|
300
|
+
) -> list[RecordCandidate]:
|
|
301
|
+
"""Rules (a) + (b), deduped by id. Returns ``[]`` on every degrade condition (index
|
|
302
|
+
absent/locked, no HEAD, no staged files, deadline exceeded) -- never raises."""
|
|
303
|
+
conn = open_index_ro(store_dir)
|
|
304
|
+
if conn is None:
|
|
305
|
+
return []
|
|
306
|
+
try:
|
|
307
|
+
candidates: dict[str, RecordCandidate] = {}
|
|
308
|
+
if time.monotonic() < deadline:
|
|
309
|
+
candidates.update(_rule_a_candidates(conn, cwd))
|
|
310
|
+
if time.monotonic() < deadline:
|
|
311
|
+
for rid, cand in _rule_b_candidates(conn, cwd).items():
|
|
312
|
+
candidates.setdefault(rid, cand)
|
|
313
|
+
return list(candidates.values())
|
|
314
|
+
finally:
|
|
315
|
+
conn.close()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def render_candidate_block(candidates: list[RecordCandidate], char: str) -> str:
|
|
319
|
+
lines = [f"{char} {_HEADER_LINE}"]
|
|
320
|
+
for c in candidates:
|
|
321
|
+
lines.append(f"{char} {TRAILER_KEY}: {c.id} — {c.label}")
|
|
322
|
+
return "\n".join(lines) + "\n"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def apply_prepare_commit_msg(message_file: str, cwd: Path, store_dir: Path) -> None:
|
|
326
|
+
"""Write commented candidate trailers into ``message_file`` (§1). No-op (never
|
|
327
|
+
raises, never blocks past :data:`HOOK_WALL_CLOCK_BUDGET_SECONDS`) when there is
|
|
328
|
+
nothing to offer, the comment char can't be resolved (``auto``), or the deadline is
|
|
329
|
+
already spent.
|
|
330
|
+
|
|
331
|
+
The block is APPENDED, never prepended (review Major 1, measured against real git
|
|
332
|
+
2.54, ``commit.cleanup=strip`` -- the default for an editor-invoked commit, exactly
|
|
333
|
+
what a ``prepare-commit-msg`` hook firing with ``source`` absent implies). A
|
|
334
|
+
prepended block puts the post-uncomment trailer line directly above the subject
|
|
335
|
+
with no blank line between them; git's cleanup folds the two into ONE paragraph
|
|
336
|
+
(the subject's own), and trailers are only ever recognized in the message's LAST
|
|
337
|
+
paragraph -- so the documented "uncomment the line" gesture would silently produce
|
|
338
|
+
a commit with no real trailer at all. Appending after the original content puts the
|
|
339
|
+
(blank-line-separated) trailer paragraph last, where git actually looks."""
|
|
340
|
+
deadline = time.monotonic() + HOOK_WALL_CLOCK_BUDGET_SECONDS
|
|
341
|
+
candidates = collect_prepare_commit_candidates(store_dir, cwd, deadline)
|
|
342
|
+
if not candidates or time.monotonic() >= deadline:
|
|
343
|
+
return
|
|
344
|
+
char = resolved_comment_char(cwd)
|
|
345
|
+
if char is None:
|
|
346
|
+
return # "auto" -- unresolvable, never guess (see resolved_comment_char)
|
|
347
|
+
if time.monotonic() >= deadline:
|
|
348
|
+
return
|
|
349
|
+
try:
|
|
350
|
+
original = Path(message_file).read_text(encoding="utf-8")
|
|
351
|
+
except OSError:
|
|
352
|
+
return
|
|
353
|
+
block = render_candidate_block(candidates, char)
|
|
354
|
+
try:
|
|
355
|
+
Path(message_file).write_text(original.rstrip("\n") + "\n\n" + block, encoding="utf-8")
|
|
356
|
+
except OSError:
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ============================================================================
|
|
361
|
+
# П2 -- sidegraph-blame
|
|
362
|
+
# ============================================================================
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@dataclass
|
|
366
|
+
class Hunk:
|
|
367
|
+
start: int
|
|
368
|
+
end: int
|
|
369
|
+
sha: str
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
_BLAME_HEADER_RE = re.compile(r"^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def blame_hunks(
|
|
376
|
+
path: str, cwd: Path, line_range: tuple[int, int] | None = None
|
|
377
|
+
) -> list[Hunk] | None:
|
|
378
|
+
"""``git blame --porcelain [-L A,B] -- <path>`` -> merged contiguous-sha hunks.
|
|
379
|
+
``None`` on any git failure (not a repo, unknown file, git absent -- G9's CLI error
|
|
380
|
+
surface)."""
|
|
381
|
+
args = ["blame", "--porcelain"]
|
|
382
|
+
if line_range is not None:
|
|
383
|
+
args += ["-L", f"{line_range[0]},{line_range[1]}"]
|
|
384
|
+
args += ["--", path]
|
|
385
|
+
result = _run_git(args, cwd)
|
|
386
|
+
if result is None or result.returncode != 0:
|
|
387
|
+
return None
|
|
388
|
+
entries: list[tuple[int, str]] = []
|
|
389
|
+
for line in result.stdout.splitlines():
|
|
390
|
+
m = _BLAME_HEADER_RE.match(line)
|
|
391
|
+
if m:
|
|
392
|
+
entries.append((int(m.group(2)), m.group(1)))
|
|
393
|
+
entries.sort(key=lambda e: e[0])
|
|
394
|
+
hunks: list[Hunk] = []
|
|
395
|
+
for final_line, sha in entries:
|
|
396
|
+
if hunks and hunks[-1].sha == sha and hunks[-1].end == final_line - 1:
|
|
397
|
+
hunks[-1].end = final_line
|
|
398
|
+
else:
|
|
399
|
+
hunks.append(Hunk(start=final_line, end=final_line, sha=sha))
|
|
400
|
+
return hunks
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def commit_date(sha: str, cwd: Path) -> str | None:
|
|
404
|
+
result = _run_git(["log", "-1", "--format=%cI", sha], cwd)
|
|
405
|
+
if result is None or result.returncode != 0:
|
|
406
|
+
return None
|
|
407
|
+
return result.stdout.strip() or None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def trailer_decision_ids(sha: str, cwd: Path) -> list[str]:
|
|
411
|
+
"""§2 step 2, trailer join -- corrected literal (review Major 2, verified against
|
|
412
|
+
git 2.54): ``valueonly`` singular. The spec-rev-1 ``valuesonly`` is not a recognized
|
|
413
|
+
git option and git echoes the literal placeholder string back instead of erroring --
|
|
414
|
+
a silent-failure literal this exact spelling avoids.
|
|
415
|
+
|
|
416
|
+
§1's rendered candidate line is ``Sidegraph-Decision: <id> — <label>`` -- the label
|
|
417
|
+
is there to help a human pick which candidate to keep, not part of the trailer's
|
|
418
|
+
real value, but a straight "uncomment the line" (the documented gesture) makes the
|
|
419
|
+
literal trailer value ``"<id> — <label>"``, decorative suffix included (measured,
|
|
420
|
+
end-to-end smoke). Each comma-separated value is trimmed to its leading token up to
|
|
421
|
+
the first `` — `` / `` -- `` so the join still resolves whether or not a human
|
|
422
|
+
bothered to hand-trim the label first."""
|
|
423
|
+
result = _run_git(
|
|
424
|
+
[
|
|
425
|
+
"log",
|
|
426
|
+
"-1",
|
|
427
|
+
f"--format=%(trailers:key={TRAILER_KEY},valueonly,separator=%x2C)",
|
|
428
|
+
sha,
|
|
429
|
+
],
|
|
430
|
+
cwd,
|
|
431
|
+
)
|
|
432
|
+
if result is None or result.returncode != 0:
|
|
433
|
+
return []
|
|
434
|
+
raw = result.stdout.strip()
|
|
435
|
+
if not raw:
|
|
436
|
+
return []
|
|
437
|
+
ids: list[str] = []
|
|
438
|
+
for part in raw.split(","):
|
|
439
|
+
part = part.strip()
|
|
440
|
+
if not part:
|
|
441
|
+
continue
|
|
442
|
+
part = re.split(r"\s+—\s+|\s+--\s+", part, maxsplit=1)[0].strip()
|
|
443
|
+
if part:
|
|
444
|
+
ids.append(part)
|
|
445
|
+
return ids
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@dataclass
|
|
449
|
+
class ResolvedRecord:
|
|
450
|
+
id: str
|
|
451
|
+
kind: str | None # "decision" | "fact" | None (unresolved)
|
|
452
|
+
label: str = ""
|
|
453
|
+
superseded_by: str | None = None
|
|
454
|
+
resolved: bool = True
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _all_records(conn: sqlite3.Connection) -> dict[str, dict]:
|
|
458
|
+
"""Every decision + fact row, keyed by id, parsed once."""
|
|
459
|
+
out: dict[str, dict] = {}
|
|
460
|
+
for table in ("decisions", "facts"):
|
|
461
|
+
for row in conn.execute(f"SELECT data FROM {table}"):
|
|
462
|
+
rec = _load_json(row["data"])
|
|
463
|
+
if rec is not None and rec.get("id"):
|
|
464
|
+
rec["_kind"] = "decision" if table == "decisions" else "fact"
|
|
465
|
+
out[rec["id"]] = rec
|
|
466
|
+
return out
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _supersessions(records: dict[str, dict]) -> dict[str, str]:
|
|
470
|
+
"""predecessor id -> successor id, from every record's own ``supersedes`` field."""
|
|
471
|
+
out: dict[str, str] = {}
|
|
472
|
+
for rec in records.values():
|
|
473
|
+
pred = rec.get("supersedes")
|
|
474
|
+
if pred:
|
|
475
|
+
out[pred] = rec["id"]
|
|
476
|
+
return out
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def records_by_commit(conn: sqlite3.Connection) -> dict[str, list[dict]]:
|
|
480
|
+
"""§2 step 2, provenance join -- decisions AND facts (post-П0) whose
|
|
481
|
+
``provenance.commit == sha``, bucketed once (not per hunk/sha)."""
|
|
482
|
+
out: dict[str, list[dict]] = {}
|
|
483
|
+
records = _all_records(conn)
|
|
484
|
+
for rec in records.values():
|
|
485
|
+
commit = (rec.get("provenance") or {}).get("commit")
|
|
486
|
+
if commit:
|
|
487
|
+
out.setdefault(commit, []).append(rec)
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def resolve_sha(
|
|
492
|
+
sha: str,
|
|
493
|
+
cwd: Path,
|
|
494
|
+
records: dict[str, dict],
|
|
495
|
+
supersessions: dict[str, str],
|
|
496
|
+
by_commit: dict[str, list[dict]],
|
|
497
|
+
) -> list[ResolvedRecord]:
|
|
498
|
+
"""Both join paths, deduped by id (§2 step 2/4): trailers first, then provenance-only
|
|
499
|
+
additions. A resolved record whose id is now superseded prints its ``superseded_by``
|
|
500
|
+
pointer (§2 step 4) -- blame surfaces history, the chain does the rest."""
|
|
501
|
+
ids: dict[str, None] = {}
|
|
502
|
+
for rid in trailer_decision_ids(sha, cwd):
|
|
503
|
+
ids.setdefault(rid, None)
|
|
504
|
+
for rec in by_commit.get(sha, []):
|
|
505
|
+
ids.setdefault(rec["id"], None)
|
|
506
|
+
|
|
507
|
+
out: list[ResolvedRecord] = []
|
|
508
|
+
for rid in ids:
|
|
509
|
+
resolved_rec = records.get(rid)
|
|
510
|
+
if resolved_rec is None:
|
|
511
|
+
out.append(ResolvedRecord(id=rid, kind=None, resolved=False))
|
|
512
|
+
continue
|
|
513
|
+
label_field = "title" if resolved_rec["_kind"] == "decision" else "statement"
|
|
514
|
+
out.append(
|
|
515
|
+
ResolvedRecord(
|
|
516
|
+
id=rid,
|
|
517
|
+
kind=resolved_rec["_kind"],
|
|
518
|
+
label=_truncate(resolved_rec.get(label_field) or "", limit=80),
|
|
519
|
+
superseded_by=supersessions.get(rid),
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
return out
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@dataclass
|
|
526
|
+
class BlameHunkResult:
|
|
527
|
+
start: int
|
|
528
|
+
end: int
|
|
529
|
+
sha: str
|
|
530
|
+
date: str | None
|
|
531
|
+
records: list[ResolvedRecord]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def blame_report(
|
|
535
|
+
path: str, cwd: Path, store_dir: Path, line_range: tuple[int, int] | None = None
|
|
536
|
+
) -> list[BlameHunkResult] | None:
|
|
537
|
+
"""The full П2 pipeline: git blame -> per-distinct-sha join (trailers + provenance)
|
|
538
|
+
-> resolved records, in hunk (line-range) order -- the single entry point
|
|
539
|
+
``cli.blame_main`` calls; formatting/capping/JSON stay in ``cli.py``.
|
|
540
|
+
|
|
541
|
+
``None`` only when git blame itself fails (not a repo, unknown file/range, git
|
|
542
|
+
missing -- G9's CLI error surface). A missing or lock-timed-out index degrades to
|
|
543
|
+
every hunk resolving to nothing (``ResolvedRecord.resolved=False`` is never used
|
|
544
|
+
here for that case -- an index-less run simply can't join at all, so every hunk's
|
|
545
|
+
``records`` list comes back empty) rather than a hard failure: blame without a store
|
|
546
|
+
is still useful blame."""
|
|
547
|
+
hunks = blame_hunks(path, cwd, line_range)
|
|
548
|
+
if hunks is None:
|
|
549
|
+
return None
|
|
550
|
+
conn = open_index_ro(store_dir)
|
|
551
|
+
records: dict[str, dict] = {}
|
|
552
|
+
supersessions: dict[str, str] = {}
|
|
553
|
+
by_commit: dict[str, list[dict]] = {}
|
|
554
|
+
if conn is not None:
|
|
555
|
+
try:
|
|
556
|
+
records = _all_records(conn)
|
|
557
|
+
supersessions = _supersessions(records)
|
|
558
|
+
by_commit = records_by_commit(conn)
|
|
559
|
+
finally:
|
|
560
|
+
conn.close()
|
|
561
|
+
date_cache: dict[str, str | None] = {}
|
|
562
|
+
out: list[BlameHunkResult] = []
|
|
563
|
+
for h in hunks:
|
|
564
|
+
if h.sha not in date_cache:
|
|
565
|
+
date_cache[h.sha] = commit_date(h.sha, cwd)
|
|
566
|
+
resolved = resolve_sha(h.sha, cwd, records, supersessions, by_commit)
|
|
567
|
+
out.append(
|
|
568
|
+
BlameHunkResult(
|
|
569
|
+
start=h.start, end=h.end, sha=h.sha, date=date_cache[h.sha], records=resolved
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
return out
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Host seam — Claude Code integration, isolated from the portable core.
|
|
2
|
+
|
|
3
|
+
v1 ships Claude-Code-only. The core (store + retrieval + MCP) never imports from here; this
|
|
4
|
+
package holds the host-specific hooks (SessionStart / Stop / PreToolUse) so a second host
|
|
5
|
+
(Cursor, Cline, plain MCP) is *possible* later without touching the core. Multi-host support
|
|
6
|
+
is explicitly deferred past v1.
|
|
7
|
+
"""
|