| 1 | #!/usr/bin/env python3 |
| 2 | """Deterministic command migration manifest gate for EPIC-006 (FEAT-015). |
| 3 | |
| 4 | Enforces the staged-migration contract: |
| 5 | |
| 6 | 1. The checked-in migration topology document (`scripts/command-migration-topology.json`) |
| 7 | is versioned and fail-closed: `schema_version` must be 1; unknown versions, |
| 8 | tags, fields, selector kinds, type/trait node tags, and const atoms are rejected. |
| 9 | 2. The pending frontier is a valid topology frontier: sorted, unique, containing |
| 10 | only known leaves, and reachable from the roots by parent-to-all-children |
| 11 | replacements (documented splits) or leaf removals (migrations). Arbitrary |
| 12 | additions, partial splits, and stale entries fail closed. |
| 13 | 3. The frontier exactly equals the set of groups/slices whose handlers still |
| 14 | contain concrete-`App` signatures (`&mut App` / `&mut crate::tui::app::App`) |
| 15 | within `crates/tui/src/commands/groups/` (bidirectional source scan; the |
| 16 | AST/selector resolution part lands in Phase 3, `scan_and_check`). |
| 17 | |
| 18 | The guard is hermetic for its data parts: it reads the topology artifact and |
| 19 | optionally the source tree; it never starts the TUI and makes no network calls. |
| 20 | |
| 21 | Usage: |
| 22 | python3 scripts/check-command-migration-manifest.py # enforce |
| 23 | """ |
| 24 | |
| 25 | from __future__ import annotations |
| 26 | |
| 27 | import argparse |
| 28 | import json |
| 29 | import re |
| 30 | import subprocess |
| 31 | import sys |
| 32 | from pathlib import Path |
| 33 | |
| 34 | REPO_ROOT = Path(__file__).resolve().parent.parent |
| 35 | TOPOLOGY_PATH = REPO_ROOT / "scripts" / "command-migration-topology.json" |
| 36 | CONTRACT_PATH = REPO_ROOT / "crates" / "tui" / "src" / "commands" / "contract.rs" |
| 37 | TOPOLOGY_REPO_PATH = "scripts/command-migration-topology.json" |
| 38 | SUPPORTED_SCHEMA_VERSION = 1 |
| 39 | |
| 40 | # Closed set of Rust integer suffixes accepted by selector const atoms. |
| 41 | INTEGER_SUFFIXES = { |
| 42 | "i8", "u8", "i16", "u16", "i32", "u32", "i64", "u64", "i128", "u128", |
| 43 | "isize", "usize", |
| 44 | } |
| 45 | |
| 46 | # Closed set of primitive type names accepted by the type algebra (v1). |
| 47 | PRIMITIVE_TYPES = { |
| 48 | "u8", "u16", "u32", "u64", "u128", "usize", |
| 49 | "i8", "i16", "i32", "i64", "i128", "isize", |
| 50 | "f32", "f64", "bool", "char", "str", |
| 51 | } |
| 52 | |
| 53 | VALID_SELECTOR_KINDS = {"free", "inherent", "trait_impl"} |
| 54 | VALID_TYPE_TAGS = { |
| 55 | "path", "qualified", "tuple", "reference", "pointer", "slice", "array", |
| 56 | "primitive", "never", |
| 57 | } |
| 58 | VALID_GENERIC_ARG_TAGS = {"lifetime", "type", "const"} |
| 59 | VALID_CONST_TAGS = {"bool", "int", "char", "path"} |
| 60 | |
| 61 | |
| 62 | class ManifestViolation: |
| 63 | """One deterministic manifest violation with an actionable diagnostic.""" |
| 64 | |
| 65 | def __init__(self, category: str, location: str, detail: str) -> None: |
| 66 | self.category = category |
| 67 | self.location = location |
| 68 | self.detail = detail |
| 69 | |
| 70 | def __str__(self) -> str: |
| 71 | return f"{self.category}: {self.location}: {self.detail}" |
| 72 | |
| 73 | |
| 74 | # --------------------------------------------------------------------------- |
| 75 | # Const atom validation (Deep-Dive: closed four-tag records, canonical decimal |
| 76 | # magnitudes, byte bounds, fail-closed unknown forms). |
| 77 | # --------------------------------------------------------------------------- |
| 78 | |
| 79 | def _canonical_magnitude(value: str, location: str) -> list[ManifestViolation]: |
| 80 | """Validate `0|[1-9][0-9]*` and return violations (never leading zeros).""" |
| 81 | if value == "0": |
| 82 | return [] |
| 83 | if not value.isdigit() or value[0] == "0": |
| 84 | return [ManifestViolation( |
| 85 | "const-atom", location, |
| 86 | f"integer magnitude must be canonical unsigned decimal without leading zeros " |
| 87 | f"(0|[1-9][0-9]*), got {value!r}", |
| 88 | )] |
| 89 | return [] |
| 90 | |
| 91 | |
| 92 | def validate_const_atom(atom, location: str) -> list[ManifestViolation]: |
| 93 | """Validate one const atom record; reject unknown tags/fields/forms.""" |
| 94 | if not isinstance(atom, dict) or "tag" not in atom: |
| 95 | return [ManifestViolation("const-atom", location, "const atom must be an object with a tag")] |
| 96 | tag = atom["tag"] |
| 97 | if tag not in VALID_CONST_TAGS: |
| 98 | return [ManifestViolation( |
| 99 | "const-atom", location, f"unknown const atom tag {tag!r}; expected one of {sorted(VALID_CONST_TAGS)}", |
| 100 | )] |
| 101 | violations: list[ManifestViolation] = [] |
| 102 | if tag == "bool": |
| 103 | if set(atom) != {"tag", "value"} or not isinstance(atom.get("value"), bool): |
| 104 | violations.append(ManifestViolation( |
| 105 | "const-atom", location, "bool const atom must be {{tag: bool, value: <bool>}}", |
| 106 | )) |
| 107 | elif tag == "int": |
| 108 | allowed = {"tag", "negative", "magnitude", "suffix"} |
| 109 | if set(atom) != allowed: |
| 110 | violations.append(ManifestViolation( |
| 111 | "const-atom", location, |
| 112 | f"int const atom must have exactly {{tag, negative, magnitude, suffix}}, got {sorted(atom)}", |
| 113 | )) |
| 114 | return violations |
| 115 | if not isinstance(atom.get("negative"), bool): |
| 116 | violations.append(ManifestViolation("const-atom", location, "int.negative must be a bool")) |
| 117 | magnitude = atom.get("magnitude") |
| 118 | if not isinstance(magnitude, str): |
| 119 | violations.append(ManifestViolation("const-atom", location, "int.magnitude must be a string")) |
| 120 | else: |
| 121 | violations.extend(_canonical_magnitude(magnitude, location)) |
| 122 | suffix = atom.get("suffix") |
| 123 | if suffix is not None and suffix not in INTEGER_SUFFIXES: |
| 124 | violations.append(ManifestViolation( |
| 125 | "const-atom", location, f"int.suffix must be null or a Rust integer suffix, got {suffix!r}", |
| 126 | )) |
| 127 | # Negative zero is noncanonical: normalize to nonnegative. |
| 128 | if atom.get("negative") and magnitude == "0": |
| 129 | violations.append(ManifestViolation( |
| 130 | "const-atom", location, "negative zero must be normalized to nonnegative", |
| 131 | )) |
| 132 | # Decoded byte values (u8 suffix) must be within 0-255 inclusive. |
| 133 | if suffix == "u8" and magnitude is not None and magnitude.isdigit(): |
| 134 | if int(magnitude) > 255: |
| 135 | violations.append(ManifestViolation( |
| 136 | "const-atom", location, |
| 137 | f"decoded byte value {magnitude} exceeds the inclusive u8 range 0-255", |
| 138 | )) |
| 139 | elif tag == "char": |
| 140 | if set(atom) != {"tag", "scalar"} or not isinstance(atom.get("scalar"), str): |
| 141 | violations.append(ManifestViolation( |
| 142 | "const-atom", location, "char const atom must be {{tag: char, scalar: <single Unicode scalar>}}", |
| 143 | )) |
| 144 | else: |
| 145 | scalar = atom["scalar"] |
| 146 | if len(scalar) != 1: |
| 147 | violations.append(ManifestViolation( |
| 148 | "const-atom", location, "char.scalar must be exactly one Unicode scalar", |
| 149 | )) |
| 150 | elif tag == "path": |
| 151 | if set(atom) != {"tag", "absolute", "segments"}: |
| 152 | violations.append(ManifestViolation( |
| 153 | "const-atom", location, |
| 154 | "path const atom must be {{tag: path, absolute: <bool>, segments: [...]}}", |
| 155 | )) |
| 156 | return violations |
| 157 | if not isinstance(atom.get("absolute"), bool): |
| 158 | violations.append(ManifestViolation("const-atom", location, "path.absolute must be a bool")) |
| 159 | segments = atom.get("segments") |
| 160 | if not isinstance(segments, list) or not segments: |
| 161 | violations.append(ManifestViolation("const-atom", location, "path.segments must be a nonempty array")) |
| 162 | elif not all(isinstance(s, str) and s for s in segments): |
| 163 | violations.append(ManifestViolation("const-atom", location, "path.segments must be nonempty strings")) |
| 164 | return violations |
| 165 | |
| 166 | |
| 167 | # --------------------------------------------------------------------------- |
| 168 | # Type algebra validation (Deep-Dive: closed v1 algebra, fail-closed). |
| 169 | # --------------------------------------------------------------------------- |
| 170 | |
| 171 | def validate_type_node(node, location: str) -> list[ManifestViolation]: |
| 172 | """Validate a recursive type/trait node; reject unsupported syntax.""" |
| 173 | if not isinstance(node, dict) or "tag" not in node: |
| 174 | return [ManifestViolation("type-algebra", location, "type node must be an object with a tag")] |
| 175 | tag = node["tag"] |
| 176 | if tag not in VALID_TYPE_TAGS: |
| 177 | return [ManifestViolation( |
| 178 | "type-algebra", location, |
| 179 | f"unknown type node tag {tag!r}; expected one of {sorted(VALID_TYPE_TAGS)}", |
| 180 | )] |
| 181 | violations: list[ManifestViolation] = [] |
| 182 | |
| 183 | if tag == "path": |
| 184 | allowed = {"tag", "absolute", "segments"} |
| 185 | if set(node) != allowed: |
| 186 | return [ManifestViolation( |
| 187 | "type-algebra", location, f"path node must have exactly {{tag, absolute, segments}}, got {sorted(node)}", |
| 188 | )] |
| 189 | if not isinstance(node.get("absolute"), bool): |
| 190 | violations.append(ManifestViolation("type-algebra", location, "path.absolute must be a bool")) |
| 191 | segments = node.get("segments") |
| 192 | if not isinstance(segments, list) or not segments: |
| 193 | return violations + [ManifestViolation("type-algebra", location, "path.segments must be a nonempty array")] |
| 194 | for i, seg in enumerate(segments): |
| 195 | seg_loc = f"{location}.segments[{i}]" |
| 196 | if not isinstance(seg, dict) or "name" not in seg or not isinstance(seg.get("name"), str) or not seg["name"]: |
| 197 | violations.append(ManifestViolation("type-algebra", seg_loc, "segment must be {{name: <string>, args?: [...]}}")) |
| 198 | continue |
| 199 | if "args" in seg: |
| 200 | if not isinstance(seg["args"], list): |
| 201 | violations.append(ManifestViolation("type-algebra", seg_loc, "segment.args must be an array")) |
| 202 | continue |
| 203 | for j, arg in enumerate(seg["args"]): |
| 204 | violations.extend(validate_generic_arg(arg, f"{seg_loc}.args[{j}]")) |
| 205 | elif tag == "qualified": |
| 206 | allowed = {"tag", "self", "assoc"} |
| 207 | if set(node) != allowed: |
| 208 | return [ManifestViolation("type-algebra", location, "qualified node must have exactly {tag, self, assoc}")] |
| 209 | violations.extend(validate_type_node(node.get("self"), f"{location}.self")) |
| 210 | if not isinstance(node.get("assoc"), str) or not node["assoc"]: |
| 211 | violations.append(ManifestViolation("type-algebra", location, "qualified.assoc must be a nonempty string")) |
| 212 | elif tag == "tuple": |
| 213 | if set(node) != {"tag", "elems"} or not isinstance(node.get("elems"), list): |
| 214 | return [ManifestViolation("type-algebra", location, "tuple node must be {{tag: tuple, elems: [...]}}")] |
| 215 | for i, elem in enumerate(node["elems"]): |
| 216 | violations.extend(validate_type_node(elem, f"{location}.elems[{i}]")) |
| 217 | elif tag in ("reference", "pointer"): |
| 218 | allowed = {"tag", "mut", "inner"} |
| 219 | if set(node) != allowed or not isinstance(node.get("mut"), bool): |
| 220 | return [ManifestViolation("type-algebra", location, f"{tag} node must be {{tag, mut, inner}}")] |
| 221 | violations.extend(validate_type_node(node.get("inner"), f"{location}.inner")) |
| 222 | elif tag == "slice": |
| 223 | if set(node) != {"tag", "inner"}: |
| 224 | return [ManifestViolation("type-algebra", location, "slice node must be {{tag: slice, inner}}")] |
| 225 | violations.extend(validate_type_node(node.get("inner"), f"{location}.inner")) |
| 226 | elif tag == "array": |
| 227 | allowed = {"tag", "inner", "len"} |
| 228 | if set(node) != allowed: |
| 229 | return [ManifestViolation("type-algebra", location, "array node must be {{tag: array, inner, len}}")] |
| 230 | violations.extend(validate_type_node(node.get("inner"), f"{location}.inner")) |
| 231 | violations.extend(validate_const_atom(node.get("len"), f"{location}.len")) |
| 232 | elif tag == "primitive": |
| 233 | if set(node) != {"tag", "name"} or node.get("name") not in PRIMITIVE_TYPES: |
| 234 | return [ManifestViolation( |
| 235 | "type-algebra", location, |
| 236 | f"primitive node must be {{tag: primitive, name}} with name in {sorted(PRIMITIVE_TYPES)}", |
| 237 | )] |
| 238 | elif tag == "never": |
| 239 | if set(node) != {"tag"}: |
| 240 | return [ManifestViolation("type-algebra", location, "never node must be {{tag: never}}")] |
| 241 | return violations |
| 242 | |
| 243 | |
| 244 | def validate_generic_arg(arg, location: str) -> list[ManifestViolation]: |
| 245 | """Validate one path-segment generic argument (type/lifetime/const).""" |
| 246 | if not isinstance(arg, dict) or "tag" not in arg: |
| 247 | return [ManifestViolation("type-algebra", location, "generic argument must be an object with a tag")] |
| 248 | tag = arg["tag"] |
| 249 | if tag not in VALID_GENERIC_ARG_TAGS: |
| 250 | return [ManifestViolation( |
| 251 | "type-algebra", location, |
| 252 | f"unknown generic argument tag {tag!r}; expected one of {sorted(VALID_GENERIC_ARG_TAGS)}", |
| 253 | )] |
| 254 | if tag == "lifetime": |
| 255 | if set(arg) != {"tag", "name"} or not isinstance(arg.get("name"), str) or not arg["name"]: |
| 256 | return [ManifestViolation("type-algebra", location, "lifetime argument must be {{tag: lifetime, name}}")] |
| 257 | elif tag == "type": |
| 258 | return validate_type_node(arg.get("node"), f"{location}.node") |
| 259 | else: # const |
| 260 | if set(arg) != {"tag", "atom"}: |
| 261 | return [ManifestViolation("type-algebra", location, "const argument must be {{tag: const, atom}}")] |
| 262 | return validate_const_atom(arg.get("atom"), f"{location}.atom") |
| 263 | return [] |
| 264 | |
| 265 | |
| 266 | # --------------------------------------------------------------------------- |
| 267 | # Selector validation (Deep-Dive: tagged structural records). |
| 268 | # --------------------------------------------------------------------------- |
| 269 | |
| 270 | def validate_selector(selector, location: str) -> list[ManifestViolation]: |
| 271 | """Validate one handler selector record (free / inherent / trait_impl).""" |
| 272 | if not isinstance(selector, dict) or "kind" not in selector: |
| 273 | return [ManifestViolation("selector", location, "selector must be an object with a kind")] |
| 274 | kind = selector["kind"] |
| 275 | if kind not in VALID_SELECTOR_KINDS: |
| 276 | return [ManifestViolation( |
| 277 | "selector", location, |
| 278 | f"unknown selector kind {kind!r}; expected one of {sorted(VALID_SELECTOR_KINDS)}", |
| 279 | )] |
| 280 | violations: list[ManifestViolation] = [] |
| 281 | if kind == "free": |
| 282 | allowed = {"kind", "item"} |
| 283 | if set(selector) != allowed: |
| 284 | return [ManifestViolation("selector", location, f"free selector must have exactly {{kind, item}}, got {sorted(selector)}")] |
| 285 | item = selector.get("item") |
| 286 | if not isinstance(item, list) or len(item) < 2: |
| 287 | return [ManifestViolation("selector", location, "free.item must be a module path array ending with the function name")] |
| 288 | if not all(isinstance(s, str) and s for s in item): |
| 289 | return [ManifestViolation("selector", location, "free.item entries must be nonempty strings")] |
| 290 | elif kind == "inherent": |
| 291 | allowed = {"kind", "self_type", "method"} |
| 292 | if "method" not in selector: |
| 293 | return [ManifestViolation("selector", location, "inherent.method must be a nonempty string")] |
| 294 | if set(selector) != allowed: |
| 295 | return [ManifestViolation("selector", location, f"inherent selector must have exactly {{kind, self_type, method}}, got {sorted(selector)}")] |
| 296 | violations.extend(validate_type_node(selector.get("self_type"), f"{location}.self_type")) |
| 297 | if not isinstance(selector.get("method"), str) or not selector["method"]: |
| 298 | violations.append(ManifestViolation("selector", location, "inherent.method must be a nonempty string")) |
| 299 | else: # trait_impl |
| 300 | allowed = {"kind", "self_type", "trait_path", "method"} |
| 301 | if set(selector) != allowed: |
| 302 | return [ManifestViolation("selector", location, f"trait_impl selector must have exactly {{kind, self_type, trait_path, method}}, got {sorted(selector)}")] |
| 303 | violations.extend(validate_type_node(selector.get("self_type"), f"{location}.self_type")) |
| 304 | violations.extend(validate_type_node(selector.get("trait_path"), f"{location}.trait_path")) |
| 305 | if not isinstance(selector.get("method"), str) or not selector["method"]: |
| 306 | violations.append(ManifestViolation("selector", location, "trait_impl.method must be a nonempty string")) |
| 307 | return violations |
| 308 | |
| 309 | |
| 310 | # --------------------------------------------------------------------------- |
| 311 | # Topology / frontier validation. |
| 312 | # --------------------------------------------------------------------------- |
| 313 | |
| 314 | def all_leaves(topology: dict) -> dict[str, str]: |
| 315 | """Map leaf name -> owning root for every group and predeclared slice.""" |
| 316 | leaves: dict[str, str] = {} |
| 317 | for root, node in topology.items(): |
| 318 | leaves[root] = root |
| 319 | for slice_node in node.get("slices", []): |
| 320 | leaves[slice_node["name"]] = root |
| 321 | return leaves |
| 322 | |
| 323 | |
| 324 | def validate_frontier(topology: dict, frontier: list[str]) -> list[ManifestViolation]: |
| 325 | """Frontier must be sorted, unique, and reference only known leaves.""" |
| 326 | violations: list[ManifestViolation] = [] |
| 327 | if not isinstance(frontier, list): |
| 328 | return [ManifestViolation("frontier", "frontier", "frontier must be an array")] |
| 329 | if frontier != sorted(frontier): |
| 330 | violations.append(ManifestViolation("frontier", "frontier", "frontier must be sorted")) |
| 331 | if len(frontier) != len(set(frontier)): |
| 332 | violations.append(ManifestViolation("frontier", "frontier", "frontier must contain no duplicates")) |
| 333 | leaves = all_leaves(topology) |
| 334 | for entry in frontier: |
| 335 | if entry not in leaves: |
| 336 | violations.append(ManifestViolation( |
| 337 | "frontier", entry, f"frontier entry {entry!r} is not a declared topology leaf", |
| 338 | )) |
| 339 | return violations |
| 340 | |
| 341 | |
| 342 | def is_documented_split(topology: dict, old: set[str], new: set[str]) -> bool: |
| 343 | """A parent was replaced by ALL of its declared children (1->N, no growth). |
| 344 | |
| 345 | Exactly one entry is removed; it is a group with declared slices; the added |
| 346 | entries are exactly those declared children; nothing else changed. |
| 347 | """ |
| 348 | removed = old - new |
| 349 | added = new - old |
| 350 | if len(removed) != 1: |
| 351 | return False |
| 352 | parent = next(iter(removed)) |
| 353 | for root, node in topology.items(): |
| 354 | children = {s["name"] for s in node.get("slices", [])} |
| 355 | if parent == root and children: |
| 356 | return children == added and len(added) == len(children) and added == new - old |
| 357 | return False |
| 358 | |
| 359 | |
| 360 | def is_valid_frontier_transition(topology: dict, old: list[str], new: list[str]) -> list[ManifestViolation]: |
| 361 | """Permit shrink (removal of migrated leaves) or documented parent-to-all-children split.""" |
| 362 | old_set = set(old) |
| 363 | new_set = set(new) |
| 364 | violations: list[ManifestViolation] = [] |
| 365 | removed = old_set - new_set |
| 366 | added = new_set - old_set |
| 367 | |
| 368 | # Pure shrink: removed leaves only, nothing added. |
| 369 | if not added: |
| 370 | return violations # any removal is a shrink (gate re-checks source later) |
| 371 | |
| 372 | # Documented split: exactly one parent removed, all its children added. |
| 373 | if is_documented_split(topology, old_set, new_set): |
| 374 | return violations |
| 375 | |
| 376 | # Everything else is growth or partial split: fail closed. |
| 377 | if removed: |
| 378 | violations.append(ManifestViolation( |
| 379 | "frontier-transition", ", ".join(sorted(removed)), |
| 380 | "frontier shrank AND grew; only pure shrink or documented parent-to-all-children split is allowed", |
| 381 | )) |
| 382 | else: |
| 383 | violations.append(ManifestViolation( |
| 384 | "frontier-transition", ", ".join(sorted(added)), |
| 385 | "frontier grew without a documented parent-to-all-children split; arbitrary growth is forbidden", |
| 386 | )) |
| 387 | return violations |
| 388 | |
| 389 | |
| 390 | def validate_topology_document(doc: dict) -> list[ManifestViolation]: |
| 391 | """Validate the whole topology document (schema, topology, frontier, slices).""" |
| 392 | violations: list[ManifestViolation] = [] |
| 393 | if not isinstance(doc, dict): |
| 394 | return [ManifestViolation("schema", "document", "topology document must be an object")] |
| 395 | if doc.get("schema_version") != SUPPORTED_SCHEMA_VERSION: |
| 396 | return [ManifestViolation( |
| 397 | "schema", "schema_version", |
| 398 | f"unsupported schema_version {doc.get('schema_version')!r}; only {SUPPORTED_SCHEMA_VERSION} is supported", |
| 399 | )] |
| 400 | topology = doc.get("topology") |
| 401 | if not isinstance(topology, dict) or not topology: |
| 402 | return [ManifestViolation("schema", "topology", "topology must be a nonempty object")] |
| 403 | for root, node in topology.items(): |
| 404 | if not isinstance(node, dict): |
| 405 | violations.append(ManifestViolation("schema", root, "group node must be an object")) |
| 406 | continue |
| 407 | allowed_group_fields = {"kind", "scope", "slices"} |
| 408 | unknown = set(node) - allowed_group_fields |
| 409 | if unknown: |
| 410 | violations.append(ManifestViolation( |
| 411 | "schema", root, f"unknown group field(s) {sorted(unknown)}; expected {sorted(allowed_group_fields)}", |
| 412 | )) |
| 413 | if "kind" not in node or "scope" not in node or "slices" not in node: |
| 414 | violations.append(ManifestViolation( |
| 415 | "schema", root, "group node must be {{kind, scope, slices}}", |
| 416 | )) |
| 417 | continue |
| 418 | if node.get("kind") != "group": |
| 419 | violations.append(ManifestViolation("schema", root, f"group {root!r} kind must be 'group'")) |
| 420 | scope = node.get("scope") |
| 421 | if not isinstance(scope, list) or not scope or not all(isinstance(s, str) and s for s in scope): |
| 422 | violations.append(ManifestViolation("schema", root, "group scope must be a nonempty array of strings")) |
| 423 | slices = node.get("slices") |
| 424 | if not isinstance(slices, list): |
| 425 | violations.append(ManifestViolation("schema", root, "group slices must be an array")) |
| 426 | continue |
| 427 | seen: set[str] = set() |
| 428 | for i, slice_node in enumerate(slices): |
| 429 | loc = f"{root}.slices[{i}]" |
| 430 | if not isinstance(slice_node, dict): |
| 431 | violations.append(ManifestViolation("schema", loc, "slice must be an object")) |
| 432 | continue |
| 433 | allowed_slice_fields = {"name", "kind", "scope", "handlers"} |
| 434 | unknown = set(slice_node) - allowed_slice_fields |
| 435 | if unknown: |
| 436 | violations.append(ManifestViolation( |
| 437 | "schema", loc, f"unknown slice field(s) {sorted(unknown)}; expected {sorted(allowed_slice_fields)}", |
| 438 | )) |
| 439 | if "name" not in slice_node: |
| 440 | violations.append(ManifestViolation("schema", loc, "slice must be an object with a name")) |
| 441 | continue |
| 442 | name = slice_node["name"] |
| 443 | if name in seen: |
| 444 | violations.append(ManifestViolation("schema", loc, f"duplicate slice name {name!r}")) |
| 445 | seen.add(name) |
| 446 | if not name.startswith(f"{root}::"): |
| 447 | violations.append(ManifestViolation("schema", loc, f"slice name {name!r} must start with {root!r}::")) |
| 448 | if slice_node.get("kind") != "slice": |
| 449 | violations.append(ManifestViolation("schema", loc, f"slice {name!r} kind must be 'slice'")) |
| 450 | slice_scope = slice_node.get("scope") |
| 451 | if not isinstance(slice_scope, list) or not slice_scope or not all(isinstance(s, str) and s for s in slice_scope): |
| 452 | violations.append(ManifestViolation("schema", loc, f"slice {name!r} scope must be a nonempty array of strings")) |
| 453 | for selector in slice_node.get("handlers", []): |
| 454 | violations.extend(validate_selector(selector, f"{loc}.handlers")) |
| 455 | violations.extend(validate_frontier(topology, doc.get("frontier", []))) |
| 456 | return violations |
| 457 | |
| 458 | |
| 459 | def load_topology(path: Path = TOPOLOGY_PATH) -> dict: |
| 460 | with path.open(encoding="utf-8") as fh: |
| 461 | return json.load(fh) |
| 462 | |
| 463 | |
| 464 | def load_pending_groups(path: Path = CONTRACT_PATH) -> list[str]: |
| 465 | """Read the TUI frontier projection from `PENDING_GROUPS` fail-closed.""" |
| 466 | source = path.read_text(encoding="utf-8") |
| 467 | match = re.search( |
| 468 | r"pub\(crate\)\s+const\s+PENDING_GROUPS\s*:\s*&\[&str\]\s*=\s*&\[(.*?)\];", |
| 469 | source, |
| 470 | re.DOTALL, |
| 471 | ) |
| 472 | if match is None: |
| 473 | raise ValueError(f"could not locate PENDING_GROUPS in {path}") |
| 474 | body = match.group(1) |
| 475 | stripped = re.sub(r'"(?:\\.|[^"\\])*"', "", body) |
| 476 | if re.fullmatch(r"[\s,]*", stripped) is None: |
| 477 | raise ValueError("PENDING_GROUPS may contain string literals only") |
| 478 | return re.findall(r'"((?:\\.|[^"\\])*)"', body) |
| 479 | |
| 480 | |
| 481 | def validate_pending_projection(doc: dict, pending: list[str]) -> list[ManifestViolation]: |
| 482 | if pending != doc.get("frontier"): |
| 483 | return [ManifestViolation( |
| 484 | "frontier-projection", |
| 485 | "PENDING_GROUPS", |
| 486 | f"TUI projection {pending!r} does not equal JSON frontier {doc.get('frontier')!r}", |
| 487 | )] |
| 488 | return [] |
| 489 | |
| 490 | |
| 491 | def load_topology_at_ref(ref: str, root: Path = REPO_ROOT) -> dict | None: |
| 492 | """Load the topology from a Git revision; return None before its introduction.""" |
| 493 | commit = subprocess.run( |
| 494 | ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"], |
| 495 | cwd=root, |
| 496 | capture_output=True, |
| 497 | text=True, |
| 498 | check=False, |
| 499 | ) |
| 500 | if commit.returncode != 0: |
| 501 | raise ValueError(f"baseline ref {ref!r} is unavailable: {commit.stderr.strip()}") |
| 502 | shown = subprocess.run( |
| 503 | ["git", "show", f"{ref}:{TOPOLOGY_REPO_PATH}"], |
| 504 | cwd=root, |
| 505 | capture_output=True, |
| 506 | text=True, |
| 507 | check=False, |
| 508 | ) |
| 509 | if shown.returncode != 0: |
| 510 | return None |
| 511 | return json.loads(shown.stdout) |
| 512 | |
| 513 | |
| 514 | def validate_baseline_transition(current: dict, previous: dict | None) -> list[ManifestViolation]: |
| 515 | """Enforce immutable topology and shrink-or-declared-split across revisions.""" |
| 516 | if validate_topology_document(current): |
| 517 | return [ManifestViolation( |
| 518 | "current", |
| 519 | "topology", |
| 520 | "current topology is invalid; cannot validate a monotonic transition", |
| 521 | )] |
| 522 | if previous is None: |
| 523 | roots = sorted(current["topology"]) |
| 524 | if current.get("frontier") != roots: |
| 525 | return [ManifestViolation( |
| 526 | "frontier-initialization", |
| 527 | "frontier", |
| 528 | f"first manifest revision must start at all topology roots {roots!r}", |
| 529 | )] |
| 530 | return [] |
| 531 | |
| 532 | violations = validate_topology_document(previous) |
| 533 | if violations: |
| 534 | return [ManifestViolation( |
| 535 | "baseline", |
| 536 | "topology", |
| 537 | "baseline topology is invalid; cannot validate a monotonic transition", |
| 538 | )] |
| 539 | if previous.get("topology") != current.get("topology"): |
| 540 | violations.append(ManifestViolation( |
| 541 | "topology-transition", |
| 542 | "topology", |
| 543 | "migration topology is immutable; only the frontier may change", |
| 544 | )) |
| 545 | return violations |
| 546 | violations.extend( |
| 547 | is_valid_frontier_transition( |
| 548 | previous["topology"], previous["frontier"], current["frontier"] |
| 549 | ) |
| 550 | ) |
| 551 | return violations |
| 552 | |
| 553 | |
| 554 | def detect_local_baseline_ref(root: Path = REPO_ROOT) -> str | None: |
| 555 | """Use the feature-branch merge-base, or the first parent on main.""" |
| 556 | process = subprocess.run( |
| 557 | ["git", "merge-base", "HEAD", "origin/main"], |
| 558 | cwd=root, |
| 559 | capture_output=True, |
| 560 | text=True, |
| 561 | check=False, |
| 562 | ) |
| 563 | baseline = process.stdout.strip() if process.returncode == 0 else None |
| 564 | head = subprocess.run( |
| 565 | ["git", "rev-parse", "HEAD"], |
| 566 | cwd=root, |
| 567 | capture_output=True, |
| 568 | text=True, |
| 569 | check=False, |
| 570 | ).stdout.strip() |
| 571 | if baseline and baseline != head: |
| 572 | return baseline |
| 573 | # A main checkout aligned with origin/main still has migration history. |
| 574 | # Comparing its parent preserves the ratchet after push and in manual CI. |
| 575 | parent = subprocess.run( |
| 576 | ["git", "rev-parse", "--verify", "HEAD^"], |
| 577 | cwd=root, |
| 578 | capture_output=True, |
| 579 | text=True, |
| 580 | check=False, |
| 581 | ) |
| 582 | return parent.stdout.strip() if parent.returncode == 0 else None |
| 583 | |
| 584 | |
| 585 | # --------------------------------------------------------------------------- |
| 586 | # Source scan (Task 3.5): structural Rust item resolution and bidirectional |
| 587 | # frontier check. Pure-stdlib parser: brace/paren aware, resolves qualified |
| 588 | # item paths for free functions, inherent methods, and trait-impl methods. |
| 589 | # --------------------------------------------------------------------------- |
| 590 | |
| 591 | GROUPS_ROOT = REPO_ROOT / "crates" / "tui" / "src" / "commands" / "groups" |
| 592 | CONCRETE_APP_PARAM = re.compile(r"&\s*mut\s+(crate::tui::app::)?App\b") |
| 593 | |
| 594 | |
| 595 | class RustItem: |
| 596 | """One parsed Rust item relevant to the migration scan.""" |
| 597 | |
| 598 | def __init__(self, kind: str, name: str, qual_path: str, file: Path, |
| 599 | line: int, param_types: list[str], is_concrete_app: bool) -> None: |
| 600 | self.kind = kind # 'free' | 'inherent' | 'trait_impl' |
| 601 | self.name = name |
| 602 | self.qual_path = qual_path |
| 603 | self.file = file |
| 604 | self.line = line |
| 605 | self.param_types = param_types |
| 606 | self.is_concrete_app = is_concrete_app |
| 607 | |
| 608 | def __repr__(self) -> str: |
| 609 | return f"RustItem({self.kind}, {self.qual_path}, app={self.is_concrete_app})" |
| 610 | |
| 611 | |
| 612 | class SourceScanViolation: |
| 613 | """One deterministic source-scan failure with an actionable diagnostic.""" |
| 614 | |
| 615 | def __init__(self, category: str, location: str, detail: str) -> None: |
| 616 | self.category = category |
| 617 | self.location = location |
| 618 | self.detail = detail |
| 619 | |
| 620 | def __str__(self) -> str: |
| 621 | return f"{self.category}: {self.location}: {self.detail}" |
| 622 | |
| 623 | |
| 624 | def _strip_comments_and_strings(text: str) -> str: |
| 625 | """Replace comments and string/char literals with spaces so the structural |
| 626 | scanner sees only code. Keeps raw strings and byte/char literals intact |
| 627 | enough for delimiter counting (content is blanked).""" |
| 628 | out = list(text) |
| 629 | i = 0 |
| 630 | n = len(text) |
| 631 | in_line_comment = False |
| 632 | in_block_comment = 0 |
| 633 | while i < n: |
| 634 | ch = text[i] |
| 635 | if in_line_comment: |
| 636 | if ch == "\n": |
| 637 | in_line_comment = False |
| 638 | else: |
| 639 | out[i] = " " |
| 640 | i += 1 |
| 641 | continue |
| 642 | if in_block_comment: |
| 643 | if text.startswith("*/", i): |
| 644 | in_block_comment -= 1 |
| 645 | out[i] = out[i + 1] = " " |
| 646 | i += 2 |
| 647 | else: |
| 648 | out[i] = " " |
| 649 | i += 1 |
| 650 | continue |
| 651 | if text.startswith("//", i): |
| 652 | in_line_comment = True |
| 653 | out[i] = out[i + 1] = " " |
| 654 | i += 2 |
| 655 | continue |
| 656 | if text.startswith("/*", i): |
| 657 | in_block_comment += 1 |
| 658 | out[i] = out[i + 1] = " " |
| 659 | i += 2 |
| 660 | continue |
| 661 | if ch == '"': |
| 662 | # String literal (possibly raw r#"..."#). Blank until closing quote. |
| 663 | out[i] = " " |
| 664 | i += 1 |
| 665 | if text.startswith('#"', i - 1): |
| 666 | while i < n and text[i] == "#": |
| 667 | out[i] = " " |
| 668 | i += 1 |
| 669 | while i < n: |
| 670 | if text[i] == "\\" and i + 1 < n: |
| 671 | out[i] = out[i + 1] = " " |
| 672 | i += 2 |
| 673 | continue |
| 674 | if text[i] == '"': |
| 675 | out[i] = " " |
| 676 | i += 1 |
| 677 | break |
| 678 | out[i] = " " |
| 679 | i += 1 |
| 680 | continue |
| 681 | if ch == "'": |
| 682 | # Char or lifetime. Blank the atom conservatively (lifetimes are |
| 683 | # single-quote-prefixed identifiers; chars are 'x' or '\\x'). |
| 684 | if i + 1 < n and text[i + 1] == "'": |
| 685 | out[i] = out[i + 1] = " " |
| 686 | i += 2 |
| 687 | continue |
| 688 | out[i] = " " |
| 689 | i += 1 |
| 690 | if i < n and text[i] == "\\": |
| 691 | out[i] = " " |
| 692 | i += 1 |
| 693 | if i < n: |
| 694 | out[i] = " " |
| 695 | i += 1 |
| 696 | if i < n and text[i] == "'": |
| 697 | out[i] = " " |
| 698 | i += 1 |
| 699 | continue |
| 700 | i += 1 |
| 701 | return "".join(out) |
| 702 | |
| 703 | |
| 704 | def _split_top_level(text: str, sep: str) -> list[str]: |
| 705 | """Split on a separator character outside (), [], {} and strings.""" |
| 706 | parts: list[str] = [] |
| 707 | start = 0 |
| 708 | depth = 0 |
| 709 | for i, ch in enumerate(text): |
| 710 | if ch in "([{": |
| 711 | depth += 1 |
| 712 | elif ch in ")]}": |
| 713 | depth -= 1 |
| 714 | elif ch == sep and depth == 0: |
| 715 | parts.append(text[start:i]) |
| 716 | start = i + 1 |
| 717 | parts.append(text[start:]) |
| 718 | return parts |
| 719 | |
| 720 | |
| 721 | def _module_path_from_file(file: Path, root: Path) -> str: |
| 722 | """Derive the crate-relative module path for a file under the groups root. |
| 723 | |
| 724 | `mod.rs` resolves to its directory name; other files to |
| 725 | `dirname.file_name` (snake_case). Path segments are joined with `::`. |
| 726 | """ |
| 727 | rel = file.relative_to(root) |
| 728 | parts = list(rel.parts) |
| 729 | if parts[-1] == "mod.rs": |
| 730 | parts = parts[:-1] |
| 731 | else: |
| 732 | parts[-1] = parts[-1].removesuffix(".rs") |
| 733 | return "crate::commands::groups::" + "::".join(parts) |
| 734 | |
| 735 | |
| 736 | def _first_param_type(fn_sig: str) -> str | None: |
| 737 | """Extract the first parameter's type from a `fn name(...)` signature text.""" |
| 738 | open_idx = fn_sig.find("(") |
| 739 | if open_idx < 0: |
| 740 | return None |
| 741 | close_idx = fn_sig.rfind(")") |
| 742 | if close_idx < open_idx: |
| 743 | return None |
| 744 | params = _split_top_level(fn_sig[open_idx + 1:close_idx], ",") |
| 745 | params = [p.strip() for p in params if p.strip()] |
| 746 | if not params: |
| 747 | return None |
| 748 | first = params[0] |
| 749 | if first == "self" or first.startswith("self:") or first.startswith("&self"): |
| 750 | return None |
| 751 | # `name: Type` or `name: Type` with generic default: take after the first top-level ':'. |
| 752 | depth = 0 |
| 753 | for idx, ch in enumerate(first): |
| 754 | if ch in "<([": |
| 755 | depth += 1 |
| 756 | elif ch in ">)]": |
| 757 | depth -= 1 |
| 758 | elif ch == ":" and depth == 0: |
| 759 | return first[idx + 1:].strip() |
| 760 | return None |
| 761 | |
| 762 | |
| 763 | # --------------------------------------------------------------------------- |
| 764 | # Retained host machinery (FEAT-042 tracking) |
| 765 | # --------------------------------------------------------------------------- |
| 766 | # |
| 767 | # The migration topology is immutable, so the dispatcher-only host helpers that |
| 768 | # intentionally keep `&mut App` after a group migrates are declared here — the |
| 769 | # gate's own enforcement home. Each entry maps a migrated group to selectors |
| 770 | # that must keep their concrete-App signature until FEAT-042 extracts them to a |
| 771 | # host-side module; a missing or refactored-away signature fails the gate, so |
| 772 | # the tracking cannot silently go stale. FEAT-022: the skills group retains the |
| 773 | # unified slash-command fallback and its activation helpers co-located with the |
| 774 | # portable handlers (D7). |
| 775 | RETAINED_HOST_MACHINERY: dict[str, list[dict]] = { |
| 776 | "skills": [ |
| 777 | {"kind": "free", "item": ["crate", "commands", "groups", "skills", "skills", "run_skill_by_name"]}, |
| 778 | {"kind": "free", "item": ["crate", "commands", "groups", "skills", "skills", "activate_skill_with_task"]}, |
| 779 | {"kind": "free", "item": ["crate", "commands", "groups", "skills", "skills", "activate_skill"]}, |
| 780 | ], |
| 781 | } |
| 782 | |
| 783 | |
| 784 | def _is_concrete_app_type(param_type: str | None) -> bool: |
| 785 | if param_type is None: |
| 786 | return False |
| 787 | # Match `&mut App` / `&mut crate::tui::app::App` with optional spaces |
| 788 | # around `mut`; no whitespace normalization so `\s*` can bind. |
| 789 | return bool(CONCRETE_APP_PARAM.search(param_type)) |
| 790 | |
| 791 | |
| 792 | def parse_rust_file(file: Path, root: Path) -> list[RustItem]: |
| 793 | """Structurally parse one Rust file for handler-relevant items. |
| 794 | |
| 795 | Handles `fn name(...) -> R { ... }` free functions at top level and |
| 796 | `impl Trait for Type { fn ... }` / `impl Type { fn ... }` blocks. The |
| 797 | parser tracks braces to skip bodies and resolves qualified paths from the |
| 798 | module layout. It is deliberately narrow: it looks for function items with |
| 799 | a first parameter typed `&mut App` (the concrete-App handler signature). |
| 800 | |
| 801 | `root` is the *groups* root: module paths are derived from the file's |
| 802 | position under `crates/tui/src/commands/groups/`, not the repo root. |
| 803 | """ |
| 804 | raw = file.read_text(encoding="utf-8") |
| 805 | code = _strip_comments_and_strings(raw) |
| 806 | # Module paths derive from the file's position under the real groups root; |
| 807 | # hermetic tests use a synthetic root, in which case the passed root is the |
| 808 | # module-path base. |
| 809 | try: |
| 810 | module_path = _module_path_from_file(file, GROUPS_ROOT) |
| 811 | except ValueError: |
| 812 | module_path = _module_path_from_file(file, root) |
| 813 | items: list[RustItem] = [] |
| 814 | lines = raw.splitlines() |
| 815 | |
| 816 | i = 0 |
| 817 | n = len(code) |
| 818 | # Walk top-level items: skip until we find `fn` or `impl` at depth 0. |
| 819 | while i < n: |
| 820 | # find next top-level keyword occurrence |
| 821 | while i < n and code[i].isspace(): |
| 822 | i += 1 |
| 823 | if i >= n: |
| 824 | break |
| 825 | if code.startswith("fn", i) and (i == 0 or not (code[i - 1].isalnum() or code[i - 1] == "_")): |
| 826 | # free function |
| 827 | sig_end = _find_fn_signature_end(code, i + 2) |
| 828 | if sig_end is None: |
| 829 | i += 2 |
| 830 | continue |
| 831 | sig_text = code[i:sig_end] |
| 832 | name_match = re.match(r"fn\s+([A-Za-z_][A-Za-z0-9_]*)", sig_text) |
| 833 | line_no = raw.count("\n", 0, code.find(sig_text[:20], 0)) + 1 if sig_text[:20] else 0 |
| 834 | if name_match: |
| 835 | name = name_match.group(1) |
| 836 | param_type = _first_param_type(sig_text) |
| 837 | items.append(RustItem( |
| 838 | kind="free", |
| 839 | name=name, |
| 840 | qual_path=f"{module_path}::{name}", |
| 841 | file=file, |
| 842 | line=line_no, |
| 843 | param_types=[param_type] if param_type else [], |
| 844 | is_concrete_app=_is_concrete_app_type(param_type), |
| 845 | )) |
| 846 | # advance past the signature and body |
| 847 | i = sig_end |
| 848 | # skip the block body if present (or the `;` for a declaration) |
| 849 | while i < n and code[i].isspace(): |
| 850 | i += 1 |
| 851 | if i < n and code[i] == "{": |
| 852 | depth = 0 |
| 853 | while i < n: |
| 854 | if code[i] == "{": |
| 855 | depth += 1 |
| 856 | elif code[i] == "}": |
| 857 | depth -= 1 |
| 858 | if depth == 0: |
| 859 | i += 1 |
| 860 | break |
| 861 | i += 1 |
| 862 | else: |
| 863 | i += 1 # consume the `;` (or advance past a non-body token) |
| 864 | continue |
| 865 | if code.startswith("impl", i) and (i == 0 or not (code[i - 1].isalnum() or code[i - 1] == "_")): |
| 866 | # impl block: header until '{' |
| 867 | brace_idx = code.find("{", i) |
| 868 | if brace_idx < 0: |
| 869 | i += 4 |
| 870 | continue |
| 871 | header = code[i + 4:brace_idx].strip() |
| 872 | # `impl Trait for Type` vs `impl Type` |
| 873 | trait_impl = " for " in header |
| 874 | self_type = header.split(" for ")[-1].strip() if trait_impl else header.strip() |
| 875 | # methods inside |
| 876 | depth = 1 |
| 877 | j = brace_idx + 1 |
| 878 | while j < n and depth > 0: |
| 879 | if code[j] == "{": |
| 880 | depth += 1 |
| 881 | elif code[j] == "}": |
| 882 | depth -= 1 |
| 883 | if depth == 0: |
| 884 | break |
| 885 | elif code[j].isspace(): |
| 886 | j += 1 |
| 887 | continue |
| 888 | elif code.startswith("fn", j) and not (code[j - 1].isalnum() or code[j - 1] == "_"): |
| 889 | fn_start = j |
| 890 | sig_end = _find_fn_signature_end(code, j + 2) |
| 891 | if sig_end is None: |
| 892 | j += 2 |
| 893 | continue |
| 894 | sig_text = code[fn_start:sig_end] |
| 895 | name_match = re.match(r"fn\s+([A-Za-z_][A-Za-z0-9_]*)", sig_text) |
| 896 | if name_match: |
| 897 | name = name_match.group(1) |
| 898 | param_type = _first_param_type(sig_text) |
| 899 | self_qual = _self_type_qual(self_type, module_path) |
| 900 | if trait_impl: |
| 901 | trait_path = header.split(" for ")[0].strip() |
| 902 | kind = "trait_impl" |
| 903 | qual = f"{self_qual}::{name} [{trait_path}]" |
| 904 | else: |
| 905 | kind = "inherent" |
| 906 | qual = f"{self_qual}::{name}" |
| 907 | line_no = raw.count("\n", 0, code.find(sig_text[:20], 0)) + 1 if sig_text[:20] else 0 |
| 908 | items.append(RustItem( |
| 909 | kind=kind, |
| 910 | name=name, |
| 911 | qual_path=qual, |
| 912 | file=file, |
| 913 | line=line_no, |
| 914 | param_types=[param_type] if param_type else [], |
| 915 | is_concrete_app=_is_concrete_app_type(param_type), |
| 916 | )) |
| 917 | j = sig_end |
| 918 | # skip the method body (or consume `;` for a declaration) |
| 919 | while j < n and code[j].isspace(): |
| 920 | j += 1 |
| 921 | if j < n and code[j] == "{": |
| 922 | body_depth = 0 |
| 923 | while j < n: |
| 924 | if code[j] == "{": |
| 925 | body_depth += 1 |
| 926 | elif code[j] == "}": |
| 927 | body_depth -= 1 |
| 928 | if body_depth == 0: |
| 929 | j += 1 |
| 930 | break |
| 931 | j += 1 |
| 932 | else: |
| 933 | j += 1 |
| 934 | continue |
| 935 | else: |
| 936 | j += 1 |
| 937 | continue |
| 938 | i = j + 1 if j < n else n |
| 939 | continue |
| 940 | i += 1 |
| 941 | return items |
| 942 | |
| 943 | |
| 944 | def _find_fn_signature_end(code: str, start: int) -> int | None: |
| 945 | """Find the index just after a `fn` signature (before `{` or `;`), |
| 946 | respecting nested generics and parens. The `->` arrow's `>` is not a |
| 947 | generic close delimiter.""" |
| 948 | depth = 0 |
| 949 | generic = 0 |
| 950 | i = start |
| 951 | n = len(code) |
| 952 | while i < n: |
| 953 | ch = code[i] |
| 954 | if ch in "([": |
| 955 | depth += 1 |
| 956 | elif ch in ")]": |
| 957 | depth -= 1 |
| 958 | elif ch == "<": |
| 959 | generic += 1 |
| 960 | elif ch == ">": |
| 961 | # `->` arrow: previous char is '-'; not a generic close. |
| 962 | if i > 0 and code[i - 1] == "-": |
| 963 | pass |
| 964 | elif generic > 0: |
| 965 | generic -= 1 |
| 966 | elif ch in "{;" and depth == 0 and generic == 0: |
| 967 | return i |
| 968 | i += 1 |
| 969 | return None |
| 970 | |
| 971 | |
| 972 | def _self_type_qual(self_type: str, module_path: str) -> str: |
| 973 | """Qualify a bare self type with the module path (e.g. `BranchCmd` -> |
| 974 | `crate::commands::groups::session::branch::BranchCmd`). Already-qualified |
| 975 | paths pass through.""" |
| 976 | cleaned = re.sub(r"\s+", "", self_type) |
| 977 | if cleaned.startswith("crate::") or cleaned.startswith("super::"): |
| 978 | return cleaned |
| 979 | # Strip generic args for qualification purposes (path part only). |
| 980 | base = re.split(r"[<({]", cleaned)[0] |
| 981 | return f"{module_path}::{base}" |
| 982 | |
| 983 | |
| 984 | def _selector_matches(selector: dict, item: RustItem) -> bool: |
| 985 | """Match one RustItem against a checked-in selector (shared by |
| 986 | `resolve_selector` and the retained-host source scan).""" |
| 987 | kind = selector["kind"] |
| 988 | if kind == "free": |
| 989 | target = "::".join(selector["item"]) |
| 990 | return item.kind == "free" and item.qual_path == target |
| 991 | if kind == "inherent": |
| 992 | self_qual = _selector_type_to_text(selector["self_type"]) |
| 993 | return item.kind == "inherent" and item.name == selector["method"] \ |
| 994 | and item.qual_path.startswith(f"{self_qual}::") |
| 995 | self_qual = _selector_type_to_text(selector["self_type"]) |
| 996 | trait_qual = _selector_type_to_text(selector["trait_path"]) |
| 997 | return item.kind == "trait_impl" and item.name == selector["method"] \ |
| 998 | and item.qual_path.startswith(f"{self_qual}::") \ |
| 999 | and f"[{trait_qual}]" in item.qual_path |
| 1000 | |
| 1001 | |
| 1002 | def resolve_selector(selector: dict, items: list[RustItem]) -> list[SourceScanViolation]: |
| 1003 | """Resolve one checked-in handler selector against parsed items. |
| 1004 | |
| 1005 | Returns violations for missing, ambiguous, or non-concrete-App targets. |
| 1006 | """ |
| 1007 | violations: list[SourceScanViolation] = [] |
| 1008 | kind = selector["kind"] |
| 1009 | if kind == "free": |
| 1010 | target = "::".join(selector["item"]) |
| 1011 | matches = [it for it in items if it.kind == "free" and it.qual_path == target] |
| 1012 | elif kind == "inherent": |
| 1013 | self_type = selector["self_type"] |
| 1014 | method = selector["method"] |
| 1015 | self_qual = _selector_type_to_text(self_type) |
| 1016 | matches = [ |
| 1017 | it for it in items |
| 1018 | if it.kind == "inherent" and it.name == method and it.qual_path.startswith(f"{self_qual}::") |
| 1019 | ] |
| 1020 | else: # trait_impl |
| 1021 | self_type = selector["self_type"] |
| 1022 | trait_path = selector["trait_path"] |
| 1023 | method = selector["method"] |
| 1024 | self_qual = _selector_type_to_text(self_type) |
| 1025 | trait_qual = _selector_type_to_text(trait_path) |
| 1026 | matches = [ |
| 1027 | it for it in items |
| 1028 | if it.kind == "trait_impl" and it.name == method |
| 1029 | and it.qual_path.startswith(f"{self_qual}::") and f"[{trait_qual}]" in it.qual_path |
| 1030 | ] |
| 1031 | if not matches: |
| 1032 | violations.append(SourceScanViolation( |
| 1033 | "selector-resolve", str(selector), |
| 1034 | "selector resolved to no source item; handler may have moved, been renamed, or the path is stale", |
| 1035 | )) |
| 1036 | elif len(matches) > 1: |
| 1037 | violations.append(SourceScanViolation( |
| 1038 | "selector-resolve", str(selector), |
| 1039 | f"selector resolved to {len(matches)} items (ambiguous): " + "; ".join(m.qual_path for m in matches), |
| 1040 | )) |
| 1041 | elif not matches[0].is_concrete_app: |
| 1042 | violations.append(SourceScanViolation( |
| 1043 | "selector-resolve", str(selector), |
| 1044 | f"resolved handler {matches[0].qual_path} no longer has a concrete-App signature", |
| 1045 | )) |
| 1046 | return violations |
| 1047 | |
| 1048 | |
| 1049 | def _selector_type_to_text(node) -> str: |
| 1050 | """Render a type-algebra node back to Rust text for matching.""" |
| 1051 | if not isinstance(node, dict): |
| 1052 | return str(node) |
| 1053 | tag = node["tag"] |
| 1054 | if tag == "path": |
| 1055 | segments = [] |
| 1056 | for seg in node.get("segments", []): |
| 1057 | name = seg["name"] if isinstance(seg, dict) else str(seg) |
| 1058 | segments.append(name) |
| 1059 | prefix = "" if node.get("absolute") else "" |
| 1060 | return prefix + "::".join(segments) |
| 1061 | if tag == "primitive": |
| 1062 | return node["name"] |
| 1063 | if tag == "never": |
| 1064 | return "!" |
| 1065 | if tag == "tuple": |
| 1066 | return "(" + ",".join(_selector_type_to_text(e) for e in node.get("elems", [])) + ")" |
| 1067 | if tag == "reference": |
| 1068 | return "&" + ("mut " if node.get("mut") else "") + _selector_type_to_text(node.get("inner")) |
| 1069 | if tag == "pointer": |
| 1070 | return "*" + ("mut " if node.get("mut") else "const ") + _selector_type_to_text(node.get("inner")) |
| 1071 | if tag == "slice": |
| 1072 | return "[" + _selector_type_to_text(node.get("inner")) + "]" |
| 1073 | if tag == "array": |
| 1074 | return "[" + _selector_type_to_text(node.get("inner")) + "; " + _selector_type_to_text(node.get("len")) + "]" |
| 1075 | return "" |
| 1076 | |
| 1077 | |
| 1078 | def scan_leaf_handlers(leaf_scope: list[str], root: Path) -> tuple[list[RustItem], list[SourceScanViolation]]: |
| 1079 | """Scan one leaf's source scope for concrete-App handler items. |
| 1080 | |
| 1081 | `root` is the repo root (scope paths are repo-relative); module paths are |
| 1082 | derived against the groups root internally. |
| 1083 | """ |
| 1084 | items: list[RustItem] = [] |
| 1085 | violations: list[SourceScanViolation] = [] |
| 1086 | for rel in leaf_scope: |
| 1087 | path = root / rel |
| 1088 | if not path.is_file(): |
| 1089 | violations.append(SourceScanViolation( |
| 1090 | "source-scan", rel, "scope file missing from the source tree", |
| 1091 | )) |
| 1092 | continue |
| 1093 | try: |
| 1094 | items.extend(parse_rust_file(path, root)) |
| 1095 | except Exception as exc: # pragma: no cover - defensive |
| 1096 | violations.append(SourceScanViolation( |
| 1097 | "source-scan", rel, f"failed to parse: {exc}", |
| 1098 | )) |
| 1099 | return items, violations |
| 1100 | |
| 1101 | |
| 1102 | def check_source_frontier(topology: dict, frontier: list[str], root: Path = REPO_ROOT) -> list[SourceScanViolation]: |
| 1103 | """Bidirectional scan: the frontier must exactly equal the leaves whose |
| 1104 | scopes still contain concrete-App handlers. |
| 1105 | |
| 1106 | The frontier may name a whole group (all its files pending) or a group's |
| 1107 | declared slices (after a documented split). For every group: |
| 1108 | |
| 1109 | - group pending: all concrete-App handlers in the group scope are covered. |
| 1110 | - group split: the frontier must name ALL its slices, and every handler |
| 1111 | in the group scope must fall inside a pending slice's scope. |
| 1112 | - otherwise: any concrete-App handler in the group is a stale-removal. |
| 1113 | """ |
| 1114 | violations: list[SourceScanViolation] = [] |
| 1115 | frontier_set = set(frontier) |
| 1116 | |
| 1117 | for group_name, node in topology.items(): |
| 1118 | group_items, scan_violations = scan_leaf_handlers(node.get("scope", []), root) |
| 1119 | violations.extend(scan_violations) |
| 1120 | handlers = [it for it in group_items if it.is_concrete_app] |
| 1121 | |
| 1122 | slices = node.get("slices", []) |
| 1123 | if group_name in frontier_set: |
| 1124 | # Whole group pending: every handler is covered; a pending group |
| 1125 | # with no concrete-App handler is a stale entry. |
| 1126 | if not handlers: |
| 1127 | violations.append(SourceScanViolation( |
| 1128 | "stale-entry", group_name, |
| 1129 | "frontier group is pending but its scope has no concrete-App handler", |
| 1130 | )) |
| 1131 | for selector in node.get("handlers", []): |
| 1132 | violations.extend(resolve_selector(selector, group_items)) |
| 1133 | continue |
| 1134 | |
| 1135 | # Validate retained host machinery declarations first so the tracking |
| 1136 | # stays fail-closed even when the group has no other concrete-App |
| 1137 | # handlers (e.g. every retained helper lost its signature at once). |
| 1138 | retained_names: set[str] = set() |
| 1139 | for selector in RETAINED_HOST_MACHINERY.get(group_name, []): |
| 1140 | matches = [it for it in group_items if _selector_matches(selector, it)] |
| 1141 | if not matches: |
| 1142 | violations.append(SourceScanViolation( |
| 1143 | "retained-host", json.dumps(selector, sort_keys=True), |
| 1144 | f"retained host machinery selector resolves to no source item in {group_name!r}", |
| 1145 | )) |
| 1146 | for match in matches: |
| 1147 | if not match.is_concrete_app: |
| 1148 | violations.append(SourceScanViolation( |
| 1149 | "retained-host", match.qual_path, |
| 1150 | "retained host machinery must keep its concrete-App signature until FEAT-042 extracts it", |
| 1151 | )) |
| 1152 | retained_names.add(match.qual_path) |
| 1153 | |
| 1154 | if not handlers: |
| 1155 | continue |
| 1156 | |
| 1157 | if slices: |
| 1158 | slice_names = {s["name"] for s in slices} |
| 1159 | slice_pending = slice_names & frontier_set |
| 1160 | if slice_pending == slice_names: |
| 1161 | # Documented split: verify each slice scope holds its handlers. |
| 1162 | for slice_node in slices: |
| 1163 | slice_items, slice_violations = scan_leaf_handlers( |
| 1164 | slice_node.get("scope", []), root |
| 1165 | ) |
| 1166 | violations.extend(slice_violations) |
| 1167 | slice_handlers = [it for it in slice_items if it.is_concrete_app] |
| 1168 | if not slice_handlers: |
| 1169 | violations.append(SourceScanViolation( |
| 1170 | "stale-entry", slice_node["name"], |
| 1171 | "frontier slice is pending but its scope has no concrete-App handler", |
| 1172 | )) |
| 1173 | for selector in slice_node.get("handlers", []): |
| 1174 | violations.extend(resolve_selector(selector, slice_items)) |
| 1175 | continue |
| 1176 | if slice_pending: |
| 1177 | # Partial split: some slices pending, some not. |
| 1178 | missing = sorted(slice_names - slice_pending) |
| 1179 | violations.append(SourceScanViolation( |
| 1180 | "partial-split", group_name, |
| 1181 | f"group is neither wholly pending nor fully split; pending slices " |
| 1182 | f"{sorted(slice_pending)} but missing {missing}", |
| 1183 | )) |
| 1184 | continue |
| 1185 | |
| 1186 | # Not pending and not split: every remaining handler is a stale removal, |
| 1187 | # except the retained host machinery resolved above. |
| 1188 | stale = [h for h in handlers if h.qual_path not in retained_names] |
| 1189 | for handler in stale[:5]: |
| 1190 | violations.append(SourceScanViolation( |
| 1191 | "stale-removal", handler.qual_path, |
| 1192 | f"handler still uses concrete App but group {group_name!r} is not pending", |
| 1193 | )) |
| 1194 | if len(stale) > 5: |
| 1195 | violations.append(SourceScanViolation( |
| 1196 | "stale-removal", group_name, |
| 1197 | f"... and {len(stale) - 5} more concrete-App handlers in this group", |
| 1198 | )) |
| 1199 | |
| 1200 | return violations |
| 1201 | |
| 1202 | |
| 1203 | def _leaf_node(topology: dict, leaf_name: str) -> dict | None: |
| 1204 | if leaf_name in topology: |
| 1205 | return topology[leaf_name] |
| 1206 | for root_name, node in topology.items(): |
| 1207 | for slice_node in node.get("slices", []): |
| 1208 | if slice_node["name"] == leaf_name: |
| 1209 | return slice_node |
| 1210 | return None |
| 1211 | |
| 1212 | |
| 1213 | def main(argv: list[str] | None = None) -> int: |
| 1214 | parser = argparse.ArgumentParser(description=__doc__) |
| 1215 | parser.add_argument( |
| 1216 | "--baseline-ref", |
| 1217 | help="Git revision whose topology/frontier must transition monotonically to the current one", |
| 1218 | ) |
| 1219 | args = parser.parse_args(argv) |
| 1220 | |
| 1221 | try: |
| 1222 | doc = load_topology() |
| 1223 | pending = load_pending_groups() |
| 1224 | baseline_ref = args.baseline_ref or detect_local_baseline_ref() |
| 1225 | previous = load_topology_at_ref(baseline_ref) if baseline_ref else None |
| 1226 | except (OSError, ValueError, json.JSONDecodeError) as error: |
| 1227 | print(f"[command-migration-manifest] FAIL: {error}", file=sys.stderr) |
| 1228 | return 1 |
| 1229 | |
| 1230 | violations = validate_topology_document(doc) |
| 1231 | violations.extend(validate_pending_projection(doc, pending)) |
| 1232 | violations.extend(validate_baseline_transition(doc, previous)) |
| 1233 | if violations: |
| 1234 | print("[command-migration-manifest] FAIL", file=sys.stderr) |
| 1235 | for violation in violations: |
| 1236 | print(f" {violation}", file=sys.stderr) |
| 1237 | return 1 |
| 1238 | source_violations = check_source_frontier(doc["topology"], doc["frontier"]) |
| 1239 | if source_violations: |
| 1240 | print("[command-migration-manifest] FAIL (source scan)", file=sys.stderr) |
| 1241 | for violation in source_violations: |
| 1242 | print(f" {violation}", file=sys.stderr) |
| 1243 | return 1 |
| 1244 | frontier = doc["frontier"] |
| 1245 | transition = f"; baseline {baseline_ref}" if baseline_ref else "; initialization" |
| 1246 | print( |
| 1247 | f"[command-migration-manifest] PASS: schema v{SUPPORTED_SCHEMA_VERSION}; " |
| 1248 | f"frontier [{', '.join(frontier)}] matches TUI projection and source{transition}" |
| 1249 | ) |
| 1250 | return 0 |
| 1251 | |
| 1252 | |
| 1253 | if __name__ == "__main__": |
| 1254 | sys.exit(main()) |
| 1255 |