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/merge.py ADDED
@@ -0,0 +1,331 @@
1
+ """Merge engine — node-granularity merge over op-sets.
2
+
3
+ Two op-sets A and B merge by touching NODE sets, not lines:
4
+
5
+ - disjoint nodes -> clean by construction (no shared graph content)
6
+ - shared node, identical bodies -> trivial merge (keep one)
7
+ - shared node, line-compatible edits -> arbitrated: git's own 3-way merge is
8
+ safe for disjoint hunks, so the engine APPROVES it (git does the bytes)
9
+ - shared node, otherwise -> conflict: a ``MergeConflict`` naming the exact
10
+ node, escalated to the human inbox (M5)
11
+
12
+ ``MergeResult.conflicts`` is the escalation path. Blast radius via
13
+ ``CodeGraph.impact_analysis`` is optional enrichment (``enrich=True``); the core
14
+ logic does not require the index, so ``merge_ops`` stays fast and testable.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import os
22
+ import subprocess
23
+ import uuid
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime, timezone
26
+ from difflib import SequenceMatcher
27
+ from pathlib import Path
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ from awgit.capture import default_repo, git_blob
31
+ from awgit.data_root import vcs_data_root
32
+ from awgit.diff import diff_opsets
33
+ from awgit.oplog import FileLock, OpLog
34
+ from awgit.schema import EditOp, MergeConflict, NodeChange
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class MergeResult:
41
+ status: str # "clean" | "arbitrated" | "conflict"
42
+ merged_node_ids: List[str] = field(default_factory=list)
43
+ conflicts: List[MergeConflict] = field(default_factory=list)
44
+ notes: List[str] = field(default_factory=list)
45
+
46
+
47
+ # ── git helpers ──────────────────────────────────────────────────────────
48
+
49
+ def _git(repo: Path, *args: str) -> str:
50
+ return subprocess.run(
51
+ ["git", *args], cwd=str(repo), capture_output=True, text=True,
52
+ encoding="utf-8", errors="replace", check=True,
53
+ ).stdout.strip()
54
+
55
+
56
+ def _merge_base(repo: Path, a_ops: List[EditOp], b_ops: List[EditOp]) -> str:
57
+ a_sha = max(a_ops, key=lambda o: o.ts).git_sha if a_ops else "HEAD"
58
+ b_sha = max(b_ops, key=lambda o: o.ts).git_sha if b_ops else "HEAD"
59
+ try:
60
+ return _git(repo, "merge-base", a_sha, b_sha)
61
+ except subprocess.CalledProcessError:
62
+ return "HEAD"
63
+
64
+
65
+ def _op_sha(ops: List[EditOp], nc: NodeChange) -> str:
66
+ for op in sorted(ops, key=lambda o: o.ts, reverse=True):
67
+ if any(c.node_id == nc.node_id for c in op.node_changes):
68
+ return op.git_sha
69
+ return ops[0].git_sha
70
+
71
+
72
+ def _body_at(repo: Path, sha: str, path: str, symbol: str) -> Optional[str]:
73
+ """Slice ``symbol``'s body out of the ``sha:path`` blob (None if absent)."""
74
+ blob = git_blob(repo, sha, path)
75
+ if blob is None:
76
+ return None
77
+ # lazy — see capture._node_records (CodeGraph import chain is ~15s)
78
+ from awgit.parser import parse_source_bytes
79
+
80
+ graph = parse_source_bytes(blob, path)
81
+ lines = blob.decode("utf-8", errors="ignore").split("\n")
82
+ for chunk in graph.chunks:
83
+ if chunk.name == symbol and chunk.end_line:
84
+ return "\n".join(lines[chunk.start_line - 1: chunk.end_line])
85
+ return None
86
+
87
+
88
+ # ── arbitration primitives ───────────────────────────────────────────────
89
+
90
+ def _changed_ranges(base_body: str, other_body: str) -> List[tuple]:
91
+ """Line ranges (in BASE coordinates) that ``other_body`` changed."""
92
+ sm = SequenceMatcher(a=base_body.splitlines(), b=other_body.splitlines())
93
+ ranges: List[tuple] = []
94
+ for tag, i1, i2, _j1, _j2 in sm.get_opcodes():
95
+ if tag != "equal":
96
+ ranges.append((i1, i2))
97
+ return ranges
98
+
99
+
100
+ _MIN_MERGE_GAP = 2 # unchanged lines required between changed ranges
101
+
102
+
103
+ def _line_compatible(base_body, a_body, b_body) -> bool:
104
+ """True if A's and B's changes merge cleanly under git's 3-way merge.
105
+
106
+ Disjoint ranges are NOT enough: git merges hunks with ~3 lines of context,
107
+ so ADJACENT changes (gap 0) conflict even though the lines don't overlap.
108
+ Measured 2026-08-08 with a true 3-way merge sweep: gap 0 -> CONFLICT,
109
+ gap >= 1 -> CLEAN. We require >= _MIN_MERGE_GAP unchanged lines for margin.
110
+ """
111
+ if base_body is None or a_body is None or b_body is None:
112
+ return False
113
+ ranges_a = _changed_ranges(base_body, a_body)
114
+ ranges_b = _changed_ranges(base_body, b_body)
115
+ if not ranges_a or not ranges_b:
116
+ return False
117
+ for r1 in ranges_a:
118
+ for r2 in ranges_b:
119
+ if not _ranges_separated(r1, r2):
120
+ return False
121
+ return True
122
+
123
+
124
+ def _ranges_separated(r1, r2, gap: int = _MIN_MERGE_GAP) -> bool:
125
+ """True if [lo,hi) ranges are separated by >= ``gap`` unchanged lines."""
126
+ lo1, hi1 = r1
127
+ lo2, hi2 = r2
128
+ if hi1 <= lo2:
129
+ dist = lo2 - hi1
130
+ elif hi2 <= lo1:
131
+ dist = lo1 - hi2
132
+ else:
133
+ return False # overlapping
134
+ return dist >= gap
135
+
136
+
137
+ def _blast_radius(node_id: str, symbol: str, path: str) -> Dict[str, Any]:
138
+ """Best-effort impact enrichment — never required, never fatal.
139
+
140
+ Standalone awgit has no code-graph index, so there is no impact analysis
141
+ to attach here — the conflict record already carries the bodies and the
142
+ symbol, which is what the resolver needs. (The AitherOS monorepo's awgit
143
+ enriches the same hook with its live CodeGraph before escalating.)
144
+ """
145
+ return {}
146
+
147
+
148
+ # ── conflict store ───────────────────────────────────────────────────────
149
+
150
+ def _conflicts_path(data_root: Path) -> Path:
151
+ return data_root / "conflicts.jsonl"
152
+
153
+
154
+ def append_conflicts(data_root: Path, conflicts: List[MergeConflict]) -> None:
155
+ """Append conflicts to the escalation log (append-only, fsync'd)."""
156
+ data_root.mkdir(parents=True, exist_ok=True)
157
+ with FileLock(data_root / "conflicts.lock"):
158
+ with open(_conflicts_path(data_root), "a", encoding="utf-8") as f:
159
+ for c in conflicts:
160
+ f.write(json.dumps(c.to_dict()) + "\n")
161
+ f.flush()
162
+ os.fsync(f.fileno())
163
+
164
+
165
+ def list_conflicts(data_root: Optional[Path] = None) -> List[MergeConflict]:
166
+ """Read the escalation log (all statuses)."""
167
+ data = data_root or vcs_data_root()
168
+ path = _conflicts_path(data)
169
+ if not path.exists():
170
+ return []
171
+ out: List[MergeConflict] = []
172
+ with FileLock(data / "conflicts.lock"):
173
+ with open(path, encoding="utf-8") as f:
174
+ for line in f:
175
+ line = line.strip()
176
+ if not line:
177
+ continue
178
+ out.append(MergeConflict.from_dict(json.loads(line)))
179
+ return out
180
+
181
+
182
+ # ── the engine ───────────────────────────────────────────────────────────
183
+
184
+ def merge_ops(
185
+ a_ops: List[EditOp],
186
+ b_ops: List[EditOp],
187
+ *,
188
+ repo_path: Optional[str] = None,
189
+ data_root: Optional[Path] = None,
190
+ enrich: bool = True,
191
+ ) -> MergeResult:
192
+ """Merge two op-sets at node granularity.
193
+
194
+ Returns ``MergeResult``; ``status == "conflict"`` means a human must
195
+ resolve (see ``conflicts``). Callers run the actual byte-merge (git) only
196
+ for clean / arbitrated results.
197
+ """
198
+ repo = Path(repo_path or default_repo())
199
+ data = data_root or vcs_data_root()
200
+ overlap = diff_opsets(a_ops, b_ops)
201
+ merged = overlap["only_a"] + overlap["only_b"]
202
+ if not overlap["shared"]:
203
+ return MergeResult(
204
+ status="clean",
205
+ merged_node_ids=merged,
206
+ notes=["disjoint node sets — clean by construction"],
207
+ )
208
+
209
+ a_by_node = {nc.node_id: nc for op in a_ops for nc in op.node_changes}
210
+ b_by_node = {nc.node_id: nc for op in b_ops for nc in op.node_changes}
211
+ base_sha = _merge_base(repo, a_ops, b_ops)
212
+ conflicts: List[MergeConflict] = []
213
+ notes: List[str] = []
214
+ arbitrated = 0
215
+ for node_id in sorted(overlap["shared"]):
216
+ a_nc, b_nc = a_by_node.get(node_id), b_by_node.get(node_id)
217
+ if a_nc is None or b_nc is None:
218
+ continue
219
+ a_sha, b_sha = _op_sha(a_ops, a_nc), _op_sha(b_ops, b_nc)
220
+ base_body = _body_at(repo, base_sha, a_nc.path, a_nc.symbol)
221
+ a_body = _body_at(repo, a_sha, a_nc.path, a_nc.symbol)
222
+ b_body = _body_at(repo, b_sha, b_nc.path, b_nc.symbol)
223
+ if a_body is not None and a_body == b_body:
224
+ notes.append(f"{node_id}: identical change — trivial merge")
225
+ arbitrated += 1
226
+ continue
227
+ # M5 gate: a rename on EITHER side is high-risk (two agents renaming the
228
+ # same node to different names must escalate, never silently merge).
229
+ if "renamed" in (a_nc.change_type, b_nc.change_type):
230
+ notes.append(f"{node_id}: rename involved — high risk, escalating")
231
+ conflicts.append(_conflict(node_id, a_nc, base_body, a_body, b_body, enrich))
232
+ continue
233
+ if a_nc.change_type == "signature_changed" or b_nc.change_type == "signature_changed":
234
+ notes.append(f"{node_id}: signature changed — high risk, escalating")
235
+ conflicts.append(_conflict(node_id, a_nc, base_body, a_body, b_body, enrich))
236
+ continue
237
+ if _line_compatible(base_body, a_body, b_body):
238
+ notes.append(f"{node_id}: line-compatible edits — git 3-way merge safe")
239
+ arbitrated += 1
240
+ continue
241
+ conflicts.append(_conflict(node_id, a_nc, base_body, a_body, b_body, enrich))
242
+
243
+ if conflicts:
244
+ append_conflicts(data, conflicts)
245
+ status = "conflict"
246
+ elif arbitrated:
247
+ status = "arbitrated"
248
+ else:
249
+ status = "clean"
250
+ return MergeResult(
251
+ status=status, merged_node_ids=merged, conflicts=conflicts, notes=notes
252
+ )
253
+
254
+
255
+ def _conflict(
256
+ node_id: str,
257
+ nc: NodeChange,
258
+ base_body: Optional[str],
259
+ a_body: Optional[str],
260
+ b_body: Optional[str],
261
+ enrich: bool,
262
+ ) -> MergeConflict:
263
+ radius = _blast_radius(node_id, nc.symbol, nc.path) if enrich else {}
264
+ return MergeConflict(
265
+ conflict_id=uuid.uuid4().hex,
266
+ node_id=node_id,
267
+ symbol=nc.symbol,
268
+ path=nc.path,
269
+ base_body=base_body,
270
+ a_body=a_body,
271
+ b_body=b_body,
272
+ blast_radius=radius,
273
+ suggested=None,
274
+ status="escalated",
275
+ )
276
+
277
+
278
+ def resolve_conflict(
279
+ conflict_id: str,
280
+ *,
281
+ resolved_body: str,
282
+ resolver: str,
283
+ data_root: Optional[Path] = None,
284
+ ) -> Optional[EditOp]:
285
+ """Mark a conflict resolved and record the resolution as a synthetic EditOp.
286
+
287
+ The synthetic op carries ``actor="human:<resolver>"`` so the resolution is
288
+ attributed to a real human in the op-log. Phase 6+ (AitherIdentity) makes
289
+ this a verified identity rather than a self-asserted name.
290
+ """
291
+ data = data_root or vcs_data_root()
292
+ conflicts = list_conflicts(data_root=data)
293
+ target = next((c for c in conflicts if c.conflict_id == conflict_id), None)
294
+ if target is None:
295
+ return None
296
+ target.status = "resolved"
297
+ target.suggested = resolved_body
298
+ _rewrite_conflicts(data, conflicts)
299
+ op = EditOp(
300
+ op_id=os.urandom(16).hex(),
301
+ parent_ops=[],
302
+ actor=f"human:{resolver}",
303
+ ts=datetime.now(timezone.utc).isoformat(),
304
+ git_sha="",
305
+ git_parent_sha="",
306
+ file_paths=[target.path] if target.path else [],
307
+ node_changes=[
308
+ NodeChange(
309
+ node_id=target.node_id,
310
+ change_type="body_rewrite",
311
+ symbol=target.symbol,
312
+ path=target.path,
313
+ semantic_note=f"human resolution of conflict {conflict_id}",
314
+ )
315
+ ],
316
+ summary=f"human:{resolver} resolved {conflict_id}",
317
+ leased=False,
318
+ )
319
+ OpLog(data_root=data).append(op)
320
+ return op
321
+
322
+
323
+ def _rewrite_conflicts(data_root: Path, conflicts: List[MergeConflict]) -> None:
324
+ """Rewrite the escalation log with updated conflict statuses."""
325
+ data_root.mkdir(parents=True, exist_ok=True)
326
+ with FileLock(data_root / "conflicts.lock"):
327
+ with open(_conflicts_path(data_root), "w", encoding="utf-8") as f:
328
+ for c in conflicts:
329
+ f.write(json.dumps(c.to_dict()) + "\n")
330
+ f.flush()
331
+ os.fsync(f.fileno())
awgit/nodeid.py ADDED
@@ -0,0 +1,210 @@
1
+ """StableNodeIDLayer — rename-safe graph node identity.
2
+
3
+ The legacy CodeGraph id scheme ``{type}_{name}_{path_sha8}`` bakes the name and
4
+ path INTO the id, so renaming a function mints a new id and every ``calls`` /
5
+ ``called_by`` edge that referenced the old name dangles.
6
+
7
+ This layer makes the id a content-independent UUID with ``name``/``path`` as
8
+ *mutable properties* plus a redirect map. Renaming updates one node; edges keyed
9
+ by the stable id survive. Adopt it per-graph (CodeGraph first) behind a version
10
+ flag — it's a standalone component so existing graphs are untouched until they
11
+ opt in.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import threading
20
+ import uuid
21
+ from pathlib import Path
22
+ from typing import Dict, List, Optional
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class StableNodeIDManager:
28
+ """Registry of stable node ids with mutable name/path and redirects."""
29
+
30
+ def __init__(self, path: Optional[Path] = None, persist: bool = False):
31
+ self._persist = persist
32
+ self._path = Path(path) if path is not None else None
33
+ self._lock = threading.RLock()
34
+ self._nodes: Dict[str, Dict] = {} # stable_id -> {name, path, type, ...}
35
+ self._redirects: Dict[str, str] = {} # old_id -> new_id
36
+ self._name_index: Dict[str, List[str]] = {} # name -> [stable_id]
37
+ self._pathname_index: Dict[str, str] = {} # "name\x00path" -> stable_id
38
+ # legacy (name, path) keys -> sid, kept after a rename/move so captures
39
+ # on divergent branches still resolve the pre-rename symbol to the same
40
+ # id (the M5 rename gate). Persisted; rebuilt on load.
41
+ self._aliases: Dict[str, str] = {}
42
+ if self._persist and self._path and self._path.exists():
43
+ self._load()
44
+
45
+ # ── (name, path) → stable id (idempotent; reindex-preserving) ────────
46
+
47
+ @staticmethod
48
+ def _pn_key(name: str, path: str) -> str:
49
+ return f"{name}\x00{path}"
50
+
51
+ def id_for(self, name: str, path: str, type_: str = "") -> str:
52
+ """Return the stable id for ``(name, path)``, creating + registering one
53
+ on first sight. Idempotent — the SAME symbol gets the SAME id across
54
+ reindexes, which is what stops renames/edits from orphaning edges."""
55
+ with self._lock:
56
+ key = self._pn_key(name, path)
57
+ sid = self._pathname_index.get(key) or self._aliases.get(key)
58
+ if sid is not None and sid in self._nodes:
59
+ return sid
60
+ sid = self.generate_stable_id()
61
+ self.register_node(sid, name, path, type_)
62
+ return sid
63
+
64
+ def to_dict(self) -> Dict:
65
+ with self._lock:
66
+ return {
67
+ "nodes": self._nodes,
68
+ "redirects": self._redirects,
69
+ "aliases": self._aliases,
70
+ }
71
+
72
+ @classmethod
73
+ def from_dict(cls, data: Dict) -> "StableNodeIDManager":
74
+ mgr = cls()
75
+ mgr._nodes = dict(data.get("nodes", {}))
76
+ mgr._redirects = dict(data.get("redirects", {}))
77
+ mgr._aliases = dict(data.get("aliases", {}))
78
+ for sid, node in mgr._nodes.items():
79
+ nm = node.get("name", "")
80
+ mgr._name_index.setdefault(nm, []).append(sid)
81
+ mgr._pathname_index[cls._pn_key(nm, node.get("path", ""))] = sid
82
+ return mgr
83
+
84
+ # ── id lifecycle ────────────────────────────────────────────────────
85
+
86
+ @staticmethod
87
+ def generate_stable_id() -> str:
88
+ """A content-independent id — survives renames and path moves."""
89
+ return f"node_{uuid.uuid4().hex}"
90
+
91
+ def register_node(
92
+ self, stable_id: str, name: str, path: str, type_: str = "", **meta
93
+ ) -> None:
94
+ with self._lock:
95
+ self._nodes[stable_id] = {
96
+ "stable_id": stable_id, "name": name, "path": path,
97
+ "type": type_, "renamed_from": [], "path_history": [], **meta,
98
+ }
99
+ self._name_index.setdefault(name, []).append(stable_id)
100
+ self._pathname_index[self._pn_key(name, path)] = stable_id
101
+ self._save()
102
+
103
+ def get_node(self, stable_id: str) -> Optional[Dict]:
104
+ with self._lock:
105
+ return self._nodes.get(self.resolve_node_id(stable_id))
106
+
107
+ def by_name(self, name: str) -> List[str]:
108
+ with self._lock:
109
+ return [self.resolve_node_id(i) for i in self._name_index.get(name, [])]
110
+
111
+ # ── mutation that edges survive ─────────────────────────────────────
112
+
113
+ def rename_node(self, stable_id: str, new_name: str) -> bool:
114
+ with self._lock:
115
+ sid = self.resolve_node_id(stable_id)
116
+ node = self._nodes.get(sid)
117
+ if node is None:
118
+ return False
119
+ old = node["name"]
120
+ if old == new_name:
121
+ return True
122
+ node.setdefault("renamed_from", []).append(old)
123
+ node["name"] = new_name
124
+ # update name index (id is UNCHANGED — edges keyed by id survive)
125
+ self._name_index.get(old, [])
126
+ if sid in self._name_index.get(old, []):
127
+ self._name_index[old].remove(sid)
128
+ self._name_index.setdefault(new_name, []).append(sid)
129
+ # pathname index: point the NEW (name, path) key at the id. The OLD
130
+ # key is KEPT as a legacy alias — captures on divergent branches
131
+ # still resolve the pre-rename symbol to the same id (two agents
132
+ # renaming the same function must land on ONE node — the M5 rename
133
+ # gate). A future function reusing the old name aliases the same id
134
+ # (rare, accepted).
135
+ path = node.get("path", "")
136
+ if path:
137
+ # keep the pre-rename key as a persisted legacy alias
138
+ self._aliases[self._pn_key(old, path)] = sid
139
+ self._pathname_index[self._pn_key(new_name, path)] = sid
140
+ self._save()
141
+ return True
142
+
143
+ def move_node(self, stable_id: str, new_path: str) -> bool:
144
+ with self._lock:
145
+ sid = self.resolve_node_id(stable_id)
146
+ node = self._nodes.get(sid)
147
+ if node is None:
148
+ return False
149
+ old_path = node["path"]
150
+ if old_path == new_path:
151
+ return True
152
+ node.setdefault("path_history", []).append(old_path)
153
+ node["path"] = new_path
154
+ name = node.get("name", "")
155
+ if name:
156
+ # New-path key added; old-path key kept as a legacy alias
157
+ # (same rationale as rename_node).
158
+ self._aliases[self._pn_key(name, old_path)] = sid
159
+ self._pathname_index[self._pn_key(name, new_path)] = sid
160
+ self._save()
161
+ return True
162
+
163
+ def add_redirect(self, old_id: str, new_id: str) -> None:
164
+ """Point ``old_id`` at ``new_id`` (e.g. after a merge/dedup)."""
165
+ with self._lock:
166
+ self._redirects[old_id] = new_id
167
+ self._save()
168
+
169
+ def resolve_node_id(self, node_id: str) -> str:
170
+ seen = set()
171
+ cur = node_id
172
+ with self._lock:
173
+ while cur in self._redirects and cur not in seen:
174
+ seen.add(cur)
175
+ cur = self._redirects[cur]
176
+ return cur
177
+
178
+ # ── persistence (optional) ──────────────────────────────────────────
179
+
180
+ def _save(self) -> None:
181
+ if not (self._persist and self._path):
182
+ return
183
+ try:
184
+ self._path.parent.mkdir(parents=True, exist_ok=True)
185
+ payload = {
186
+ "nodes": self._nodes,
187
+ "redirects": self._redirects,
188
+ "aliases": self._aliases,
189
+ }
190
+ tmp = self._path.with_suffix(".json.tmp")
191
+ tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
192
+ os.replace(tmp, self._path)
193
+ except Exception as exc:
194
+ logger.warning("[StableNodeIDLayer] _save failed: %s", exc)
195
+
196
+ def _load(self) -> None:
197
+ try:
198
+ payload = json.loads(self._path.read_text(encoding="utf-8"))
199
+ self._nodes = payload.get("nodes", {})
200
+ self._redirects = payload.get("redirects", {})
201
+ self._aliases = payload.get("aliases", {})
202
+ for sid, node in self._nodes.items():
203
+ nm = node.get("name", "")
204
+ self._name_index.setdefault(nm, []).append(sid)
205
+ # from_dict rebuilds this index; the file-load path MUST too, or
206
+ # a reloaded manager mints NEW ids for existing symbols (breaks
207
+ # idempotency — the semantic-VCS op-log depends on it).
208
+ self._pathname_index[self._pn_key(nm, node.get("path", ""))] = sid
209
+ except Exception as exc:
210
+ logger.warning("[StableNodeIDLayer] _load failed: %s", exc)