symbol_diff.py
python
sha256:3767afb72520f9b56053bb98fd83d323f738ee4cad16e306e8cf6862608380e4
feat: first-class directory tracking across status, diff, r…
Sonnet 4.6
minor
⚠ breaking
54 days ago
| 1 | """Symbol-level diff engine for the code domain plugin. |
| 2 | |
| 3 | Produces typed :class:`~muse.domain.DomainOp` entries at symbol granularity |
| 4 | rather than file granularity. This allows Muse to report which *functions* |
| 5 | were added, removed, renamed, or modified — not just which *files* changed. |
| 6 | |
| 7 | Operation types produced |
| 8 | ------------------------ |
| 9 | ``InsertOp`` |
| 10 | A symbol was added to the file (new function, class, etc.). |
| 11 | |
| 12 | ``DeleteOp`` |
| 13 | A symbol was removed from the file. |
| 14 | |
| 15 | ``ReplaceOp`` |
| 16 | A symbol's content changed. The ``old_summary`` / ``new_summary`` fields |
| 17 | describe the nature of the change: |
| 18 | |
| 19 | - ``"renamed to <name>"`` — same body, different name (rename detected |
| 20 | via matching ``body_hash``). |
| 21 | - ``"signature changed"`` — same body, different signature. |
| 22 | - ``"implementation changed"`` — same signature, different body. |
| 23 | - ``"modified"`` — both signature and body changed. |
| 24 | |
| 25 | ``MoveOp`` |
| 26 | Reserved for intra-file positional moves (used when a symbol's address |
| 27 | is unchanged but it is detected elsewhere via ``content_id``). |
| 28 | |
| 29 | Cross-file move detection |
| 30 | ------------------------- |
| 31 | When a symbol disappears from one file and appears in another with an |
| 32 | identical ``content_id``, the diff engine annotates the ``DeleteOp`` and |
| 33 | ``InsertOp`` ``content_summary`` fields to indicate the move direction. No |
| 34 | special op type is introduced — the existing :class:`~muse.domain.InsertOp` |
| 35 | and :class:`~muse.domain.DeleteOp` suffice because their addresses already |
| 36 | encode the file path. |
| 37 | |
| 38 | Algorithm |
| 39 | --------- |
| 40 | 1. Partition symbol addresses into ``added``, ``removed``, and ``common``. |
| 41 | 2. Build ``body_hash → address`` reverse maps for added and removed sets. |
| 42 | 3. For each ``removed`` symbol: |
| 43 | |
| 44 | a. If ``content_id`` matches an ``added`` symbol → **exact move/copy** |
| 45 | (same name, same body, different file). |
| 46 | b. Else if ``body_hash`` matches an ``added`` symbol → **rename** |
| 47 | (same body, different name). |
| 48 | |
| 49 | 4. Emit ``ReplaceOp`` for renames; pair the cross-file move partners via |
| 50 | ``content_summary``. |
| 51 | 5. Emit ``DeleteOp`` for genuinely removed symbols. |
| 52 | 6. Emit ``InsertOp`` for genuinely added symbols. |
| 53 | 7. Emit ``ReplaceOp`` for symbols whose ``content_id`` changed. |
| 54 | """ |
| 55 | |
| 56 | import logging |
| 57 | |
| 58 | from muse.core.types import Manifest |
| 59 | from muse.domain import AddressedDeleteOp, AddressedInsertOp, DomainOp, PatchOp, RenameOp, ReplaceOp |
| 60 | from muse.plugins.code.ast_parser import SymbolTree |
| 61 | |
| 62 | logger = logging.getLogger(__name__) |
| 63 | |
| 64 | type ContentIdMap = dict[str, str] # content_id or body_hash → address |
| 65 | type SetHashMap = dict[str, set[str]] # path → set of body hashes |
| 66 | type FileMoveMap = dict[str, str] # old_path → new_path |
| 67 | type ContentTupleMap = dict[str, tuple[str, int, list[DomainOp]]] # content_id → (addr, idx, ops) |
| 68 | type SymbolTreeMap = dict[str, SymbolTree] # file_path → symbol tree |
| 69 | |
| 70 | _CHILD_DOMAIN = "code_symbols" |
| 71 | |
| 72 | # --------------------------------------------------------------------------- |
| 73 | # Symbol-level diff within a single file |
| 74 | # --------------------------------------------------------------------------- |
| 75 | |
| 76 | def diff_symbol_trees( |
| 77 | base: SymbolTree, |
| 78 | target: SymbolTree, |
| 79 | ) -> list[DomainOp]: |
| 80 | """Compute symbol-level ops transforming *base* into *target*. |
| 81 | |
| 82 | Both trees must be scoped to the same file (their addresses share the |
| 83 | same ``"<file_path>::"`` prefix). |
| 84 | |
| 85 | Args: |
| 86 | base: Symbol tree of the base (older) version of the file. |
| 87 | target: Symbol tree of the target (newer) version of the file. |
| 88 | |
| 89 | Returns: |
| 90 | Ordered list of :class:`~muse.domain.DomainOp` entries. |
| 91 | """ |
| 92 | base_addrs = set(base) |
| 93 | target_addrs = set(target) |
| 94 | added: set[str] = target_addrs - base_addrs |
| 95 | removed: set[str] = base_addrs - target_addrs |
| 96 | common: set[str] = base_addrs & target_addrs |
| 97 | |
| 98 | # Reverse maps for rename / move detection. |
| 99 | added_by_content: ContentIdMap = { |
| 100 | target[a]["content_id"]: a for a in added |
| 101 | } |
| 102 | added_by_body: ContentIdMap = { |
| 103 | target[a]["body_hash"]: a for a in added |
| 104 | } |
| 105 | |
| 106 | ops: list[DomainOp] = [] |
| 107 | # Addresses claimed by rename / move detection — excluded from plain ops. |
| 108 | matched_removed: set[str] = set() |
| 109 | matched_added: set[str] = set() |
| 110 | |
| 111 | # ── Pass 1: renames (same body_hash, different name) ────────────────── |
| 112 | for rem_addr in sorted(removed): |
| 113 | base_rec = base[rem_addr] |
| 114 | |
| 115 | if base_rec["content_id"] in added_by_content: |
| 116 | # Exact content match at a different address → same symbol moved |
| 117 | # (intra-file positional moves don't produce a different address; |
| 118 | # this catches cross-file moves surfaced within a single-file diff |
| 119 | # when the caller slices the tree incorrectly — uncommon). |
| 120 | tgt_addr = added_by_content[base_rec["content_id"]] |
| 121 | tgt_rec = target[tgt_addr] |
| 122 | ops.append(ReplaceOp( |
| 123 | op="replace", |
| 124 | address=rem_addr, |
| 125 | position=None, |
| 126 | old_content_id=base_rec["content_id"], |
| 127 | new_content_id=tgt_rec["content_id"], |
| 128 | old_summary=f"{base_rec['kind']} {base_rec['name']}", |
| 129 | new_summary=f"moved to {tgt_rec['qualified_name']}", |
| 130 | )) |
| 131 | matched_removed.add(rem_addr) |
| 132 | matched_added.add(tgt_addr) |
| 133 | |
| 134 | elif ( |
| 135 | base_rec["body_hash"] in added_by_body |
| 136 | and added_by_body[base_rec["body_hash"]] not in matched_added |
| 137 | ): |
| 138 | # Same body, different name → rename. |
| 139 | tgt_addr = added_by_body[base_rec["body_hash"]] |
| 140 | tgt_rec = target[tgt_addr] |
| 141 | ops.append(ReplaceOp( |
| 142 | op="replace", |
| 143 | address=rem_addr, |
| 144 | position=None, |
| 145 | old_content_id=base_rec["content_id"], |
| 146 | new_content_id=tgt_rec["content_id"], |
| 147 | old_summary=f"{base_rec['kind']} {base_rec['name']}", |
| 148 | new_summary=f"renamed to {tgt_rec['name']}", |
| 149 | )) |
| 150 | matched_removed.add(rem_addr) |
| 151 | matched_added.add(tgt_addr) |
| 152 | |
| 153 | # ── Pass 2: plain deletions ──────────────────────────────────────────── |
| 154 | for rem_addr in sorted(removed - matched_removed): |
| 155 | rec = base[rem_addr] |
| 156 | ops.append(AddressedDeleteOp( |
| 157 | op="delete", |
| 158 | address=rem_addr, |
| 159 | content_id=rec["content_id"], |
| 160 | content_summary=( |
| 161 | f"removed {rec['kind']} {rec['name']}" |
| 162 | f" L{rec['lineno']}–{rec['end_lineno']}" |
| 163 | ), |
| 164 | )) |
| 165 | |
| 166 | # ── Pass 3: plain additions ──────────────────────────────────────────── |
| 167 | for add_addr in sorted(added - matched_added): |
| 168 | rec = target[add_addr] |
| 169 | ops.append(AddressedInsertOp( |
| 170 | op="insert", |
| 171 | address=add_addr, |
| 172 | content_id=rec["content_id"], |
| 173 | content_summary=( |
| 174 | f"added {rec['kind']} {rec['name']}" |
| 175 | f" L{rec['lineno']}–{rec['end_lineno']}" |
| 176 | ), |
| 177 | )) |
| 178 | |
| 179 | # ── Pass 4: modifications ────────────────────────────────────────────── |
| 180 | for addr in sorted(common): |
| 181 | base_rec = base[addr] |
| 182 | tgt_rec = target[addr] |
| 183 | if base_rec["content_id"] == tgt_rec["content_id"]: |
| 184 | continue # unchanged |
| 185 | |
| 186 | loc = f" L{tgt_rec['lineno']}–{tgt_rec['end_lineno']}" |
| 187 | if base_rec["body_hash"] == tgt_rec["body_hash"]: |
| 188 | old_summary = f"{base_rec['kind']} {base_rec['name']} (signature changed)" |
| 189 | new_summary = f"{tgt_rec['kind']} {tgt_rec['name']} (signature updated){loc}" |
| 190 | elif base_rec["signature_id"] == tgt_rec["signature_id"]: |
| 191 | old_summary = f"{base_rec['kind']} {base_rec['name']} (implementation)" |
| 192 | new_summary = f"{tgt_rec['kind']} {tgt_rec['name']} (implementation changed){loc}" |
| 193 | else: |
| 194 | old_summary = f"{base_rec['kind']} {base_rec['name']}" |
| 195 | new_summary = f"{tgt_rec['kind']} {tgt_rec['name']} (modified){loc}" |
| 196 | |
| 197 | ops.append(ReplaceOp( |
| 198 | op="replace", |
| 199 | address=addr, |
| 200 | position=None, |
| 201 | old_content_id=base_rec["content_id"], |
| 202 | new_content_id=tgt_rec["content_id"], |
| 203 | old_summary=old_summary, |
| 204 | new_summary=new_summary, |
| 205 | )) |
| 206 | |
| 207 | return ops |
| 208 | |
| 209 | # --------------------------------------------------------------------------- |
| 210 | # Cross-file diff: build the full op list for a snapshot pair |
| 211 | # --------------------------------------------------------------------------- |
| 212 | |
| 213 | def build_diff_ops( |
| 214 | base_files: Manifest, |
| 215 | target_files: Manifest, |
| 216 | base_trees: SymbolTreeMap, |
| 217 | target_trees: SymbolTreeMap, |
| 218 | ) -> list[DomainOp]: |
| 219 | """Build the complete op list transforming *base* snapshot into *target*. |
| 220 | |
| 221 | For each changed file: |
| 222 | |
| 223 | - **No symbol trees available**: coarse ``InsertOp`` / ``DeleteOp`` / |
| 224 | ``ReplaceOp`` at file level. |
| 225 | - **Symbol trees available for both sides**: ``PatchOp`` with symbol-level |
| 226 | ``child_ops``. If all symbols are unchanged (formatting-only change) |
| 227 | a ``ReplaceOp`` with ``"reformatted"`` summary is emitted instead. |
| 228 | - **Symbol tree available for one side only** (new or deleted file): |
| 229 | ``PatchOp`` listing each symbol individually. |
| 230 | |
| 231 | Cross-file move annotation |
| 232 | -------------------------- |
| 233 | After building per-file ops, a second pass checks whether any symbol |
| 234 | ``content_id`` appears in both a ``DeleteOp`` child op and an ``InsertOp`` |
| 235 | child op across *different* files. When found, both ops' ``content_summary`` |
| 236 | fields are annotated with the move direction. |
| 237 | |
| 238 | Args: |
| 239 | base_files: ``{path: raw_bytes_hash}`` from the base snapshot. |
| 240 | target_files: ``{path: raw_bytes_hash}`` from the target snapshot. |
| 241 | base_trees: Symbol trees for changed base files, keyed by path. |
| 242 | target_trees: Symbol trees for changed target files, keyed by path. |
| 243 | |
| 244 | Returns: |
| 245 | Ordered list of ``DomainOp`` entries. |
| 246 | """ |
| 247 | base_paths = set(base_files) |
| 248 | target_paths = set(target_files) |
| 249 | added_paths = sorted(target_paths - base_paths) |
| 250 | removed_paths = sorted(base_paths - target_paths) |
| 251 | modified_paths = sorted( |
| 252 | p for p in base_paths & target_paths |
| 253 | if base_files[p] != target_files[p] |
| 254 | ) |
| 255 | |
| 256 | # Detect file-level move+edits before emitting per-file ops so we can |
| 257 | # suppress the plain added/removed ops for those paths. |
| 258 | move_map = _detect_file_move_edits( |
| 259 | added_paths, removed_paths, base_trees, target_trees, |
| 260 | base_files=base_files, target_files=target_files, |
| 261 | ) |
| 262 | moved_old = set(move_map) |
| 263 | moved_new = set(move_map.values()) |
| 264 | |
| 265 | ops: list[DomainOp] = [] |
| 266 | |
| 267 | # ── Added files (excluding move+edit targets) ────────────────────────── |
| 268 | for path in added_paths: |
| 269 | if path in moved_new: |
| 270 | continue |
| 271 | tree = target_trees.get(path, {}) |
| 272 | if tree: |
| 273 | child_ops: list[DomainOp] = [ |
| 274 | AddressedInsertOp( |
| 275 | op="insert", |
| 276 | address=addr, |
| 277 | content_id=rec["content_id"], |
| 278 | content_summary=( |
| 279 | f"added {rec['kind']} {rec['name']}" |
| 280 | f" L{rec['lineno']}–{rec['end_lineno']}" |
| 281 | ), |
| 282 | ) |
| 283 | for addr, rec in sorted(tree.items()) |
| 284 | ] |
| 285 | ops.append(_patch(path, child_ops, file_change="added")) |
| 286 | else: |
| 287 | ops.append(AddressedInsertOp( |
| 288 | op="insert", |
| 289 | address=path, |
| 290 | content_id=target_files[path], |
| 291 | content_summary=f"added {path}", |
| 292 | )) |
| 293 | |
| 294 | # ── Removed files (excluding move+edit sources) ──────────────────────── |
| 295 | for path in removed_paths: |
| 296 | if path in moved_old: |
| 297 | continue |
| 298 | tree = base_trees.get(path, {}) |
| 299 | if tree: |
| 300 | child_ops = [ |
| 301 | AddressedDeleteOp( |
| 302 | op="delete", |
| 303 | address=addr, |
| 304 | content_id=rec["content_id"], |
| 305 | content_summary=( |
| 306 | f"removed {rec['kind']} {rec['name']}" |
| 307 | f" L{rec['lineno']}–{rec['end_lineno']}" |
| 308 | ), |
| 309 | ) |
| 310 | for addr, rec in sorted(tree.items()) |
| 311 | ] |
| 312 | ops.append(_patch(path, child_ops, file_change="deleted")) |
| 313 | else: |
| 314 | ops.append(AddressedDeleteOp( |
| 315 | op="delete", |
| 316 | address=path, |
| 317 | content_id=base_files[path], |
| 318 | content_summary=f"removed {path}", |
| 319 | )) |
| 320 | |
| 321 | # ── Modified files ───────────────────────────────────────────────────── |
| 322 | for path in modified_paths: |
| 323 | base_tree = base_trees.get(path, {}) |
| 324 | target_tree = target_trees.get(path, {}) |
| 325 | |
| 326 | if base_tree or target_tree: |
| 327 | child_ops = diff_symbol_trees(base_tree, target_tree) |
| 328 | if child_ops: |
| 329 | ops.append(_patch(path, child_ops, file_change="modified")) |
| 330 | else: |
| 331 | # All symbols have the same content_id — formatting-only change. |
| 332 | ops.append(ReplaceOp( |
| 333 | op="replace", |
| 334 | address=path, |
| 335 | position=None, |
| 336 | old_content_id=base_files[path], |
| 337 | new_content_id=target_files[path], |
| 338 | old_summary=f"{path} (before)", |
| 339 | new_summary=f"{path} (reformatted — no semantic change)", |
| 340 | )) |
| 341 | else: |
| 342 | ops.append(ReplaceOp( |
| 343 | op="replace", |
| 344 | address=path, |
| 345 | position=None, |
| 346 | old_content_id=base_files[path], |
| 347 | new_content_id=target_files[path], |
| 348 | old_summary=f"{path} (before)", |
| 349 | new_summary=f"{path} (after)", |
| 350 | )) |
| 351 | |
| 352 | # ── Move+edit files ──────────────────────────────────────────────────── |
| 353 | for old_path, new_path in sorted(move_map.items()): |
| 354 | old_tree = base_trees.get(old_path, {}) |
| 355 | new_tree = target_trees.get(new_path, {}) |
| 356 | # Strip the file-path prefix (everything up to and including "::") so |
| 357 | # that "math_utils.py::add" and "core_math.py::add" both normalise to |
| 358 | # "add" and land in the *common* bucket of diff_symbol_trees. Without |
| 359 | # this, every symbol looks deleted-and-added, producing spurious |
| 360 | # "moved to <name>" entries for functions that are actually unchanged. |
| 361 | old_tree_norm: SymbolTree = { |
| 362 | addr.split("::", 1)[-1]: rec for addr, rec in old_tree.items() |
| 363 | } |
| 364 | new_tree_norm: SymbolTree = { |
| 365 | addr.split("::", 1)[-1]: rec for addr, rec in new_tree.items() |
| 366 | } |
| 367 | child_ops = diff_symbol_trees(old_tree_norm, new_tree_norm) |
| 368 | |
| 369 | n_added = sum(1 for o in child_ops if o["op"] == "insert") |
| 370 | n_removed = sum(1 for o in child_ops if o["op"] == "delete") |
| 371 | n_modified = sum(1 for o in child_ops if o["op"] == "replace") |
| 372 | sym_parts: list[str] = [] |
| 373 | if n_added: |
| 374 | sym_parts.append(f"{n_added} added") |
| 375 | if n_removed: |
| 376 | sym_parts.append(f"{n_removed} removed") |
| 377 | if n_modified: |
| 378 | sym_parts.append(f"{n_modified} modified") |
| 379 | ops.append(RenameOp(op="rename", address=new_path, from_address=old_path)) |
| 380 | if child_ops: |
| 381 | child_summary = f"moved from {old_path}" |
| 382 | if sym_parts: |
| 383 | child_summary += f"; {', '.join(sym_parts)}" |
| 384 | ops.append(PatchOp( |
| 385 | op="patch", |
| 386 | address=new_path, |
| 387 | child_ops=child_ops, |
| 388 | child_domain=_CHILD_DOMAIN, |
| 389 | child_summary=child_summary, |
| 390 | )) |
| 391 | |
| 392 | _annotate_cross_file_moves(ops) |
| 393 | return ops |
| 394 | |
| 395 | def _patch( |
| 396 | path: str, |
| 397 | child_ops: list[DomainOp], |
| 398 | file_change: str, |
| 399 | ) -> PatchOp: |
| 400 | """Wrap symbol child_ops in a file-level PatchOp. |
| 401 | |
| 402 | ``file_change`` must be one of ``"added"``, ``"deleted"``, or |
| 403 | ``"modified"`` and is set by the caller based on which path bucket the |
| 404 | file came from — *not* inferred from child op direction. This prevents |
| 405 | the renderer from misclassifying a living file that lost all its symbols |
| 406 | as a deleted file. |
| 407 | """ |
| 408 | n_added = sum(1 for o in child_ops if o["op"] == "insert") |
| 409 | n_removed = sum(1 for o in child_ops if o["op"] == "delete") |
| 410 | n_modified = sum(1 for o in child_ops if o["op"] == "replace") |
| 411 | parts: list[str] = [] |
| 412 | if n_added: |
| 413 | parts.append(f"{n_added} symbol{'s' if n_added > 1 else ''} added") |
| 414 | if n_removed: |
| 415 | parts.append(f"{n_removed} symbol{'s' if n_removed > 1 else ''} removed") |
| 416 | if n_modified: |
| 417 | parts.append(f"{n_modified} symbol{'s' if n_modified > 1 else ''} modified") |
| 418 | summary = ", ".join(parts) if parts else "no symbol changes" |
| 419 | return PatchOp( |
| 420 | op="patch", |
| 421 | address=path, |
| 422 | child_ops=child_ops, |
| 423 | child_domain=_CHILD_DOMAIN, |
| 424 | child_summary=summary, |
| 425 | file_change=file_change, |
| 426 | ) |
| 427 | |
| 428 | def _detect_file_move_edits( |
| 429 | added_paths: list[str], |
| 430 | removed_paths: list[str], |
| 431 | base_trees: SymbolTreeMap, |
| 432 | target_trees: SymbolTreeMap, |
| 433 | min_overlap: float = 0.5, |
| 434 | base_files: Manifest | None = None, |
| 435 | target_files: Manifest | None = None, |
| 436 | ) -> FileMoveMap: |
| 437 | """Return a mapping of old_path → new_path for file-level move+edits. |
| 438 | |
| 439 | Two detection strategies run in priority order: |
| 440 | |
| 441 | 1. **Symbol-tree overlap** — files whose symbol trees share at least |
| 442 | ``min_overlap`` fraction of ``body_hash`` values are considered |
| 443 | moved-and-edited. Confidence score = overlap fraction (0.5–1.0). |
| 444 | |
| 445 | 2. **Blob content_id match** (fallback) — when neither the deleted nor |
| 446 | the added file has a parseable symbol tree, compare their raw blob |
| 447 | ``content_id`` values. An exact match (score = 1.0) means the file |
| 448 | content is identical: the path changed but nothing else did. This |
| 449 | covers plain-text files, unknown extensions, and any file the AST |
| 450 | adapter cannot parse. |
| 451 | |
| 452 | Each old_path and new_path is used at most once (greedy, highest-score |
| 453 | pair wins when multiple candidates exist). |
| 454 | |
| 455 | Args: |
| 456 | added_paths: Paths present in target but not in base. |
| 457 | removed_paths: Paths present in base but not in target. |
| 458 | base_trees: Symbol trees for changed base files. |
| 459 | target_trees: Symbol trees for changed target files. |
| 460 | min_overlap: Minimum body_hash overlap fraction for strategy 1. |
| 461 | base_files: Raw blob manifest for base snapshot (strategy 2). |
| 462 | target_files: Raw blob manifest for target snapshot (strategy 2). |
| 463 | |
| 464 | Returns: |
| 465 | ``{old_path: new_path}`` for each detected move+edit pair. |
| 466 | """ |
| 467 | base_hashes: SetHashMap = { |
| 468 | p: {rec["body_hash"] for rec in base_trees[p].values()} |
| 469 | for p in removed_paths |
| 470 | if p in base_trees and base_trees[p] |
| 471 | } |
| 472 | target_hashes: SetHashMap = { |
| 473 | p: {rec["body_hash"] for rec in target_trees[p].values()} |
| 474 | for p in added_paths |
| 475 | if p in target_trees and target_trees[p] |
| 476 | } |
| 477 | |
| 478 | # Strategy 1: symbol-tree overlap — score = overlap fraction. |
| 479 | candidates: list[tuple[float, str, str]] = [] |
| 480 | for old_path, old_h in base_hashes.items(): |
| 481 | for new_path, new_h in target_hashes.items(): |
| 482 | common = old_h & new_h |
| 483 | if not common: |
| 484 | continue |
| 485 | overlap = len(common) / min(len(old_h), len(new_h)) |
| 486 | if overlap >= min_overlap: |
| 487 | candidates.append((overlap, old_path, new_path)) |
| 488 | |
| 489 | # Strategy 2: blob content_id match for files with no symbol tree. |
| 490 | # Score = 1.0 (exact match) so these rank above any symbol-overlap candidate. |
| 491 | if base_files is not None and target_files is not None: |
| 492 | tree_covered_old = set(base_hashes) |
| 493 | tree_covered_new = set(target_hashes) |
| 494 | # Build content_id → [new_path] map for added files without a symbol tree. |
| 495 | blob_added: dict[str, list[str]] = {} |
| 496 | for p in added_paths: |
| 497 | if p in tree_covered_new: |
| 498 | continue |
| 499 | cid = target_files.get(p) |
| 500 | if cid: |
| 501 | blob_added.setdefault(cid, []).append(p) |
| 502 | for old_path in removed_paths: |
| 503 | if old_path in tree_covered_old: |
| 504 | continue |
| 505 | cid = base_files.get(old_path) |
| 506 | if cid and cid in blob_added: |
| 507 | for new_path in blob_added[cid]: |
| 508 | candidates.append((1.0, old_path, new_path)) |
| 509 | |
| 510 | candidates.sort(key=lambda t: t[0], reverse=True) |
| 511 | |
| 512 | moves: FileMoveMap = {} |
| 513 | used_old: set[str] = set() |
| 514 | used_new: set[str] = set() |
| 515 | for _, old_path, new_path in candidates: |
| 516 | if old_path in used_old or new_path in used_new: |
| 517 | continue |
| 518 | moves[old_path] = new_path |
| 519 | used_old.add(old_path) |
| 520 | used_new.add(new_path) |
| 521 | |
| 522 | return moves |
| 523 | |
| 524 | def _annotate_cross_file_moves(ops: list[DomainOp]) -> None: |
| 525 | """Annotate DeleteOp/InsertOp pairs that represent cross-file symbol moves. |
| 526 | |
| 527 | Mutates the ``content_summary`` of matching ops in place. A move is |
| 528 | detected when: |
| 529 | |
| 530 | - A ``DeleteOp`` child op (inside a ``PatchOp``) has the same |
| 531 | ``content_id`` as an ``InsertOp`` child op in a *different* file's |
| 532 | ``PatchOp``. |
| 533 | |
| 534 | This is a best-effort annotation pass — it does not change the semantic |
| 535 | meaning of the ops, only their human-readable summaries. |
| 536 | """ |
| 537 | # Collect child op references: content_id → (file_path, op_index_in_patch) |
| 538 | # We need mutable access so work with lists rather than immutable tuples. |
| 539 | delete_by_content: ContentTupleMap = {} |
| 540 | insert_by_content: ContentTupleMap = {} |
| 541 | |
| 542 | for op in ops: |
| 543 | if op["op"] != "patch": |
| 544 | continue |
| 545 | file_path = op["address"] |
| 546 | for i, child in enumerate(op["child_ops"]): |
| 547 | if child["op"] == "delete": |
| 548 | delete_by_content[child["content_id"]] = (file_path, i, op["child_ops"]) |
| 549 | elif child["op"] == "insert": |
| 550 | insert_by_content[child["content_id"]] = (file_path, i, op["child_ops"]) |
| 551 | |
| 552 | for content_id, (del_file, del_idx, del_children) in delete_by_content.items(): |
| 553 | if content_id not in insert_by_content: |
| 554 | continue |
| 555 | ins_file, ins_idx, ins_children = insert_by_content[content_id] |
| 556 | if del_file == ins_file: |
| 557 | continue # Same file — not a cross-file move. |
| 558 | |
| 559 | del_op = del_children[del_idx] |
| 560 | ins_op = ins_children[ins_idx] |
| 561 | # Narrow to the expected op kinds before accessing kind-specific fields. |
| 562 | if del_op["op"] != "delete" or ins_op["op"] != "insert": |
| 563 | continue |
| 564 | # Annotate both sides with move direction. |
| 565 | del_children[del_idx] = AddressedDeleteOp( |
| 566 | op="delete", |
| 567 | address=del_op["address"], |
| 568 | content_id=del_op["content_id"], |
| 569 | content_summary=f"{del_op['content_summary']} → moved to {ins_file}", |
| 570 | ) |
| 571 | ins_children[ins_idx] = AddressedInsertOp( |
| 572 | op="insert", |
| 573 | address=ins_op["address"], |
| 574 | content_id=ins_op["content_id"], |
| 575 | content_summary=f"{ins_op['content_summary']} ← moved from {del_file}", |
| 576 | ) |
| 577 | |
| 578 | # --------------------------------------------------------------------------- |
| 579 | # Summary helpers |
| 580 | # --------------------------------------------------------------------------- |
| 581 | |
| 582 | def delta_summary(ops: list[DomainOp]) -> str: |
| 583 | """Produce a human-readable one-line summary of a list of ops. |
| 584 | |
| 585 | Counts file-level and symbol-level operations separately. |
| 586 | |
| 587 | Args: |
| 588 | ops: Top-level op list from :func:`build_diff_ops`. |
| 589 | |
| 590 | Returns: |
| 591 | A concise summary string (e.g. ``"2 files modified (5 symbols)"``) |
| 592 | or ``"no changes"`` for an empty list. |
| 593 | """ |
| 594 | # Directory ops: address ends with "/" (trailing slash is the canonical signal). |
| 595 | dirs_added = sum(1 for o in ops if o["op"] == "insert" and o["address"].endswith("/")) |
| 596 | dirs_removed = sum(1 for o in ops if o["op"] == "delete" and o["address"].endswith("/")) |
| 597 | dirs_renamed = sum(1 for o in ops if o["op"] == "rename" and "::" not in o["address"] and o["address"].endswith("/")) |
| 598 | |
| 599 | files_added = sum(1 for o in ops if o["op"] == "insert" and "::" not in o["address"] and not o["address"].endswith("/")) |
| 600 | files_removed = sum(1 for o in ops if o["op"] == "delete" and "::" not in o["address"] and not o["address"].endswith("/")) |
| 601 | files_renamed = sum(1 for o in ops if o["op"] == "rename" and "::" not in o["address"] and not o["address"].endswith("/")) |
| 602 | files_modified = sum( |
| 603 | 1 for o in ops |
| 604 | if o["op"] in ("replace", "patch") and "::" not in o["address"] |
| 605 | ) |
| 606 | |
| 607 | # Count child-level symbol ops. |
| 608 | symbols_added = 0 |
| 609 | symbols_removed = 0 |
| 610 | symbols_modified = 0 |
| 611 | for op in ops: |
| 612 | if op["op"] == "patch": |
| 613 | for child in op["child_ops"]: |
| 614 | if child["op"] == "insert": |
| 615 | symbols_added += 1 |
| 616 | elif child["op"] == "delete": |
| 617 | symbols_removed += 1 |
| 618 | elif child["op"] in ("replace", "move"): |
| 619 | symbols_modified += 1 |
| 620 | elif op["op"] == "replace" and "::" not in op["address"]: |
| 621 | pass |
| 622 | |
| 623 | parts: list[str] = [] |
| 624 | |
| 625 | dir_parts: list[str] = [] |
| 626 | if dirs_added: |
| 627 | dir_parts.append(f"{dirs_added} added") |
| 628 | if dirs_removed: |
| 629 | dir_parts.append(f"{dirs_removed} removed") |
| 630 | if dirs_renamed: |
| 631 | dir_parts.append(f"{dirs_renamed} renamed") |
| 632 | if dir_parts: |
| 633 | total_dirs = dirs_added + dirs_removed + dirs_renamed |
| 634 | parts.append(f"{', '.join(dir_parts)} director{'ies' if total_dirs != 1 else 'y'}") |
| 635 | |
| 636 | file_parts: list[str] = [] |
| 637 | if files_added: |
| 638 | file_parts.append(f"{files_added} added") |
| 639 | if files_removed: |
| 640 | file_parts.append(f"{files_removed} removed") |
| 641 | if files_modified: |
| 642 | file_parts.append(f"{files_modified} modified") |
| 643 | if files_renamed: |
| 644 | file_parts.append(f"{files_renamed} renamed") |
| 645 | if file_parts: |
| 646 | total_files = files_added + files_removed + files_modified + files_renamed |
| 647 | parts.append(f"{', '.join(file_parts)} file{'s' if total_files != 1 else ''}") |
| 648 | |
| 649 | sym_parts: list[str] = [] |
| 650 | if symbols_added: |
| 651 | sym_parts.append(f"{symbols_added} added") |
| 652 | if symbols_removed: |
| 653 | sym_parts.append(f"{symbols_removed} removed") |
| 654 | if symbols_modified: |
| 655 | sym_parts.append(f"{symbols_modified} modified") |
| 656 | if sym_parts: |
| 657 | parts.append(f"{', '.join(sym_parts)} symbol{'s' if sum([symbols_added, symbols_removed, symbols_modified]) != 1 else ''}") |
| 658 | |
| 659 | return ", ".join(parts) if parts else "no changes" |
File History
1 commit
sha256:3767afb72520f9b56053bb98fd83d323f738ee4cad16e306e8cf6862608380e4
feat: first-class directory tracking across status, diff, r…
Sonnet 4.6
minor
⚠
54 days ago