返回 CodeWhale
check-command-migration-manifest.py
根目录 / scripts / check-command-migration-manifest.py
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
1255 lines PYTHON