|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +from dataclasses import dataclass |
| 4 | +from typing import TYPE_CHECKING, Literal |
| 5 | + |
| 6 | +if TYPE_CHECKING: |
| 7 | + from plain_modules import PlainModule |
| 8 | + |
| 9 | +MODULE_FUNCTIONALITIES_KEY = "functionalities" |
| 10 | +NON_FUNCTIONAL_SOURCE_HASH_KEY = "non_functional_source_hash" |
| 11 | + |
| 12 | + |
| 13 | +@dataclass |
| 14 | +class FunctionalityChange: |
| 15 | + module: str |
| 16 | + frid: str |
| 17 | + change_type: Literal["added", "removed", "edited", "moved"] |
| 18 | + detail: str | None = None |
| 19 | + |
| 20 | + |
| 21 | +@dataclass |
| 22 | +class PartialRenderStart: |
| 23 | + module: "PlainModule" |
| 24 | + frid: str |
| 25 | + |
| 26 | + |
| 27 | +def determine_partial_render_start(plain_module: "PlainModule") -> PartialRenderStart | None: |
| 28 | + """Determine where to start partial rendering based on spec changes. |
| 29 | +
|
| 30 | + Returns None (only full render is safe) if non-FR sections changed |
| 31 | + (e.g. definitions, implementation reqs) since previously-rendered FRs |
| 32 | + were generated without that context, if no changes are found, or if |
| 33 | + all changes are trailing removals that don't require rendering. |
| 34 | + """ |
| 35 | + all_modules = plain_module.all_required_modules + [plain_module] |
| 36 | + |
| 37 | + for module in all_modules: |
| 38 | + if _non_functional_content_changed(module): |
| 39 | + return None |
| 40 | + |
| 41 | + changes = _detect_module_changes(module) |
| 42 | + if not changes: |
| 43 | + continue |
| 44 | + |
| 45 | + current_fr_count = len(module._get_module_functional_requirements()) |
| 46 | + earliest_frid = _get_earliest_affected_frid(changes, current_fr_count) |
| 47 | + if earliest_frid is None: |
| 48 | + continue |
| 49 | + return PartialRenderStart(module=module, frid=earliest_frid) |
| 50 | + |
| 51 | + return None |
| 52 | + |
| 53 | + |
| 54 | +def _non_functional_content_changed(module: "PlainModule") -> bool: |
| 55 | + """Check whether anything outside functional specs changed since last render. |
| 56 | +
|
| 57 | + A missing stored hash (older builds) is treated as changed — partial rendering |
| 58 | + is unsafe without a known baseline. |
| 59 | + """ |
| 60 | + metadata = module.load_module_metadata() |
| 61 | + if not metadata: |
| 62 | + return False |
| 63 | + stored_hash = metadata.get(NON_FUNCTIONAL_SOURCE_HASH_KEY) |
| 64 | + if stored_hash is None: |
| 65 | + return True |
| 66 | + return stored_hash != module.get_module_non_functional_source_hash() |
| 67 | + |
| 68 | + |
| 69 | +def _get_earliest_affected_frid(changes: list[FunctionalityChange], current_fr_count: int) -> str | None: |
| 70 | + """Earliest FRID (in current spec numbering) that must be re-rendered. |
| 71 | +
|
| 72 | + Returns the minimum position across all changes: |
| 73 | + - added / edited: the FRID itself. |
| 74 | + - removed: the position the removal opened up (now occupied by the next FR). |
| 75 | + - moved: the FR's old position. |
| 76 | +
|
| 77 | + Correctness does not rely on any single change type's position being individually |
| 78 | + "the" earliest (in particular, a move's old position is *not* always its earliest |
| 79 | + touched position once moves mix with adds/removes). The guarantee is structural: |
| 80 | + the first index at which the new spec diverges from the old is always emitted as |
| 81 | + some change anchored at that index — a move with that old position, or an |
| 82 | + edit/removal/addition there — so the minimum over all change FRIDs lands at or |
| 83 | + before the true first divergence. Rendering runs from this FRID to the end of the |
| 84 | + module, so an at-or-before start point is always safe (it can re-render unchanged |
| 85 | + trailing FRs, but never skips a changed one). |
| 86 | +
|
| 87 | + Returns None when every change is a removal beyond the current spec length |
| 88 | + (only trailing FRs were removed, so nothing needs rendering). |
| 89 | + """ |
| 90 | + earliest = None |
| 91 | + for change in changes: |
| 92 | + frid_int = int(change.frid) |
| 93 | + if change.change_type == "removed" and frid_int > current_fr_count: |
| 94 | + continue |
| 95 | + if earliest is None or frid_int < earliest: |
| 96 | + earliest = frid_int |
| 97 | + if earliest is None: |
| 98 | + return None |
| 99 | + return str(earliest) |
| 100 | + |
| 101 | + |
| 102 | +def _detect_module_changes(module: "PlainModule") -> list[FunctionalityChange]: |
| 103 | + metadata = module.load_module_metadata() |
| 104 | + old_frs: list[str] = metadata.get(MODULE_FUNCTIONALITIES_KEY, []) if metadata else [] |
| 105 | + new_frs: list[str] = module._get_module_functional_requirements() |
| 106 | + |
| 107 | + if old_frs == new_frs: |
| 108 | + return [] |
| 109 | + |
| 110 | + moves, edits, removed, added = _classify_changes(old_frs, new_frs) |
| 111 | + |
| 112 | + changes: list[FunctionalityChange] = [] |
| 113 | + name = module.module_name |
| 114 | + |
| 115 | + for old_idx, new_idx in moves: |
| 116 | + old_frid = _frid_from_index(old_idx) |
| 117 | + new_frid = _frid_from_index(new_idx) |
| 118 | + changes.append(FunctionalityChange(module=name, frid=old_frid, change_type="moved", detail=new_frid)) |
| 119 | + |
| 120 | + for idx in edits: |
| 121 | + changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="edited")) |
| 122 | + |
| 123 | + for idx in removed: |
| 124 | + changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="removed")) |
| 125 | + |
| 126 | + for idx in added: |
| 127 | + changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="added")) |
| 128 | + |
| 129 | + return changes |
| 130 | + |
| 131 | + |
| 132 | +def _classify_changes( |
| 133 | + old_frs: list[str], new_frs: list[str] |
| 134 | +) -> tuple[list[tuple[int, int]], list[int], list[int], list[int]]: |
| 135 | + matched_old: set[int] = set() |
| 136 | + matched_new: set[int] = set() |
| 137 | + |
| 138 | + for i in range(min(len(old_frs), len(new_frs))): |
| 139 | + if old_frs[i] == new_frs[i]: |
| 140 | + matched_old.add(i) |
| 141 | + matched_new.add(i) |
| 142 | + |
| 143 | + content_matches: list[tuple[int, int]] = [] |
| 144 | + for old_idx in range(len(old_frs)): |
| 145 | + if old_idx in matched_old: |
| 146 | + continue |
| 147 | + for new_idx in range(len(new_frs)): |
| 148 | + if new_idx in matched_new: |
| 149 | + continue |
| 150 | + if old_frs[old_idx] == new_frs[new_idx]: |
| 151 | + content_matches.append((old_idx, new_idx)) |
| 152 | + matched_old.add(old_idx) |
| 153 | + matched_new.add(new_idx) |
| 154 | + break |
| 155 | + |
| 156 | + moves: list[tuple[int, int]] = [] |
| 157 | + if content_matches and _has_relative_order_change(content_matches): |
| 158 | + moves = content_matches |
| 159 | + |
| 160 | + edits: list[int] = [] |
| 161 | + for i in range(min(len(old_frs), len(new_frs))): |
| 162 | + if i not in matched_old and i not in matched_new: |
| 163 | + edits.append(i) |
| 164 | + matched_old.add(i) |
| 165 | + matched_new.add(i) |
| 166 | + |
| 167 | + removed = [i for i in range(len(old_frs)) if i not in matched_old] |
| 168 | + added = [i for i in range(len(new_frs)) if i not in matched_new] |
| 169 | + |
| 170 | + return moves, edits, removed, added |
| 171 | + |
| 172 | + |
| 173 | +def _has_relative_order_change(matches: list[tuple[int, int]]) -> bool: |
| 174 | + """Check if content matches represent a true reorder (relative order changed). |
| 175 | +
|
| 176 | + If all matches preserve relative order (sorted by old_idx gives same ordering |
| 177 | + as sorted by new_idx), it's just a positional shift from insertions/removals. |
| 178 | + """ |
| 179 | + if len(matches) <= 1: |
| 180 | + return False |
| 181 | + sorted_by_old = sorted(matches, key=lambda m: m[0]) |
| 182 | + new_indices = [m[1] for m in sorted_by_old] |
| 183 | + for i in range(len(new_indices) - 1): |
| 184 | + if new_indices[i] > new_indices[i + 1]: |
| 185 | + return True |
| 186 | + return False |
| 187 | + |
| 188 | + |
| 189 | +def _frid_from_index(index: int) -> str: |
| 190 | + return str(index + 1) |
0 commit comments