Files
DeepSeek-TUI/scripts/check-command-migration-manifest.py
Paulo Aboim Pinto e159f577c7 fix(FEAT-015): close command extraction boundary gaps
Delegate capability mutations through authoritative App operations, bridge portable RegisterCommand metadata into the TUI registry, and enforce monotonic migration-frontier transitions against the baseline revision.
2026-08-18 13:39:59 +02:00

1186 lines
50 KiB
Python

#!/usr/bin/env python3
"""Deterministic command migration manifest gate for EPIC-006 (FEAT-015).
Enforces the staged-migration contract:
1. The checked-in migration topology document (`scripts/command-migration-topology.json`)
is versioned and fail-closed: `schema_version` must be 1; unknown versions,
tags, fields, selector kinds, type/trait node tags, and const atoms are rejected.
2. The pending frontier is a valid topology frontier: sorted, unique, containing
only known leaves, and reachable from the roots by parent-to-all-children
replacements (documented splits) or leaf removals (migrations). Arbitrary
additions, partial splits, and stale entries fail closed.
3. The frontier exactly equals the set of groups/slices whose handlers still
contain concrete-`App` signatures (`&mut App` / `&mut crate::tui::app::App`)
within `crates/tui/src/commands/groups/` (bidirectional source scan; the
AST/selector resolution part lands in Phase 3, `scan_and_check`).
The guard is hermetic for its data parts: it reads the topology artifact and
optionally the source tree; it never starts the TUI and makes no network calls.
Usage:
python3 scripts/check-command-migration-manifest.py # enforce
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
TOPOLOGY_PATH = REPO_ROOT / "scripts" / "command-migration-topology.json"
CONTRACT_PATH = REPO_ROOT / "crates" / "tui" / "src" / "commands" / "contract.rs"
TOPOLOGY_REPO_PATH = "scripts/command-migration-topology.json"
SUPPORTED_SCHEMA_VERSION = 1
# Closed set of Rust integer suffixes accepted by selector const atoms.
INTEGER_SUFFIXES = {
"i8", "u8", "i16", "u16", "i32", "u32", "i64", "u64", "i128", "u128",
"isize", "usize",
}
# Closed set of primitive type names accepted by the type algebra (v1).
PRIMITIVE_TYPES = {
"u8", "u16", "u32", "u64", "u128", "usize",
"i8", "i16", "i32", "i64", "i128", "isize",
"f32", "f64", "bool", "char", "str",
}
VALID_SELECTOR_KINDS = {"free", "inherent", "trait_impl"}
VALID_TYPE_TAGS = {
"path", "qualified", "tuple", "reference", "pointer", "slice", "array",
"primitive", "never",
}
VALID_GENERIC_ARG_TAGS = {"lifetime", "type", "const"}
VALID_CONST_TAGS = {"bool", "int", "char", "path"}
class ManifestViolation:
"""One deterministic manifest violation with an actionable diagnostic."""
def __init__(self, category: str, location: str, detail: str) -> None:
self.category = category
self.location = location
self.detail = detail
def __str__(self) -> str:
return f"{self.category}: {self.location}: {self.detail}"
# ---------------------------------------------------------------------------
# Const atom validation (Deep-Dive: closed four-tag records, canonical decimal
# magnitudes, byte bounds, fail-closed unknown forms).
# ---------------------------------------------------------------------------
def _canonical_magnitude(value: str, location: str) -> list[ManifestViolation]:
"""Validate `0|[1-9][0-9]*` and return violations (never leading zeros)."""
if value == "0":
return []
if not value.isdigit() or value[0] == "0":
return [ManifestViolation(
"const-atom", location,
f"integer magnitude must be canonical unsigned decimal without leading zeros "
f"(0|[1-9][0-9]*), got {value!r}",
)]
return []
def validate_const_atom(atom, location: str) -> list[ManifestViolation]:
"""Validate one const atom record; reject unknown tags/fields/forms."""
if not isinstance(atom, dict) or "tag" not in atom:
return [ManifestViolation("const-atom", location, "const atom must be an object with a tag")]
tag = atom["tag"]
if tag not in VALID_CONST_TAGS:
return [ManifestViolation(
"const-atom", location, f"unknown const atom tag {tag!r}; expected one of {sorted(VALID_CONST_TAGS)}",
)]
violations: list[ManifestViolation] = []
if tag == "bool":
if set(atom) != {"tag", "value"} or not isinstance(atom.get("value"), bool):
violations.append(ManifestViolation(
"const-atom", location, "bool const atom must be {{tag: bool, value: <bool>}}",
))
elif tag == "int":
allowed = {"tag", "negative", "magnitude", "suffix"}
if set(atom) != allowed:
violations.append(ManifestViolation(
"const-atom", location,
f"int const atom must have exactly {{tag, negative, magnitude, suffix}}, got {sorted(atom)}",
))
return violations
if not isinstance(atom.get("negative"), bool):
violations.append(ManifestViolation("const-atom", location, "int.negative must be a bool"))
magnitude = atom.get("magnitude")
if not isinstance(magnitude, str):
violations.append(ManifestViolation("const-atom", location, "int.magnitude must be a string"))
else:
violations.extend(_canonical_magnitude(magnitude, location))
suffix = atom.get("suffix")
if suffix is not None and suffix not in INTEGER_SUFFIXES:
violations.append(ManifestViolation(
"const-atom", location, f"int.suffix must be null or a Rust integer suffix, got {suffix!r}",
))
# Negative zero is noncanonical: normalize to nonnegative.
if atom.get("negative") and magnitude == "0":
violations.append(ManifestViolation(
"const-atom", location, "negative zero must be normalized to nonnegative",
))
# Decoded byte values (u8 suffix) must be within 0-255 inclusive.
if suffix == "u8" and magnitude is not None and magnitude.isdigit():
if int(magnitude) > 255:
violations.append(ManifestViolation(
"const-atom", location,
f"decoded byte value {magnitude} exceeds the inclusive u8 range 0-255",
))
elif tag == "char":
if set(atom) != {"tag", "scalar"} or not isinstance(atom.get("scalar"), str):
violations.append(ManifestViolation(
"const-atom", location, "char const atom must be {{tag: char, scalar: <single Unicode scalar>}}",
))
else:
scalar = atom["scalar"]
if len(scalar) != 1:
violations.append(ManifestViolation(
"const-atom", location, "char.scalar must be exactly one Unicode scalar",
))
elif tag == "path":
if set(atom) != {"tag", "absolute", "segments"}:
violations.append(ManifestViolation(
"const-atom", location,
"path const atom must be {{tag: path, absolute: <bool>, segments: [...]}}",
))
return violations
if not isinstance(atom.get("absolute"), bool):
violations.append(ManifestViolation("const-atom", location, "path.absolute must be a bool"))
segments = atom.get("segments")
if not isinstance(segments, list) or not segments:
violations.append(ManifestViolation("const-atom", location, "path.segments must be a nonempty array"))
elif not all(isinstance(s, str) and s for s in segments):
violations.append(ManifestViolation("const-atom", location, "path.segments must be nonempty strings"))
return violations
# ---------------------------------------------------------------------------
# Type algebra validation (Deep-Dive: closed v1 algebra, fail-closed).
# ---------------------------------------------------------------------------
def validate_type_node(node, location: str) -> list[ManifestViolation]:
"""Validate a recursive type/trait node; reject unsupported syntax."""
if not isinstance(node, dict) or "tag" not in node:
return [ManifestViolation("type-algebra", location, "type node must be an object with a tag")]
tag = node["tag"]
if tag not in VALID_TYPE_TAGS:
return [ManifestViolation(
"type-algebra", location,
f"unknown type node tag {tag!r}; expected one of {sorted(VALID_TYPE_TAGS)}",
)]
violations: list[ManifestViolation] = []
if tag == "path":
allowed = {"tag", "absolute", "segments"}
if set(node) != allowed:
return [ManifestViolation(
"type-algebra", location, f"path node must have exactly {{tag, absolute, segments}}, got {sorted(node)}",
)]
if not isinstance(node.get("absolute"), bool):
violations.append(ManifestViolation("type-algebra", location, "path.absolute must be a bool"))
segments = node.get("segments")
if not isinstance(segments, list) or not segments:
return violations + [ManifestViolation("type-algebra", location, "path.segments must be a nonempty array")]
for i, seg in enumerate(segments):
seg_loc = f"{location}.segments[{i}]"
if not isinstance(seg, dict) or "name" not in seg or not isinstance(seg.get("name"), str) or not seg["name"]:
violations.append(ManifestViolation("type-algebra", seg_loc, "segment must be {{name: <string>, args?: [...]}}"))
continue
if "args" in seg:
if not isinstance(seg["args"], list):
violations.append(ManifestViolation("type-algebra", seg_loc, "segment.args must be an array"))
continue
for j, arg in enumerate(seg["args"]):
violations.extend(validate_generic_arg(arg, f"{seg_loc}.args[{j}]"))
elif tag == "qualified":
allowed = {"tag", "self", "assoc"}
if set(node) != allowed:
return [ManifestViolation("type-algebra", location, "qualified node must have exactly {tag, self, assoc}")]
violations.extend(validate_type_node(node.get("self"), f"{location}.self"))
if not isinstance(node.get("assoc"), str) or not node["assoc"]:
violations.append(ManifestViolation("type-algebra", location, "qualified.assoc must be a nonempty string"))
elif tag == "tuple":
if set(node) != {"tag", "elems"} or not isinstance(node.get("elems"), list):
return [ManifestViolation("type-algebra", location, "tuple node must be {{tag: tuple, elems: [...]}}")]
for i, elem in enumerate(node["elems"]):
violations.extend(validate_type_node(elem, f"{location}.elems[{i}]"))
elif tag in ("reference", "pointer"):
allowed = {"tag", "mut", "inner"}
if set(node) != allowed or not isinstance(node.get("mut"), bool):
return [ManifestViolation("type-algebra", location, f"{tag} node must be {{tag, mut, inner}}")]
violations.extend(validate_type_node(node.get("inner"), f"{location}.inner"))
elif tag == "slice":
if set(node) != {"tag", "inner"}:
return [ManifestViolation("type-algebra", location, "slice node must be {{tag: slice, inner}}")]
violations.extend(validate_type_node(node.get("inner"), f"{location}.inner"))
elif tag == "array":
allowed = {"tag", "inner", "len"}
if set(node) != allowed:
return [ManifestViolation("type-algebra", location, "array node must be {{tag: array, inner, len}}")]
violations.extend(validate_type_node(node.get("inner"), f"{location}.inner"))
violations.extend(validate_const_atom(node.get("len"), f"{location}.len"))
elif tag == "primitive":
if set(node) != {"tag", "name"} or node.get("name") not in PRIMITIVE_TYPES:
return [ManifestViolation(
"type-algebra", location,
f"primitive node must be {{tag: primitive, name}} with name in {sorted(PRIMITIVE_TYPES)}",
)]
elif tag == "never":
if set(node) != {"tag"}:
return [ManifestViolation("type-algebra", location, "never node must be {{tag: never}}")]
return violations
def validate_generic_arg(arg, location: str) -> list[ManifestViolation]:
"""Validate one path-segment generic argument (type/lifetime/const)."""
if not isinstance(arg, dict) or "tag" not in arg:
return [ManifestViolation("type-algebra", location, "generic argument must be an object with a tag")]
tag = arg["tag"]
if tag not in VALID_GENERIC_ARG_TAGS:
return [ManifestViolation(
"type-algebra", location,
f"unknown generic argument tag {tag!r}; expected one of {sorted(VALID_GENERIC_ARG_TAGS)}",
)]
if tag == "lifetime":
if set(arg) != {"tag", "name"} or not isinstance(arg.get("name"), str) or not arg["name"]:
return [ManifestViolation("type-algebra", location, "lifetime argument must be {{tag: lifetime, name}}")]
elif tag == "type":
return validate_type_node(arg.get("node"), f"{location}.node")
else: # const
if set(arg) != {"tag", "atom"}:
return [ManifestViolation("type-algebra", location, "const argument must be {{tag: const, atom}}")]
return validate_const_atom(arg.get("atom"), f"{location}.atom")
return []
# ---------------------------------------------------------------------------
# Selector validation (Deep-Dive: tagged structural records).
# ---------------------------------------------------------------------------
def validate_selector(selector, location: str) -> list[ManifestViolation]:
"""Validate one handler selector record (free / inherent / trait_impl)."""
if not isinstance(selector, dict) or "kind" not in selector:
return [ManifestViolation("selector", location, "selector must be an object with a kind")]
kind = selector["kind"]
if kind not in VALID_SELECTOR_KINDS:
return [ManifestViolation(
"selector", location,
f"unknown selector kind {kind!r}; expected one of {sorted(VALID_SELECTOR_KINDS)}",
)]
violations: list[ManifestViolation] = []
if kind == "free":
allowed = {"kind", "item"}
if set(selector) != allowed:
return [ManifestViolation("selector", location, f"free selector must have exactly {{kind, item}}, got {sorted(selector)}")]
item = selector.get("item")
if not isinstance(item, list) or len(item) < 2:
return [ManifestViolation("selector", location, "free.item must be a module path array ending with the function name")]
if not all(isinstance(s, str) and s for s in item):
return [ManifestViolation("selector", location, "free.item entries must be nonempty strings")]
elif kind == "inherent":
allowed = {"kind", "self_type", "method"}
if "method" not in selector:
return [ManifestViolation("selector", location, "inherent.method must be a nonempty string")]
if set(selector) != allowed:
return [ManifestViolation("selector", location, f"inherent selector must have exactly {{kind, self_type, method}}, got {sorted(selector)}")]
violations.extend(validate_type_node(selector.get("self_type"), f"{location}.self_type"))
if not isinstance(selector.get("method"), str) or not selector["method"]:
violations.append(ManifestViolation("selector", location, "inherent.method must be a nonempty string"))
else: # trait_impl
allowed = {"kind", "self_type", "trait_path", "method"}
if set(selector) != allowed:
return [ManifestViolation("selector", location, f"trait_impl selector must have exactly {{kind, self_type, trait_path, method}}, got {sorted(selector)}")]
violations.extend(validate_type_node(selector.get("self_type"), f"{location}.self_type"))
violations.extend(validate_type_node(selector.get("trait_path"), f"{location}.trait_path"))
if not isinstance(selector.get("method"), str) or not selector["method"]:
violations.append(ManifestViolation("selector", location, "trait_impl.method must be a nonempty string"))
return violations
# ---------------------------------------------------------------------------
# Topology / frontier validation.
# ---------------------------------------------------------------------------
def all_leaves(topology: dict) -> dict[str, str]:
"""Map leaf name -> owning root for every group and predeclared slice."""
leaves: dict[str, str] = {}
for root, node in topology.items():
leaves[root] = root
for slice_node in node.get("slices", []):
leaves[slice_node["name"]] = root
return leaves
def validate_frontier(topology: dict, frontier: list[str]) -> list[ManifestViolation]:
"""Frontier must be sorted, unique, and reference only known leaves."""
violations: list[ManifestViolation] = []
if not isinstance(frontier, list):
return [ManifestViolation("frontier", "frontier", "frontier must be an array")]
if frontier != sorted(frontier):
violations.append(ManifestViolation("frontier", "frontier", "frontier must be sorted"))
if len(frontier) != len(set(frontier)):
violations.append(ManifestViolation("frontier", "frontier", "frontier must contain no duplicates"))
leaves = all_leaves(topology)
for entry in frontier:
if entry not in leaves:
violations.append(ManifestViolation(
"frontier", entry, f"frontier entry {entry!r} is not a declared topology leaf",
))
return violations
def is_documented_split(topology: dict, old: set[str], new: set[str]) -> bool:
"""A parent was replaced by ALL of its declared children (1->N, no growth).
Exactly one entry is removed; it is a group with declared slices; the added
entries are exactly those declared children; nothing else changed.
"""
removed = old - new
added = new - old
if len(removed) != 1:
return False
parent = next(iter(removed))
for root, node in topology.items():
children = {s["name"] for s in node.get("slices", [])}
if parent == root and children:
return children == added and len(added) == len(children) and added == new - old
return False
def is_valid_frontier_transition(topology: dict, old: list[str], new: list[str]) -> list[ManifestViolation]:
"""Permit shrink (removal of migrated leaves) or documented parent-to-all-children split."""
old_set = set(old)
new_set = set(new)
violations: list[ManifestViolation] = []
removed = old_set - new_set
added = new_set - old_set
# Pure shrink: removed leaves only, nothing added.
if not added:
return violations # any removal is a shrink (gate re-checks source later)
# Documented split: exactly one parent removed, all its children added.
if is_documented_split(topology, old_set, new_set):
return violations
# Everything else is growth or partial split: fail closed.
if removed:
violations.append(ManifestViolation(
"frontier-transition", ", ".join(sorted(removed)),
"frontier shrank AND grew; only pure shrink or documented parent-to-all-children split is allowed",
))
else:
violations.append(ManifestViolation(
"frontier-transition", ", ".join(sorted(added)),
"frontier grew without a documented parent-to-all-children split; arbitrary growth is forbidden",
))
return violations
def validate_topology_document(doc: dict) -> list[ManifestViolation]:
"""Validate the whole topology document (schema, topology, frontier, slices)."""
violations: list[ManifestViolation] = []
if not isinstance(doc, dict):
return [ManifestViolation("schema", "document", "topology document must be an object")]
if doc.get("schema_version") != SUPPORTED_SCHEMA_VERSION:
return [ManifestViolation(
"schema", "schema_version",
f"unsupported schema_version {doc.get('schema_version')!r}; only {SUPPORTED_SCHEMA_VERSION} is supported",
)]
topology = doc.get("topology")
if not isinstance(topology, dict) or not topology:
return [ManifestViolation("schema", "topology", "topology must be a nonempty object")]
for root, node in topology.items():
if not isinstance(node, dict):
violations.append(ManifestViolation("schema", root, "group node must be an object"))
continue
allowed_group_fields = {"kind", "scope", "slices"}
unknown = set(node) - allowed_group_fields
if unknown:
violations.append(ManifestViolation(
"schema", root, f"unknown group field(s) {sorted(unknown)}; expected {sorted(allowed_group_fields)}",
))
if "kind" not in node or "scope" not in node or "slices" not in node:
violations.append(ManifestViolation(
"schema", root, "group node must be {{kind, scope, slices}}",
))
continue
if node.get("kind") != "group":
violations.append(ManifestViolation("schema", root, f"group {root!r} kind must be 'group'"))
scope = node.get("scope")
if not isinstance(scope, list) or not scope or not all(isinstance(s, str) and s for s in scope):
violations.append(ManifestViolation("schema", root, "group scope must be a nonempty array of strings"))
slices = node.get("slices")
if not isinstance(slices, list):
violations.append(ManifestViolation("schema", root, "group slices must be an array"))
continue
seen: set[str] = set()
for i, slice_node in enumerate(slices):
loc = f"{root}.slices[{i}]"
if not isinstance(slice_node, dict):
violations.append(ManifestViolation("schema", loc, "slice must be an object"))
continue
allowed_slice_fields = {"name", "kind", "scope", "handlers"}
unknown = set(slice_node) - allowed_slice_fields
if unknown:
violations.append(ManifestViolation(
"schema", loc, f"unknown slice field(s) {sorted(unknown)}; expected {sorted(allowed_slice_fields)}",
))
if "name" not in slice_node:
violations.append(ManifestViolation("schema", loc, "slice must be an object with a name"))
continue
name = slice_node["name"]
if name in seen:
violations.append(ManifestViolation("schema", loc, f"duplicate slice name {name!r}"))
seen.add(name)
if not name.startswith(f"{root}::"):
violations.append(ManifestViolation("schema", loc, f"slice name {name!r} must start with {root!r}::"))
if slice_node.get("kind") != "slice":
violations.append(ManifestViolation("schema", loc, f"slice {name!r} kind must be 'slice'"))
slice_scope = slice_node.get("scope")
if not isinstance(slice_scope, list) or not slice_scope or not all(isinstance(s, str) and s for s in slice_scope):
violations.append(ManifestViolation("schema", loc, f"slice {name!r} scope must be a nonempty array of strings"))
for selector in slice_node.get("handlers", []):
violations.extend(validate_selector(selector, f"{loc}.handlers"))
violations.extend(validate_frontier(topology, doc.get("frontier", [])))
return violations
def load_topology(path: Path = TOPOLOGY_PATH) -> dict:
with path.open(encoding="utf-8") as fh:
return json.load(fh)
def load_pending_groups(path: Path = CONTRACT_PATH) -> list[str]:
"""Read the TUI frontier projection from `PENDING_GROUPS` fail-closed."""
source = path.read_text(encoding="utf-8")
match = re.search(
r"pub\(crate\)\s+const\s+PENDING_GROUPS\s*:\s*&\[&str\]\s*=\s*&\[(.*?)\];",
source,
re.DOTALL,
)
if match is None:
raise ValueError(f"could not locate PENDING_GROUPS in {path}")
body = match.group(1)
stripped = re.sub(r'"(?:\\.|[^"\\])*"', "", body)
if re.fullmatch(r"[\s,]*", stripped) is None:
raise ValueError("PENDING_GROUPS may contain string literals only")
return re.findall(r'"((?:\\.|[^"\\])*)"', body)
def validate_pending_projection(doc: dict, pending: list[str]) -> list[ManifestViolation]:
if pending != doc.get("frontier"):
return [ManifestViolation(
"frontier-projection",
"PENDING_GROUPS",
f"TUI projection {pending!r} does not equal JSON frontier {doc.get('frontier')!r}",
)]
return []
def load_topology_at_ref(ref: str, root: Path = REPO_ROOT) -> dict | None:
"""Load the topology from a Git revision; return None before its introduction."""
commit = subprocess.run(
["git", "rev-parse", "--verify", f"{ref}^{{commit}}"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if commit.returncode != 0:
raise ValueError(f"baseline ref {ref!r} is unavailable: {commit.stderr.strip()}")
shown = subprocess.run(
["git", "show", f"{ref}:{TOPOLOGY_REPO_PATH}"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if shown.returncode != 0:
return None
return json.loads(shown.stdout)
def validate_baseline_transition(current: dict, previous: dict | None) -> list[ManifestViolation]:
"""Enforce immutable topology and shrink-or-declared-split across revisions."""
if validate_topology_document(current):
return [ManifestViolation(
"current",
"topology",
"current topology is invalid; cannot validate a monotonic transition",
)]
if previous is None:
roots = sorted(current["topology"])
if current.get("frontier") != roots:
return [ManifestViolation(
"frontier-initialization",
"frontier",
f"first manifest revision must start at all topology roots {roots!r}",
)]
return []
violations = validate_topology_document(previous)
if violations:
return [ManifestViolation(
"baseline",
"topology",
"baseline topology is invalid; cannot validate a monotonic transition",
)]
if previous.get("topology") != current.get("topology"):
violations.append(ManifestViolation(
"topology-transition",
"topology",
"migration topology is immutable; only the frontier may change",
))
return violations
violations.extend(
is_valid_frontier_transition(
previous["topology"], previous["frontier"], current["frontier"]
)
)
return violations
def detect_local_baseline_ref(root: Path = REPO_ROOT) -> str | None:
"""Use the local feature-branch merge-base when available."""
process = subprocess.run(
["git", "merge-base", "HEAD", "origin/main"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if process.returncode != 0:
return None
baseline = process.stdout.strip()
head = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=root,
capture_output=True,
text=True,
check=False,
).stdout.strip()
return baseline if baseline and baseline != head else None
# ---------------------------------------------------------------------------
# Source scan (Task 3.5): structural Rust item resolution and bidirectional
# frontier check. Pure-stdlib parser: brace/paren aware, resolves qualified
# item paths for free functions, inherent methods, and trait-impl methods.
# ---------------------------------------------------------------------------
GROUPS_ROOT = REPO_ROOT / "crates" / "tui" / "src" / "commands" / "groups"
CONCRETE_APP_PARAM = re.compile(r"&\s*mut\s+(crate::tui::app::)?App\b")
class RustItem:
"""One parsed Rust item relevant to the migration scan."""
def __init__(self, kind: str, name: str, qual_path: str, file: Path,
line: int, param_types: list[str], is_concrete_app: bool) -> None:
self.kind = kind # 'free' | 'inherent' | 'trait_impl'
self.name = name
self.qual_path = qual_path
self.file = file
self.line = line
self.param_types = param_types
self.is_concrete_app = is_concrete_app
def __repr__(self) -> str:
return f"RustItem({self.kind}, {self.qual_path}, app={self.is_concrete_app})"
class SourceScanViolation:
"""One deterministic source-scan failure with an actionable diagnostic."""
def __init__(self, category: str, location: str, detail: str) -> None:
self.category = category
self.location = location
self.detail = detail
def __str__(self) -> str:
return f"{self.category}: {self.location}: {self.detail}"
def _strip_comments_and_strings(text: str) -> str:
"""Replace comments and string/char literals with spaces so the structural
scanner sees only code. Keeps raw strings and byte/char literals intact
enough for delimiter counting (content is blanked)."""
out = list(text)
i = 0
n = len(text)
in_line_comment = False
in_block_comment = 0
while i < n:
ch = text[i]
if in_line_comment:
if ch == "\n":
in_line_comment = False
else:
out[i] = " "
i += 1
continue
if in_block_comment:
if text.startswith("*/", i):
in_block_comment -= 1
out[i] = out[i + 1] = " "
i += 2
else:
out[i] = " "
i += 1
continue
if text.startswith("//", i):
in_line_comment = True
out[i] = out[i + 1] = " "
i += 2
continue
if text.startswith("/*", i):
in_block_comment += 1
out[i] = out[i + 1] = " "
i += 2
continue
if ch == '"':
# String literal (possibly raw r#"..."#). Blank until closing quote.
out[i] = " "
i += 1
if text.startswith('#"', i - 1):
while i < n and text[i] == "#":
out[i] = " "
i += 1
while i < n:
if text[i] == "\\" and i + 1 < n:
out[i] = out[i + 1] = " "
i += 2
continue
if text[i] == '"':
out[i] = " "
i += 1
break
out[i] = " "
i += 1
continue
if ch == "'":
# Char or lifetime. Blank the atom conservatively (lifetimes are
# single-quote-prefixed identifiers; chars are 'x' or '\\x').
if i + 1 < n and text[i + 1] == "'":
out[i] = out[i + 1] = " "
i += 2
continue
out[i] = " "
i += 1
if i < n and text[i] == "\\":
out[i] = " "
i += 1
if i < n:
out[i] = " "
i += 1
if i < n and text[i] == "'":
out[i] = " "
i += 1
continue
i += 1
return "".join(out)
def _split_top_level(text: str, sep: str) -> list[str]:
"""Split on a separator character outside (), [], {} and strings."""
parts: list[str] = []
start = 0
depth = 0
for i, ch in enumerate(text):
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch == sep and depth == 0:
parts.append(text[start:i])
start = i + 1
parts.append(text[start:])
return parts
def _module_path_from_file(file: Path, root: Path) -> str:
"""Derive the crate-relative module path for a file under the groups root.
`mod.rs` resolves to its directory name; other files to
`dirname.file_name` (snake_case). Path segments are joined with `::`.
"""
rel = file.relative_to(root)
parts = list(rel.parts)
if parts[-1] == "mod.rs":
parts = parts[:-1]
else:
parts[-1] = parts[-1].removesuffix(".rs")
return "crate::commands::groups::" + "::".join(parts)
def _first_param_type(fn_sig: str) -> str | None:
"""Extract the first parameter's type from a `fn name(...)` signature text."""
open_idx = fn_sig.find("(")
if open_idx < 0:
return None
close_idx = fn_sig.rfind(")")
if close_idx < open_idx:
return None
params = _split_top_level(fn_sig[open_idx + 1:close_idx], ",")
params = [p.strip() for p in params if p.strip()]
if not params:
return None
first = params[0]
if first == "self" or first.startswith("self:") or first.startswith("&self"):
return None
# `name: Type` or `name: Type` with generic default: take after the first top-level ':'.
depth = 0
for idx, ch in enumerate(first):
if ch in "<([":
depth += 1
elif ch in ">)]":
depth -= 1
elif ch == ":" and depth == 0:
return first[idx + 1:].strip()
return None
def _is_concrete_app_type(param_type: str | None) -> bool:
if param_type is None:
return False
# Match `&mut App` / `&mut crate::tui::app::App` with optional spaces
# around `mut`; no whitespace normalization so `\s*` can bind.
return bool(CONCRETE_APP_PARAM.search(param_type))
def parse_rust_file(file: Path, root: Path) -> list[RustItem]:
"""Structurally parse one Rust file for handler-relevant items.
Handles `fn name(...) -> R { ... }` free functions at top level and
`impl Trait for Type { fn ... }` / `impl Type { fn ... }` blocks. The
parser tracks braces to skip bodies and resolves qualified paths from the
module layout. It is deliberately narrow: it looks for function items with
a first parameter typed `&mut App` (the concrete-App handler signature).
`root` is the *groups* root: module paths are derived from the file's
position under `crates/tui/src/commands/groups/`, not the repo root.
"""
raw = file.read_text(encoding="utf-8")
code = _strip_comments_and_strings(raw)
# Module paths derive from the file's position under the real groups root;
# hermetic tests use a synthetic root, in which case the passed root is the
# module-path base.
try:
module_path = _module_path_from_file(file, GROUPS_ROOT)
except ValueError:
module_path = _module_path_from_file(file, root)
items: list[RustItem] = []
lines = raw.splitlines()
i = 0
n = len(code)
# Walk top-level items: skip until we find `fn` or `impl` at depth 0.
while i < n:
# find next top-level keyword occurrence
while i < n and code[i].isspace():
i += 1
if i >= n:
break
if code.startswith("fn", i) and (i == 0 or not (code[i - 1].isalnum() or code[i - 1] == "_")):
# free function
sig_end = _find_fn_signature_end(code, i + 2)
if sig_end is None:
i += 2
continue
sig_text = code[i:sig_end]
name_match = re.match(r"fn\s+([A-Za-z_][A-Za-z0-9_]*)", sig_text)
line_no = raw.count("\n", 0, code.find(sig_text[:20], 0)) + 1 if sig_text[:20] else 0
if name_match:
name = name_match.group(1)
param_type = _first_param_type(sig_text)
items.append(RustItem(
kind="free",
name=name,
qual_path=f"{module_path}::{name}",
file=file,
line=line_no,
param_types=[param_type] if param_type else [],
is_concrete_app=_is_concrete_app_type(param_type),
))
# advance past the signature and body
i = sig_end
# skip the block body if present (or the `;` for a declaration)
while i < n and code[i].isspace():
i += 1
if i < n and code[i] == "{":
depth = 0
while i < n:
if code[i] == "{":
depth += 1
elif code[i] == "}":
depth -= 1
if depth == 0:
i += 1
break
i += 1
else:
i += 1 # consume the `;` (or advance past a non-body token)
continue
if code.startswith("impl", i) and (i == 0 or not (code[i - 1].isalnum() or code[i - 1] == "_")):
# impl block: header until '{'
brace_idx = code.find("{", i)
if brace_idx < 0:
i += 4
continue
header = code[i + 4:brace_idx].strip()
# `impl Trait for Type` vs `impl Type`
trait_impl = " for " in header
self_type = header.split(" for ")[-1].strip() if trait_impl else header.strip()
# methods inside
depth = 1
j = brace_idx + 1
while j < n and depth > 0:
if code[j] == "{":
depth += 1
elif code[j] == "}":
depth -= 1
if depth == 0:
break
elif code[j].isspace():
j += 1
continue
elif code.startswith("fn", j) and not (code[j - 1].isalnum() or code[j - 1] == "_"):
fn_start = j
sig_end = _find_fn_signature_end(code, j + 2)
if sig_end is None:
j += 2
continue
sig_text = code[fn_start:sig_end]
name_match = re.match(r"fn\s+([A-Za-z_][A-Za-z0-9_]*)", sig_text)
if name_match:
name = name_match.group(1)
param_type = _first_param_type(sig_text)
self_qual = _self_type_qual(self_type, module_path)
if trait_impl:
trait_path = header.split(" for ")[0].strip()
kind = "trait_impl"
qual = f"{self_qual}::{name} [{trait_path}]"
else:
kind = "inherent"
qual = f"{self_qual}::{name}"
line_no = raw.count("\n", 0, code.find(sig_text[:20], 0)) + 1 if sig_text[:20] else 0
items.append(RustItem(
kind=kind,
name=name,
qual_path=qual,
file=file,
line=line_no,
param_types=[param_type] if param_type else [],
is_concrete_app=_is_concrete_app_type(param_type),
))
j = sig_end
# skip the method body (or consume `;` for a declaration)
while j < n and code[j].isspace():
j += 1
if j < n and code[j] == "{":
body_depth = 0
while j < n:
if code[j] == "{":
body_depth += 1
elif code[j] == "}":
body_depth -= 1
if body_depth == 0:
j += 1
break
j += 1
else:
j += 1
continue
else:
j += 1
continue
i = j + 1 if j < n else n
continue
i += 1
return items
def _find_fn_signature_end(code: str, start: int) -> int | None:
"""Find the index just after a `fn` signature (before `{` or `;`),
respecting nested generics and parens. The `->` arrow's `>` is not a
generic close delimiter."""
depth = 0
generic = 0
i = start
n = len(code)
while i < n:
ch = code[i]
if ch in "([":
depth += 1
elif ch in ")]":
depth -= 1
elif ch == "<":
generic += 1
elif ch == ">":
# `->` arrow: previous char is '-'; not a generic close.
if i > 0 and code[i - 1] == "-":
pass
elif generic > 0:
generic -= 1
elif ch in "{;" and depth == 0 and generic == 0:
return i
i += 1
return None
def _self_type_qual(self_type: str, module_path: str) -> str:
"""Qualify a bare self type with the module path (e.g. `BranchCmd` ->
`crate::commands::groups::session::branch::BranchCmd`). Already-qualified
paths pass through."""
cleaned = re.sub(r"\s+", "", self_type)
if cleaned.startswith("crate::") or cleaned.startswith("super::"):
return cleaned
# Strip generic args for qualification purposes (path part only).
base = re.split(r"[<({]", cleaned)[0]
return f"{module_path}::{base}"
def resolve_selector(selector: dict, items: list[RustItem]) -> list[SourceScanViolation]:
"""Resolve one checked-in handler selector against parsed items.
Returns violations for missing, ambiguous, or non-concrete-App targets.
"""
violations: list[SourceScanViolation] = []
kind = selector["kind"]
if kind == "free":
target = "::".join(selector["item"])
matches = [it for it in items if it.kind == "free" and it.qual_path == target]
elif kind == "inherent":
self_type = selector["self_type"]
method = selector["method"]
self_qual = _selector_type_to_text(self_type)
matches = [
it for it in items
if it.kind == "inherent" and it.name == method and it.qual_path.startswith(f"{self_qual}::")
]
else: # trait_impl
self_type = selector["self_type"]
trait_path = selector["trait_path"]
method = selector["method"]
self_qual = _selector_type_to_text(self_type)
trait_qual = _selector_type_to_text(trait_path)
matches = [
it for it in items
if it.kind == "trait_impl" and it.name == method
and it.qual_path.startswith(f"{self_qual}::") and f"[{trait_qual}]" in it.qual_path
]
if not matches:
violations.append(SourceScanViolation(
"selector-resolve", str(selector),
"selector resolved to no source item; handler may have moved, been renamed, or the path is stale",
))
elif len(matches) > 1:
violations.append(SourceScanViolation(
"selector-resolve", str(selector),
f"selector resolved to {len(matches)} items (ambiguous): " + "; ".join(m.qual_path for m in matches),
))
elif not matches[0].is_concrete_app:
violations.append(SourceScanViolation(
"selector-resolve", str(selector),
f"resolved handler {matches[0].qual_path} no longer has a concrete-App signature",
))
return violations
def _selector_type_to_text(node) -> str:
"""Render a type-algebra node back to Rust text for matching."""
if not isinstance(node, dict):
return str(node)
tag = node["tag"]
if tag == "path":
segments = []
for seg in node.get("segments", []):
name = seg["name"] if isinstance(seg, dict) else str(seg)
segments.append(name)
prefix = "" if node.get("absolute") else ""
return prefix + "::".join(segments)
if tag == "primitive":
return node["name"]
if tag == "never":
return "!"
if tag == "tuple":
return "(" + ",".join(_selector_type_to_text(e) for e in node.get("elems", [])) + ")"
if tag == "reference":
return "&" + ("mut " if node.get("mut") else "") + _selector_type_to_text(node.get("inner"))
if tag == "pointer":
return "*" + ("mut " if node.get("mut") else "const ") + _selector_type_to_text(node.get("inner"))
if tag == "slice":
return "[" + _selector_type_to_text(node.get("inner")) + "]"
if tag == "array":
return "[" + _selector_type_to_text(node.get("inner")) + "; " + _selector_type_to_text(node.get("len")) + "]"
return ""
def scan_leaf_handlers(leaf_scope: list[str], root: Path) -> tuple[list[RustItem], list[SourceScanViolation]]:
"""Scan one leaf's source scope for concrete-App handler items.
`root` is the repo root (scope paths are repo-relative); module paths are
derived against the groups root internally.
"""
items: list[RustItem] = []
violations: list[SourceScanViolation] = []
for rel in leaf_scope:
path = root / rel
if not path.is_file():
violations.append(SourceScanViolation(
"source-scan", rel, "scope file missing from the source tree",
))
continue
try:
items.extend(parse_rust_file(path, root))
except Exception as exc: # pragma: no cover - defensive
violations.append(SourceScanViolation(
"source-scan", rel, f"failed to parse: {exc}",
))
return items, violations
def check_source_frontier(topology: dict, frontier: list[str], root: Path = REPO_ROOT) -> list[SourceScanViolation]:
"""Bidirectional scan: the frontier must exactly equal the leaves whose
scopes still contain concrete-App handlers.
The frontier may name a whole group (all its files pending) or a group's
declared slices (after a documented split). For every group:
- group pending: all concrete-App handlers in the group scope are covered.
- group split: the frontier must name ALL its slices, and every handler
in the group scope must fall inside a pending slice's scope.
- otherwise: any concrete-App handler in the group is a stale-removal.
"""
violations: list[SourceScanViolation] = []
frontier_set = set(frontier)
for group_name, node in topology.items():
group_items, scan_violations = scan_leaf_handlers(node.get("scope", []), root)
violations.extend(scan_violations)
handlers = [it for it in group_items if it.is_concrete_app]
slices = node.get("slices", [])
if group_name in frontier_set:
# Whole group pending: every handler is covered; a pending group
# with no concrete-App handler is a stale entry.
if not handlers:
violations.append(SourceScanViolation(
"stale-entry", group_name,
"frontier group is pending but its scope has no concrete-App handler",
))
for selector in node.get("handlers", []):
violations.extend(resolve_selector(selector, group_items))
continue
if not handlers:
continue
if slices:
slice_names = {s["name"] for s in slices}
slice_pending = slice_names & frontier_set
if slice_pending == slice_names:
# Documented split: verify each slice scope holds its handlers.
for slice_node in slices:
slice_items, slice_violations = scan_leaf_handlers(
slice_node.get("scope", []), root
)
violations.extend(slice_violations)
slice_handlers = [it for it in slice_items if it.is_concrete_app]
if not slice_handlers:
violations.append(SourceScanViolation(
"stale-entry", slice_node["name"],
"frontier slice is pending but its scope has no concrete-App handler",
))
for selector in slice_node.get("handlers", []):
violations.extend(resolve_selector(selector, slice_items))
continue
if slice_pending:
# Partial split: some slices pending, some not.
missing = sorted(slice_names - slice_pending)
violations.append(SourceScanViolation(
"partial-split", group_name,
f"group is neither wholly pending nor fully split; pending slices "
f"{sorted(slice_pending)} but missing {missing}",
))
continue
# Not pending and not split: every remaining handler is a stale removal.
for handler in handlers[:5]:
violations.append(SourceScanViolation(
"stale-removal", handler.qual_path,
f"handler still uses concrete App but group {group_name!r} is not pending",
))
if len(handlers) > 5:
violations.append(SourceScanViolation(
"stale-removal", group_name,
f"... and {len(handlers) - 5} more concrete-App handlers in this group",
))
return violations
def _leaf_node(topology: dict, leaf_name: str) -> dict | None:
if leaf_name in topology:
return topology[leaf_name]
for root_name, node in topology.items():
for slice_node in node.get("slices", []):
if slice_node["name"] == leaf_name:
return slice_node
return None
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--baseline-ref",
help="Git revision whose topology/frontier must transition monotonically to the current one",
)
args = parser.parse_args(argv)
try:
doc = load_topology()
pending = load_pending_groups()
baseline_ref = args.baseline_ref or detect_local_baseline_ref()
previous = load_topology_at_ref(baseline_ref) if baseline_ref else None
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"[command-migration-manifest] FAIL: {error}", file=sys.stderr)
return 1
violations = validate_topology_document(doc)
violations.extend(validate_pending_projection(doc, pending))
violations.extend(validate_baseline_transition(doc, previous))
if violations:
print("[command-migration-manifest] FAIL", file=sys.stderr)
for violation in violations:
print(f" {violation}", file=sys.stderr)
return 1
source_violations = check_source_frontier(doc["topology"], doc["frontier"])
if source_violations:
print("[command-migration-manifest] FAIL (source scan)", file=sys.stderr)
for violation in source_violations:
print(f" {violation}", file=sys.stderr)
return 1
frontier = doc["frontier"]
transition = f"; baseline {baseline_ref}" if baseline_ref else "; initialization"
print(
f"[command-migration-manifest] PASS: schema v{SUPPORTED_SCHEMA_VERSION}; "
f"frontier [{', '.join(frontier)}] matches TUI projection and source{transition}"
)
return 0
if __name__ == "__main__":
sys.exit(main())