proposal_symbol_delta.py
python
sha256:3c58668648c7323bb9f5c6881cfe6a3f14fc93fcb73b537d253732952a5bf8bf
chore: bump version to 0.2.0rc12
Sonnet 4.6
patch
8 days ago
| 1 | """Build a structured symbol delta for a proposal's commit set. |
| 2 | |
| 3 | Reads structured_delta from each commit, maps the delta algebra op types |
| 4 | to (added | modified | deleted), reduces net op across commits in chronological |
| 5 | order, and groups entries by file path. |
| 6 | |
| 7 | Op mapping (from the delta algebra): |
| 8 | insert → added |
| 9 | delete → deleted |
| 10 | replace / mutate / patch / <unknown> → modified |
| 11 | |
| 12 | Net-op reduction (chronological): |
| 13 | none + insert → added |
| 14 | none + delete → deleted |
| 15 | none + modify → modified |
| 16 | added + modify → added (still new to this branch) |
| 17 | added + delete → (cancelled — not in output) |
| 18 | modified + modify → modified |
| 19 | modified + delete → deleted |
| 20 | deleted + insert → modified (re-added) |
| 21 | deleted + modify → modified |
| 22 | """ |
| 23 | from __future__ import annotations |
| 24 | |
| 25 | from dataclasses import dataclass, field |
| 26 | |
| 27 | from musehub.db.musehub_repo_models import MusehubCommit |
| 28 | |
| 29 | |
| 30 | # --------------------------------------------------------------------------- |
| 31 | # Types |
| 32 | # --------------------------------------------------------------------------- |
| 33 | |
| 34 | |
| 35 | @dataclass |
| 36 | class SymbolDeltaEntry: |
| 37 | address: str |
| 38 | file_path: str |
| 39 | symbol_name: str |
| 40 | net_op: str # "added" | "modified" | "deleted" |
| 41 | is_breaking: bool = False |
| 42 | |
| 43 | |
| 44 | @dataclass |
| 45 | class ProposalSymbolDelta: |
| 46 | added: list[SymbolDeltaEntry] = field(default_factory=list) |
| 47 | modified: list[SymbolDeltaEntry] = field(default_factory=list) |
| 48 | deleted: list[SymbolDeltaEntry] = field(default_factory=list) |
| 49 | by_file: dict[str, list[SymbolDeltaEntry]] = field(default_factory=dict) |
| 50 | |
| 51 | @property |
| 52 | def total(self) -> int: |
| 53 | return len(self.added) + len(self.modified) + len(self.deleted) |
| 54 | |
| 55 | |
| 56 | # --------------------------------------------------------------------------- |
| 57 | # Op classification |
| 58 | # --------------------------------------------------------------------------- |
| 59 | |
| 60 | _INSERT_OPS = {"insert"} |
| 61 | _DELETE_OPS = {"delete"} |
| 62 | |
| 63 | |
| 64 | def _classify(op: str) -> str: |
| 65 | if op in _INSERT_OPS: |
| 66 | return "added" |
| 67 | if op in _DELETE_OPS: |
| 68 | return "deleted" |
| 69 | return "modified" |
| 70 | |
| 71 | |
| 72 | # --------------------------------------------------------------------------- |
| 73 | # Net-op reduction |
| 74 | # --------------------------------------------------------------------------- |
| 75 | |
| 76 | # (current_state, incoming_op) → new_state | None (None = cancelled) |
| 77 | _REDUCTION: dict[tuple[str | None, str], str | None] = { |
| 78 | (None, "added"): "added", |
| 79 | (None, "modified"): "modified", |
| 80 | (None, "deleted"): "deleted", |
| 81 | ("added", "added"): "added", |
| 82 | ("added", "modified"): "added", |
| 83 | ("added", "deleted"): None, # net zero |
| 84 | ("modified", "added"): "modified", |
| 85 | ("modified", "modified"): "modified", |
| 86 | ("modified", "deleted"): "deleted", |
| 87 | ("deleted", "added"): "modified", # re-added |
| 88 | ("deleted", "modified"): "modified", |
| 89 | ("deleted", "deleted"): "deleted", |
| 90 | } |
| 91 | |
| 92 | |
| 93 | def _reduce(current: str | None, incoming_op: str) -> str | None: |
| 94 | return _REDUCTION.get((current, incoming_op), incoming_op) |
| 95 | |
| 96 | |
| 97 | # --------------------------------------------------------------------------- |
| 98 | # Public API |
| 99 | # --------------------------------------------------------------------------- |
| 100 | |
| 101 | |
| 102 | def build_proposal_symbol_delta( |
| 103 | commits: list[MusehubCommit], |
| 104 | breaking_changes: list[str] | None = None, |
| 105 | ) -> ProposalSymbolDelta: |
| 106 | """Fold commit structured_deltas into a net symbol delta for the proposal. |
| 107 | |
| 108 | ``commits`` should be MusehubCommit ORM objects (or any objects with |
| 109 | ``structured_delta``, ``breaking_changes``, and ``timestamp`` attrs). |
| 110 | They need not be pre-sorted; this function sorts by timestamp internally. |
| 111 | |
| 112 | ``breaking_changes`` is an optional pre-collected list of breaking symbol |
| 113 | addresses (supplements per-commit ``breaking_changes`` fields). |
| 114 | """ |
| 115 | all_breaking: set[str] = set(breaking_changes or []) |
| 116 | |
| 117 | # Sort chronologically so later commits override earlier ones. |
| 118 | sorted_commits = sorted(commits, key=lambda c: c.timestamp) |
| 119 | |
| 120 | # address → (net_op, is_breaking_ever) |
| 121 | net: dict[str, tuple[str | None, bool]] = {} |
| 122 | |
| 123 | for commit in sorted_commits: |
| 124 | # Collect breaking addresses from this commit. |
| 125 | bc = getattr(commit, "breaking_changes", None) or [] |
| 126 | all_breaking.update(bc) |
| 127 | |
| 128 | delta = getattr(commit, "structured_delta", None) |
| 129 | if not isinstance(delta, dict): |
| 130 | continue |
| 131 | |
| 132 | for top_op in delta.get("ops", []): |
| 133 | if not isinstance(top_op, dict): |
| 134 | continue |
| 135 | # Collect this op plus any child_ops into one flat pass. |
| 136 | candidates = [top_op] |
| 137 | child_ops = top_op.get("child_ops") |
| 138 | if isinstance(child_ops, list): |
| 139 | candidates.extend(c for c in child_ops if isinstance(c, dict)) |
| 140 | |
| 141 | for op in candidates: |
| 142 | address = op.get("address", "") |
| 143 | if "::" not in address: |
| 144 | continue |
| 145 | raw_op = op.get("op", "") |
| 146 | classified = _classify(raw_op) |
| 147 | |
| 148 | current_state, was_breaking = net.get(address, (None, False)) |
| 149 | new_state = _reduce(current_state, classified) |
| 150 | net[address] = (new_state, was_breaking) |
| 151 | |
| 152 | # Apply breaking flags collected across all commits. |
| 153 | net = { |
| 154 | addr: (state, breaking or (addr in all_breaking)) |
| 155 | for addr, (state, breaking) in net.items() |
| 156 | } |
| 157 | |
| 158 | # Build output buckets. |
| 159 | result = ProposalSymbolDelta() |
| 160 | by_file: dict[str, list[SymbolDeltaEntry]] = {} |
| 161 | |
| 162 | for address, (state, is_breaking) in sorted(net.items()): |
| 163 | if state is None: |
| 164 | continue # cancelled (added then deleted) |
| 165 | file_path, symbol_name = address.split("::", 1) |
| 166 | entry = SymbolDeltaEntry( |
| 167 | address=address, |
| 168 | file_path=file_path, |
| 169 | symbol_name=symbol_name, |
| 170 | net_op=state, |
| 171 | is_breaking=is_breaking, |
| 172 | ) |
| 173 | if state == "added": |
| 174 | result.added.append(entry) |
| 175 | elif state == "modified": |
| 176 | result.modified.append(entry) |
| 177 | elif state == "deleted": |
| 178 | result.deleted.append(entry) |
| 179 | by_file.setdefault(file_path, []).append(entry) |
| 180 | |
| 181 | # Sort each file's entries: added → modified → deleted. |
| 182 | _order = {"added": 0, "modified": 1, "deleted": 2} |
| 183 | for entries in by_file.values(): |
| 184 | entries.sort(key=lambda e: (_order.get(e.net_op, 9), e.symbol_name)) |
| 185 | |
| 186 | result.by_file = by_file |
| 187 | return result |
File History
1 commit
sha256:3c58668648c7323bb9f5c6881cfe6a3f14fc93fcb73b537d253732952a5bf8bf
chore: bump version to 0.2.0rc12
Sonnet 4.6
patch
8 days ago