awgit 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.
awgit/capture.py ADDED
@@ -0,0 +1,441 @@
1
+ """Edit-op capture — turn a git commit into a semantic ``EditOp``.
2
+
3
+ Flow: changed files (git) → for each Python file parse the parent and commit
4
+ blobs via ``parse_source_bytes`` → diff the node sets keyed by stable node id
5
+ (``StableNodeIDManager``) → emit ``NodeChange`` records whose body hashes come
6
+ from the EXACT commit blobs, never the live index. That is the anti-staleness
7
+ guarantee: node *identity* is graph-derived, body *content* is commit-derived,
8
+ so an op stays self-consistent even if the index has moved on (D-1640 class).
9
+
10
+ Python-tree-first: non-``.py`` files are recorded in ``file_paths`` but skipped
11
+ at the node level in M1 (file-level fallback ops are a later refinement).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ import json
18
+ import logging
19
+ import os
20
+ import subprocess
21
+ import time
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ from awgit.bodies import BodyStore
27
+ from awgit.bodies import blob_sha as _blob_sha
28
+ from awgit.data_root import vcs_data_root
29
+ from awgit.ledger import mint_ledger_ref
30
+ from awgit.oplog import FileLock, OpLog
31
+ from awgit.schema import EditOp, NodeChange
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def default_repo() -> Path:
37
+ return Path(os.environ.get("VCS_REPO_ROOT", os.getcwd()))
38
+
39
+
40
+ def _git(repo: Path, *args: str) -> bytes:
41
+ return subprocess.run(
42
+ ["git", *args], cwd=str(repo), capture_output=True, check=True
43
+ ).stdout
44
+
45
+
46
+ def git_parent(repo: Path, sha: str) -> Optional[str]:
47
+ out = _git(repo, "rev-list", "--parents", "-n", "1", sha).decode().split()
48
+ return out[1] if len(out) > 1 else None
49
+
50
+
51
+ def git_changed_files(repo: Path, sha: str) -> List[str]:
52
+ out = _git(repo, "show", "--format=", "--name-only", sha).decode()
53
+ return [ln for ln in out.splitlines() if ln.strip()]
54
+
55
+
56
+ def git_blob(repo: Path, sha: str, rel_path: str) -> Optional[bytes]:
57
+ try:
58
+ return _git(repo, "show", f"{sha}:{rel_path}")
59
+ except subprocess.CalledProcessError:
60
+ return None
61
+
62
+
63
+ # ── actor provenance ──────────────────────────────────────────────────────
64
+
65
+ _GITHUB_TTL_SEC = 6 * 3600 # refresh the verified identity at most this often
66
+
67
+
68
+ def _git_author(repo: Path, sha: str) -> str:
69
+ """The commit's real author (``Name <email>``) — independent provenance."""
70
+ try:
71
+ out = _git(
72
+ repo, "show", "-s", "--format=%an <%ae>", sha
73
+ ).decode("utf-8", errors="replace").strip()
74
+ return out
75
+ except subprocess.CalledProcessError:
76
+ return ""
77
+
78
+
79
+ def _github_identity(data_root: Path) -> Optional[str]:
80
+ """Verified GitHub login via ``gh`` (the Aitherium GitHub OAuth-app identity).
81
+
82
+ Best-effort and never load-bearing: returns None on any failure (no ``gh``
83
+ on PATH, offline, unauthenticated) so capture never depends on the network.
84
+ Cached in the vcs store for ``_GITHUB_TTL_SEC`` so per-commit hooks hit the
85
+ network at most once per window. Phase 6+ makes this identity (via the
86
+ Aitherium GitHub OAuth app for humans and the Aitherium GitHub app for the
87
+ agent fleet) the authoritative actor source; the schema already records it
88
+ via ``verified_actor`` / ``actor_verified``.
89
+ """
90
+ cache = data_root / "identity.json"
91
+ if cache.exists():
92
+ try:
93
+ rec = json.loads(cache.read_text(encoding="utf-8"))
94
+ if time.time() - rec.get("ts", 0) < _GITHUB_TTL_SEC and rec.get("login"):
95
+ return rec["login"]
96
+ except (OSError, ValueError):
97
+ logger.debug("vcs: identity cache unreadable, re-resolving")
98
+ try:
99
+ out = subprocess.run(
100
+ ["gh", "api", "user", "-q", ".login"],
101
+ capture_output=True, text=True, encoding="utf-8",
102
+ timeout=5, check=True,
103
+ ).stdout.strip()
104
+ except (OSError, subprocess.SubprocessError):
105
+ return None
106
+ if not out:
107
+ return None
108
+ try:
109
+ data_root.mkdir(parents=True, exist_ok=True)
110
+ cache.write_text(
111
+ json.dumps({"ts": time.time(), "login": out}), encoding="utf-8"
112
+ )
113
+ except OSError:
114
+ logger.debug("vcs: identity cache write failed (best-effort)")
115
+ return out
116
+
117
+
118
+ def resolve_actor(
119
+ actor_arg: Optional[str], repo: Path, git_sha: str, data_root: Path
120
+ ) -> Dict[str, object]:
121
+ """Resolve the claimed actor + verified identity for an op.
122
+
123
+ Claimed-actor priority: explicit arg → ``AITHER_ACTOR`` env → verified
124
+ GitHub login → commit author. ``actor_verified``/``verified_actor`` record
125
+ the box's VERIFIED identity independently of the claimed actor, so a
126
+ session claiming ``AITHER_ACTOR=lyra`` on a box verified as ``wizzense``
127
+ records both — attribution stays honest without losing the agent name.
128
+ """
129
+ github = _github_identity(data_root)
130
+ env_actor = os.environ.get("AITHER_ACTOR")
131
+ if actor_arg:
132
+ actor, source = actor_arg, "arg"
133
+ elif env_actor:
134
+ actor, source = env_actor, "env"
135
+ elif github:
136
+ actor, source = github, "github"
137
+ else:
138
+ author = _git_author(repo, git_sha)
139
+ actor, source = author or "unknown", "git-author" if author else "unknown"
140
+ return {
141
+ "actor": actor,
142
+ "actor_source": source,
143
+ "actor_verified": bool(github),
144
+ "verified_actor": github or "",
145
+ }
146
+
147
+
148
+ def _ancestor_shas(repo: Path, sha: str) -> List[str]:
149
+ """First-parent chain of ``sha``, closest first, excluding ``sha``."""
150
+ out = _git(repo, "rev-list", "--first-parent", sha).decode().split()
151
+ return out[1:] # first entry is sha itself
152
+
153
+
154
+ def _node_records(src: Optional[bytes], rel_path: str) -> List[Dict[str, Any]]:
155
+ if src is None:
156
+ return []
157
+ # lazy: CodeGraph's import chain is ~15s (AitherConfig auto-tune etc.); only
158
+ # pay it when actually parsing (capture/diff/merge runtime), never for
159
+ # `vcs lease`/`status` which import this module but never parse.
160
+ from awgit.parser import parse_source_bytes
161
+
162
+ graph = parse_source_bytes(src, rel_path)
163
+ lines = src.decode("utf-8", errors="ignore").split("\n")
164
+ recs: List[Dict[str, Any]] = []
165
+ for chunk in graph.chunks:
166
+ body = ""
167
+ if chunk.end_line:
168
+ body = "\n".join(lines[chunk.start_line - 1: chunk.end_line])
169
+ recs.append({
170
+ "name": chunk.name,
171
+ "path": rel_path,
172
+ "type": chunk.chunk_type.value,
173
+ "signature": chunk.signature or "",
174
+ "body": body,
175
+ })
176
+ return recs
177
+
178
+
179
+ def _body_sha(rec: Dict[str, Any]) -> Optional[str]:
180
+ body = rec.get("body") or ""
181
+ return _blob_sha(body.encode("utf-8")) if body else None
182
+
183
+
184
+ def _diff_node_sets(
185
+ old: List[Dict[str, Any]],
186
+ new: List[Dict[str, Any]],
187
+ manager: StableNodeIDManager,
188
+ store: Optional[BodyStore] = None,
189
+ ) -> List[NodeChange]:
190
+ """Diff two node sets keyed by stable node id.
191
+
192
+ Rename detection pairs genuinely-ADDED names with genuinely-DELETED names
193
+ (same file, same type, similar body). A name present in BOTH sets is
194
+ unchanged or rewritten — NEVER a rename candidate — which is what keeps a
195
+ coincidentally-similar ADDED function from being misread as a rename (a
196
+ tiny function's body is structurally confusable at ~0.75-0.8 similarity).
197
+ The renamed node reuses the old stable id; the persisted legacy alias lets
198
+ a divergent branch still resolve the pre-rename symbol to it (the M5
199
+ rename gate).
200
+ """
201
+ old_by: Dict[str, Dict[str, Any]] = {
202
+ manager.id_for(r["name"], r["path"], r["type"]): r for r in old
203
+ }
204
+ old_sid_by_name: Dict[str, str] = {}
205
+ for sid, r in old_by.items():
206
+ old_sid_by_name.setdefault(r["name"], sid)
207
+
208
+ old_names = {r["name"] for r in old}
209
+ new_names = {r["name"] for r in new}
210
+ added_cands = [r for r in new if r["name"] not in old_names]
211
+ deleted_cands = [r for r in old if r["name"] not in new_names]
212
+ rename_pairs: List[tuple] = []
213
+ used_old_names: set = set()
214
+ for nr in added_cands:
215
+ best_o, best_score = None, 0.0
216
+ for o in deleted_cands:
217
+ if o["name"] in used_old_names:
218
+ continue
219
+ if o["path"] != nr["path"] or o["type"] != nr["type"]:
220
+ continue
221
+ score = _similarity(o["body"], nr["body"])
222
+ if score > best_score:
223
+ best_o, best_score = o, score
224
+ if best_o is not None and best_score >= _RENAME_SIMILARITY:
225
+ used_old_names.add(best_o["name"])
226
+ rename_pairs.append((best_o, nr))
227
+
228
+ new_by: Dict[str, Dict[str, Any]] = {}
229
+ for o, nr in rename_pairs:
230
+ sid = old_sid_by_name[o["name"]]
231
+ manager.rename_node(sid, nr["name"])
232
+ new_by[sid] = nr
233
+ used_new_names = {nr["name"] for _, nr in rename_pairs}
234
+ for r in new:
235
+ if r["name"] in used_new_names:
236
+ continue
237
+ new_by[manager.id_for(r["name"], r["path"], r["type"])] = r
238
+
239
+ changes: List[NodeChange] = []
240
+ for o, nr in rename_pairs:
241
+ sid = old_sid_by_name[o["name"]]
242
+ changes.append(NodeChange(
243
+ node_id=sid,
244
+ change_type="renamed",
245
+ old_body_sha=_body_sha(o),
246
+ new_body_sha=_body_sha(nr),
247
+ symbol=nr["name"],
248
+ path=nr["path"],
249
+ renamed_from=o["name"],
250
+ ))
251
+ renamed_sids = {old_sid_by_name[o["name"]] for o, _ in rename_pairs}
252
+ for sid in sorted(old_by.keys() & new_by.keys()):
253
+ if sid in renamed_sids:
254
+ continue # consumed by a rename above
255
+ o, n = old_by[sid], new_by[sid]
256
+ osha, nsha = _body_sha(o), _body_sha(n)
257
+ if osha == nsha:
258
+ continue
259
+ ctype = (
260
+ "signature_changed"
261
+ if o["signature"] != n["signature"]
262
+ else "body_rewrite"
263
+ )
264
+ changes.append(NodeChange(
265
+ node_id=sid,
266
+ change_type=ctype,
267
+ old_body_sha=osha,
268
+ new_body_sha=nsha,
269
+ symbol=n["name"],
270
+ path=n["path"],
271
+ ))
272
+ for sid in sorted(new_by.keys() - old_by.keys()):
273
+ n = new_by[sid]
274
+ changes.append(NodeChange(
275
+ node_id=sid,
276
+ change_type="added",
277
+ new_body_sha=_body_sha(n),
278
+ symbol=n["name"],
279
+ path=n["path"],
280
+ ))
281
+ for sid in sorted(old_by.keys() - new_by.keys()):
282
+ if sid in renamed_sids:
283
+ continue
284
+ o = old_by[sid]
285
+ changes.append(NodeChange(
286
+ node_id=sid,
287
+ change_type="deleted",
288
+ old_body_sha=_body_sha(o),
289
+ symbol=o["name"],
290
+ path=o["path"],
291
+ ))
292
+ if store is not None:
293
+ # Materialize the bodies the op references so the op-log is
294
+ # self-contained — reconstructable WITHOUT git blobs (which live on the
295
+ # failing D: drive). Content addressing dedupes: identical bodies (across
296
+ # commits/branches/worktrees) collapse to one blob.
297
+ bodies: Dict[str, bytes] = {}
298
+ for r in list(old) + list(new):
299
+ body = r.get("body")
300
+ if body:
301
+ b = body.encode("utf-8")
302
+ bodies[_blob_sha(b)] = b
303
+ for nc in changes:
304
+ for sha in (nc.old_body_sha, nc.new_body_sha):
305
+ if sha and sha in bodies:
306
+ store.put(bodies[sha])
307
+ return changes
308
+
309
+
310
+ _RENAME_SIMILARITY = 0.75
311
+
312
+
313
+ def _similarity(a: str, b: str) -> float:
314
+ if not a and not b:
315
+ return 1.0
316
+ if not a or not b:
317
+ return 0.0
318
+ return difflib.SequenceMatcher(None, a, b).ratio()
319
+
320
+
321
+ def diff_sources(
322
+ a_src: Optional[bytes],
323
+ b_src: Optional[bytes],
324
+ rel_path: str,
325
+ manager: StableNodeIDManager,
326
+ store: Optional[BodyStore] = None,
327
+ ) -> List[NodeChange]:
328
+ """Node-level diff of two source blobs (shared by capture and diff)."""
329
+ return _diff_node_sets(
330
+ _node_records(a_src, rel_path),
331
+ _node_records(b_src, rel_path),
332
+ manager,
333
+ store,
334
+ )
335
+
336
+
337
+ def load_node_manager(data_root: Path) -> "StableNodeIDManager":
338
+ """Read-only stable-id manager over a persisted registry, if one exists.
339
+
340
+ ``persist=False`` + explicit ``_load`` keeps a pure diff from WRITING the
341
+ registry — unseen symbols get ephemeral ids for the call without being
342
+ registered (a diff must not mutate the node universe).
343
+ """
344
+ from awgit.nodeid import StableNodeIDManager
345
+
346
+ path = data_root / "nodes.json"
347
+ if path.exists():
348
+ mgr = StableNodeIDManager(path=path, persist=False)
349
+ mgr._load() # init skips the load when persist=False
350
+ return mgr
351
+ return StableNodeIDManager()
352
+
353
+
354
+ def _make_summary(actor: str, changes: List[NodeChange]) -> str:
355
+ counts: Dict[str, int] = {}
356
+ for c in changes:
357
+ counts[c.change_type] = counts.get(c.change_type, 0) + 1
358
+ parts = [f"{counts[k]} {k}" for k in sorted(counts)]
359
+ who = actor or "unknown"
360
+ return f"{who}: {', '.join(parts)}" if parts else f"{who}: no semantic changes"
361
+
362
+
363
+ def capture_ops(
364
+ git_sha: str,
365
+ *,
366
+ actor: Optional[str] = None,
367
+ repo_path: Optional[str] = None,
368
+ root_path: Optional[str] = None,
369
+ data_root: Optional[Path] = None,
370
+ ) -> Optional[EditOp]:
371
+ """Capture ``git_sha`` as an ``EditOp`` (idempotent by commit).
372
+
373
+ Returns None when there is nothing to record (root commit, no Python
374
+ semantic changes) or the op already exists. Serialized by a per-store
375
+ file lock so concurrent post-commit hooks cannot tear the node registry
376
+ or double-log a commit.
377
+ """
378
+ repo = Path(repo_path or default_repo())
379
+ data = data_root or vcs_data_root(root_path=root_path)
380
+
381
+ parent = git_parent(repo, git_sha)
382
+ if parent is None:
383
+ return None
384
+ files = git_changed_files(repo, git_sha)
385
+ if not files:
386
+ return None
387
+
388
+ # Provenance is resolved BEFORE the lock: the gh identity cache is a
389
+ # separate file, and the lock must never be held across a network call.
390
+ prov = resolve_actor(actor, repo, git_sha, data)
391
+ actor_name = str(prov["actor"])
392
+
393
+ data.mkdir(parents=True, exist_ok=True)
394
+ with FileLock(data / "capture.lock"):
395
+ from awgit.nodeid import StableNodeIDManager
396
+
397
+ oplog = OpLog(data_root=data)
398
+ if oplog.has_commit(git_sha):
399
+ return oplog.ops_for_commit(git_sha)[0]
400
+
401
+ manager = StableNodeIDManager(path=data / "nodes.json", persist=True)
402
+ store = BodyStore(data_root=data)
403
+ node_changes: List[NodeChange] = []
404
+ for rel in files:
405
+ if not rel.endswith(".py"):
406
+ continue
407
+ old_src = git_blob(repo, parent, rel)
408
+ new_src = git_blob(repo, git_sha, rel)
409
+ node_changes.extend(diff_sources(old_src, new_src, rel, manager, store))
410
+ if not node_changes:
411
+ return None
412
+
413
+ ts = datetime.now(timezone.utc).isoformat()
414
+ sha_to_op = oplog.sha_index()
415
+ parent_ops = [
416
+ sha_to_op[a] for a in _ancestor_shas(repo, git_sha) if a in sha_to_op
417
+ ]
418
+ op_id = os.urandom(16).hex()
419
+ op = EditOp(
420
+ op_id=op_id,
421
+ parent_ops=parent_ops,
422
+ actor=actor_name,
423
+ ts=ts,
424
+ git_sha=git_sha,
425
+ git_parent_sha=parent,
426
+ file_paths=files,
427
+ node_changes=node_changes,
428
+ summary=_make_summary(actor_name, node_changes),
429
+ leased=False,
430
+ actor_verified=bool(prov["actor_verified"]),
431
+ actor_source=str(prov["actor_source"]),
432
+ verified_actor=str(prov["verified_actor"]),
433
+ ledger_ref=mint_ledger_ref(op_id, git_sha),
434
+ )
435
+ oplog.append(op)
436
+ # a self-capture is by definition "applied" on this node — so
437
+ # sync_status shows it as covered, not missing.
438
+ from awgit.sync import mark_op_applied
439
+
440
+ mark_op_applied(op.op_id, data_root=data)
441
+ return op