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/__init__.py +53 -0
- awgit/bodies.py +323 -0
- awgit/bridge.py +117 -0
- awgit/capture.py +441 -0
- awgit/cli.py +488 -0
- awgit/data_root.py +30 -0
- awgit/diff.py +82 -0
- awgit/hooks/chain.sh +38 -0
- awgit/hooks/post-commit.d/vcs-capture +38 -0
- awgit/hooks/pre-commit.d/vcs-lease-check +15 -0
- awgit/identity.py +80 -0
- awgit/leases.py +264 -0
- awgit/ledger.py +80 -0
- awgit/mcp.py +94 -0
- awgit/merge.py +331 -0
- awgit/nodeid.py +210 -0
- awgit/oplog.py +221 -0
- awgit/parser.py +186 -0
- awgit/schema.py +190 -0
- awgit/sync.py +180 -0
- awgit-0.1.0.dist-info/METADATA +117 -0
- awgit-0.1.0.dist-info/RECORD +26 -0
- awgit-0.1.0.dist-info/WHEEL +5 -0
- awgit-0.1.0.dist-info/entry_points.txt +2 -0
- awgit-0.1.0.dist-info/licenses/LICENSE +202 -0
- awgit-0.1.0.dist-info/top_level.txt +1 -0
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)
|