gabriel / musehub public
proposal_symbol_delta.py python
187 lines 6.5 KB
Raw
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