返回 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 group_source_scope(group_name: str, node: dict, root: Path) -> list[str]:
1103 """Preserve the immutable historical topology and scan every debug source.
1104
1105 Recursive discovery includes receipts, module roots, helpers, tests and new
1106 nested files even when the historical manifest never listed them. Retaining
1107 declared paths also preserves missing-file diagnostics; no scope is removed.
1108 """
1109 scope = set(node.get("scope", []))
1110 if group_name == "debug":
1111 directory = root / "crates/tui/src/commands/groups/debug"
1112 scope.update(path.relative_to(root).as_posix() for path in directory.rglob("*.rs"))
1113 return sorted(scope)
1114
1115
1116 def check_source_frontier(topology: dict, frontier: list[str], root: Path = REPO_ROOT) -> list[SourceScanViolation]:
1117 """Bidirectional scan: the frontier must exactly equal the leaves whose
1118 scopes still contain concrete-App handlers.
1119
1120 The frontier may name a whole group (all its files pending) or a group's
1121 declared slices (after a documented split). For every group:
1122
1123 - group pending: all concrete-App handlers in the group scope are covered.
1124 - group split: the frontier must name ALL its slices, and every handler
1125 in the group scope must fall inside a pending slice's scope.
1126 - otherwise: any concrete-App handler in the group is a stale-removal.
1127 """
1128 violations: list[SourceScanViolation] = []
1129 frontier_set = set(frontier)
1130
1131 for group_name, node in topology.items():
1132 group_items, scan_violations = scan_leaf_handlers(
1133 group_source_scope(group_name, node, root), root
1134 )
1135 violations.extend(scan_violations)
1136 handlers = [it for it in group_items if it.is_concrete_app]
1137
1138 slices = node.get("slices", [])
1139 if group_name in frontier_set:
1140 # Whole group pending: every handler is covered; a pending group
1141 # with no concrete-App handler is a stale entry.
1142 if not handlers:
1143 violations.append(SourceScanViolation(
1144 "stale-entry", group_name,
1145 "frontier group is pending but its scope has no concrete-App handler",
1146 ))
1147 for selector in node.get("handlers", []):
1148 violations.extend(resolve_selector(selector, group_items))
1149 continue
1150
1151 # Validate retained host machinery declarations first so the tracking
1152 # stays fail-closed even when the group has no other concrete-App
1153 # handlers (e.g. every retained helper lost its signature at once).
1154 retained_names: set[str] = set()
1155 for selector in RETAINED_HOST_MACHINERY.get(group_name, []):
1156 matches = [it for it in group_items if _selector_matches(selector, it)]
1157 if not matches:
1158 violations.append(SourceScanViolation(
1159 "retained-host", json.dumps(selector, sort_keys=True),
1160 f"retained host machinery selector resolves to no source item in {group_name!r}",
1161 ))
1162 for match in matches:
1163 if not match.is_concrete_app:
1164 violations.append(SourceScanViolation(
1165 "retained-host", match.qual_path,
1166 "retained host machinery must keep its concrete-App signature until FEAT-042 extracts it",
1167 ))
1168 retained_names.add(match.qual_path)
1169
1170 if not handlers:
1171 continue
1172
1173 if slices:
1174 slice_names = {s["name"] for s in slices}
1175 slice_pending = slice_names & frontier_set
1176 if slice_pending == slice_names:
1177 # Documented split: verify each slice scope holds its handlers.
1178 for slice_node in slices:
1179 slice_items, slice_violations = scan_leaf_handlers(
1180 slice_node.get("scope", []), root
1181 )
1182 violations.extend(slice_violations)
1183 slice_handlers = [it for it in slice_items if it.is_concrete_app]
1184 if not slice_handlers:
1185 violations.append(SourceScanViolation(
1186 "stale-entry", slice_node["name"],
1187 "frontier slice is pending but its scope has no concrete-App handler",
1188 ))
1189 for selector in slice_node.get("handlers", []):
1190 violations.extend(resolve_selector(selector, slice_items))
1191 continue
1192 if slice_pending:
1193 # Partial split: some slices pending, some not.
1194 missing = sorted(slice_names - slice_pending)
1195 violations.append(SourceScanViolation(
1196 "partial-split", group_name,
1197 f"group is neither wholly pending nor fully split; pending slices "
1198 f"{sorted(slice_pending)} but missing {missing}",
1199 ))
1200 continue
1201
1202 # Not pending and not split: every remaining handler is a stale removal,
1203 # except the retained host machinery resolved above.
1204 stale = [h for h in handlers if h.qual_path not in retained_names]
1205 for handler in stale[:5]:
1206 violations.append(SourceScanViolation(
1207 "stale-removal", handler.qual_path,
1208 f"handler still uses concrete App but group {group_name!r} is not pending",
1209 ))
1210 if len(stale) > 5:
1211 violations.append(SourceScanViolation(
1212 "stale-removal", group_name,
1213 f"... and {len(stale) - 5} more concrete-App handlers in this group",
1214 ))
1215
1216 return violations
1217
1218
1219 def _leaf_node(topology: dict, leaf_name: str) -> dict | None:
1220 if leaf_name in topology:
1221 return topology[leaf_name]
1222 for root_name, node in topology.items():
1223 for slice_node in node.get("slices", []):
1224 if slice_node["name"] == leaf_name:
1225 return slice_node
1226 return None
1227
1228
1229 def main(argv: list[str] | None = None) -> int:
1230 parser = argparse.ArgumentParser(description=__doc__)
1231 parser.add_argument(
1232 "--baseline-ref",
1233 help="Git revision whose topology/frontier must transition monotonically to the current one",
1234 )
1235 args = parser.parse_args(argv)
1236
1237 try:
1238 doc = load_topology()
1239 pending = load_pending_groups()
1240 baseline_ref = args.baseline_ref or detect_local_baseline_ref()
1241 previous = load_topology_at_ref(baseline_ref) if baseline_ref else None
1242 except (OSError, ValueError, json.JSONDecodeError) as error:
1243 print(f"[command-migration-manifest] FAIL: {error}", file=sys.stderr)
1244 return 1
1245
1246 violations = validate_topology_document(doc)
1247 violations.extend(validate_pending_projection(doc, pending))
1248 violations.extend(validate_baseline_transition(doc, previous))
1249 if violations:
1250 print("[command-migration-manifest] FAIL", file=sys.stderr)
1251 for violation in violations:
1252 print(f" {violation}", file=sys.stderr)
1253 return 1
1254 source_violations = check_source_frontier(doc["topology"], doc["frontier"])
1255 if source_violations:
1256 print("[command-migration-manifest] FAIL (source scan)", file=sys.stderr)
1257 for violation in source_violations:
1258 print(f" {violation}", file=sys.stderr)
1259 return 1
1260 frontier = doc["frontier"]
1261 transition = f"; baseline {baseline_ref}" if baseline_ref else "; initialization"
1262 print(
1263 f"[command-migration-manifest] PASS: schema v{SUPPORTED_SCHEMA_VERSION}; "
1264 f"frontier [{', '.join(frontier)}] matches TUI projection and source{transition}"
1265 )
1266 return 0
1267
1268
1269 if __name__ == "__main__":
1270 sys.exit(main())
1271
1271 lines PYTHON