| 1 | #!/usr/bin/env python3 |
| 2 | """Hermetic tests for the FEAT-015 command migration manifest gate. |
| 3 | |
| 4 | Covers the Deep-Dive schema and frontier rules: |
| 5 | |
| 6 | - valid root frontier; valid parent-to-all-children replacement; valid removal |
| 7 | - rejected partial split; rejected arbitrary addition |
| 8 | - duplicate/unsorted entry; unknown schema/tag/field; unsupported syntax |
| 9 | - missing/overlapping selector; const normalization |
| 10 | - integer/character/byte/path const atoms; out-of-range byte diagnostics |
| 11 | """ |
| 12 | |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import importlib.util |
| 16 | import json |
| 17 | import subprocess |
| 18 | import sys |
| 19 | import tempfile |
| 20 | import unittest |
| 21 | from pathlib import Path |
| 22 | |
| 23 | ROOT = Path(__file__).resolve().parents[1] |
| 24 | SCRIPT = ROOT / "scripts" / "check-command-migration-manifest.py" |
| 25 | SPEC = importlib.util.spec_from_file_location("command_migration", SCRIPT) |
| 26 | assert SPEC and SPEC.loader |
| 27 | mod = importlib.util.module_from_spec(SPEC) |
| 28 | sys.modules[SPEC.name] = mod |
| 29 | SPEC.loader.exec_module(mod) |
| 30 | |
| 31 | |
| 32 | def sample_topology() -> dict: |
| 33 | """A minimal two-group topology with one documented slice per group.""" |
| 34 | return { |
| 35 | "schema_version": 1, |
| 36 | "topology": { |
| 37 | "utility": { |
| 38 | "kind": "group", |
| 39 | "scope": ["crates/tui/src/commands/groups/utility/mod.rs"], |
| 40 | "slices": [], |
| 41 | }, |
| 42 | "session": { |
| 43 | "kind": "group", |
| 44 | "scope": ["crates/tui/src/commands/groups/session/mod.rs"], |
| 45 | "slices": [ |
| 46 | { |
| 47 | "name": "session::lifecycle", |
| 48 | "kind": "slice", |
| 49 | "scope": ["crates/tui/src/commands/groups/session/branch.rs"], |
| 50 | }, |
| 51 | { |
| 52 | "name": "session::control", |
| 53 | "kind": "slice", |
| 54 | "scope": ["crates/tui/src/commands/groups/session/relay.rs"], |
| 55 | }, |
| 56 | ], |
| 57 | }, |
| 58 | }, |
| 59 | "frontier": ["session", "utility"], |
| 60 | } |
| 61 | |
| 62 | |
| 63 | class SchemaTests(unittest.TestCase): |
| 64 | def test_valid_document_passes(self) -> None: |
| 65 | self.assertEqual(mod.validate_topology_document(sample_topology()), []) |
| 66 | |
| 67 | def test_unknown_schema_version_fails(self) -> None: |
| 68 | doc = sample_topology() |
| 69 | doc["schema_version"] = 2 |
| 70 | violations = mod.validate_topology_document(doc) |
| 71 | self.assertEqual(len(violations), 1) |
| 72 | self.assertIn("schema_version", str(violations[0])) |
| 73 | |
| 74 | def test_missing_schema_version_fails(self) -> None: |
| 75 | doc = sample_topology() |
| 76 | del doc["schema_version"] |
| 77 | self.assertTrue(mod.validate_topology_document(doc)) |
| 78 | |
| 79 | def test_unknown_group_field_fails(self) -> None: |
| 80 | doc = sample_topology() |
| 81 | doc["topology"]["utility"]["extra"] = True |
| 82 | violations = mod.validate_topology_document(doc) |
| 83 | self.assertTrue(any("extra" in str(v) for v in violations)) |
| 84 | |
| 85 | def test_slice_without_group_prefix_fails(self) -> None: |
| 86 | doc = sample_topology() |
| 87 | doc["topology"]["session"]["slices"][0]["name"] = "other::slice" |
| 88 | violations = mod.validate_topology_document(doc) |
| 89 | self.assertTrue(any("must start with" in str(v) for v in violations)) |
| 90 | |
| 91 | def test_duplicate_slice_name_fails(self) -> None: |
| 92 | doc = sample_topology() |
| 93 | doc["topology"]["session"]["slices"].append( |
| 94 | doc["topology"]["session"]["slices"][0] |
| 95 | ) |
| 96 | violations = mod.validate_topology_document(doc) |
| 97 | self.assertTrue(any("duplicate slice" in str(v) for v in violations)) |
| 98 | |
| 99 | |
| 100 | class FrontierTests(unittest.TestCase): |
| 101 | def test_valid_root_frontier_passes(self) -> None: |
| 102 | doc = sample_topology() |
| 103 | self.assertEqual(mod.validate_frontier(doc["topology"], doc["frontier"]), []) |
| 104 | |
| 105 | def test_unsorted_frontier_fails(self) -> None: |
| 106 | doc = sample_topology() |
| 107 | violations = mod.validate_frontier(doc["topology"], ["utility", "session"]) |
| 108 | self.assertTrue(any("sorted" in str(v) for v in violations)) |
| 109 | |
| 110 | def test_duplicate_frontier_entry_fails(self) -> None: |
| 111 | doc = sample_topology() |
| 112 | violations = mod.validate_frontier(doc["topology"], ["session", "session"]) |
| 113 | self.assertTrue(any("duplicates" in str(v) for v in violations)) |
| 114 | |
| 115 | def test_unknown_frontier_leaf_fails(self) -> None: |
| 116 | doc = sample_topology() |
| 117 | violations = mod.validate_frontier(doc["topology"], ["session", "ghost"]) |
| 118 | self.assertTrue(any("not a declared topology leaf" in str(v) for v in violations)) |
| 119 | |
| 120 | def test_removal_is_valid_transition(self) -> None: |
| 121 | doc = sample_topology() |
| 122 | old = ["session", "utility"] |
| 123 | new = ["utility"] |
| 124 | self.assertEqual(mod.is_valid_frontier_transition(doc["topology"], old, new), []) |
| 125 | |
| 126 | def test_parent_to_all_children_is_valid_transition(self) -> None: |
| 127 | doc = sample_topology() |
| 128 | old = ["session", "utility"] |
| 129 | new = ["session::control", "session::lifecycle", "utility"] |
| 130 | self.assertEqual(mod.is_valid_frontier_transition(doc["topology"], old, new), []) |
| 131 | |
| 132 | def test_partial_split_is_rejected(self) -> None: |
| 133 | doc = sample_topology() |
| 134 | old = ["session", "utility"] |
| 135 | new = ["session::lifecycle", "utility"] |
| 136 | violations = mod.is_valid_frontier_transition(doc["topology"], old, new) |
| 137 | self.assertTrue(violations, "partial split must be rejected") |
| 138 | |
| 139 | def test_arbitrary_addition_is_rejected(self) -> None: |
| 140 | doc = sample_topology() |
| 141 | old = ["session", "utility"] |
| 142 | new = ["ghost", "session", "utility"] |
| 143 | violations = mod.is_valid_frontier_transition(doc["topology"], old, new) |
| 144 | self.assertTrue(violations, "arbitrary growth must be rejected") |
| 145 | |
| 146 | |
| 147 | class LiveTransitionTests(unittest.TestCase): |
| 148 | def test_invalid_current_manifest_fails_closed_before_transition(self) -> None: |
| 149 | violations = mod.validate_baseline_transition({}, None) |
| 150 | self.assertTrue(any("current topology is invalid" in str(v) for v in violations)) |
| 151 | |
| 152 | def test_initial_manifest_must_start_at_all_roots(self) -> None: |
| 153 | doc = sample_topology() |
| 154 | self.assertEqual(mod.validate_baseline_transition(doc, None), []) |
| 155 | doc["frontier"] = ["utility"] |
| 156 | violations = mod.validate_baseline_transition(doc, None) |
| 157 | self.assertTrue(any("first manifest revision" in str(v) for v in violations)) |
| 158 | |
| 159 | def test_live_transition_rejects_topology_mutation(self) -> None: |
| 160 | previous = sample_topology() |
| 161 | current = json.loads(json.dumps(previous)) |
| 162 | current["topology"]["utility"]["scope"].append("new.rs") |
| 163 | violations = mod.validate_baseline_transition(current, previous) |
| 164 | self.assertTrue(any("topology is immutable" in str(v) for v in violations)) |
| 165 | |
| 166 | def test_live_transition_accepts_documented_split(self) -> None: |
| 167 | previous = sample_topology() |
| 168 | current = json.loads(json.dumps(previous)) |
| 169 | current["frontier"] = ["session::control", "session::lifecycle", "utility"] |
| 170 | self.assertEqual(mod.validate_baseline_transition(current, previous), []) |
| 171 | |
| 172 | def test_live_transition_rejects_arbitrary_growth(self) -> None: |
| 173 | previous = sample_topology() |
| 174 | previous["frontier"] = ["utility"] |
| 175 | current = json.loads(json.dumps(previous)) |
| 176 | current["frontier"] = ["session", "utility"] |
| 177 | violations = mod.validate_baseline_transition(current, previous) |
| 178 | self.assertTrue(any("arbitrary growth" in str(v) for v in violations)) |
| 179 | |
| 180 | def test_pending_projection_must_equal_json_frontier(self) -> None: |
| 181 | doc = sample_topology() |
| 182 | self.assertEqual( |
| 183 | mod.validate_pending_projection(doc, ["session", "utility"]), [] |
| 184 | ) |
| 185 | violations = mod.validate_pending_projection(doc, ["utility"]) |
| 186 | self.assertTrue(any("does not equal JSON frontier" in str(v) for v in violations)) |
| 187 | |
| 188 | def test_pending_groups_parser_is_string_only_and_order_preserving(self) -> None: |
| 189 | with tempfile.TemporaryDirectory() as directory: |
| 190 | path = Path(directory) / "contract.rs" |
| 191 | path.write_text( |
| 192 | 'pub(crate) const PENDING_GROUPS: &[&str] = &["session", "utility"];\n', |
| 193 | encoding="utf-8", |
| 194 | ) |
| 195 | self.assertEqual(mod.load_pending_groups(path), ["session", "utility"]) |
| 196 | |
| 197 | |
| 198 | class GitBaselineTests(unittest.TestCase): |
| 199 | def setUp(self) -> None: |
| 200 | directory = tempfile.TemporaryDirectory() |
| 201 | self.addCleanup(directory.cleanup) |
| 202 | self.root = Path(directory.name) |
| 203 | self.git("init", "-q", "--template=", "-b", "main") |
| 204 | self.git("config", "user.name", "Manifest test") |
| 205 | self.git("config", "user.email", "manifest@example.invalid") |
| 206 | self.git("config", "commit.gpgsign", "false") |
| 207 | |
| 208 | def git(self, *args: str) -> str: |
| 209 | return subprocess.check_output( |
| 210 | ["git", *args], cwd=self.root, text=True, stderr=subprocess.PIPE, |
| 211 | ).strip() |
| 212 | |
| 213 | def commit(self, frontier: list[str]) -> str: |
| 214 | doc = sample_topology() |
| 215 | doc["frontier"] = frontier |
| 216 | target = self.root / mod.TOPOLOGY_REPO_PATH |
| 217 | target.parent.mkdir(exist_ok=True) |
| 218 | target.write_text(json.dumps(doc), encoding="utf-8") |
| 219 | self.git("add", mod.TOPOLOGY_REPO_PATH) |
| 220 | self.git("commit", "-qm", "Update frontier", "--allow-empty") |
| 221 | return self.git("rev-parse", "HEAD") |
| 222 | |
| 223 | def test_aligned_main_uses_parent_and_still_rejects_growth(self) -> None: |
| 224 | previous = self.commit(["utility"]) |
| 225 | head = self.commit(["session", "utility"]) |
| 226 | self.git("update-ref", "refs/remotes/origin/main", head) |
| 227 | baseline = mod.detect_local_baseline_ref(self.root) |
| 228 | self.assertEqual(baseline, previous) |
| 229 | violations = mod.validate_baseline_transition( |
| 230 | mod.load_topology_at_ref(head, self.root), |
| 231 | mod.load_topology_at_ref(baseline, self.root), |
| 232 | ) |
| 233 | self.assertTrue(any("arbitrary growth" in str(v) for v in violations)) |
| 234 | |
| 235 | def test_feature_branch_keeps_merge_base_across_multiple_commits(self) -> None: |
| 236 | base = self.commit(["session", "utility"]) |
| 237 | self.git("update-ref", "refs/remotes/origin/main", base) |
| 238 | self.commit(["utility"]) |
| 239 | self.commit(["utility"]) |
| 240 | self.assertEqual(mod.detect_local_baseline_ref(self.root), base) |
| 241 | |
| 242 | def test_history_without_remote_uses_parent(self) -> None: |
| 243 | previous = self.commit(["session", "utility"]) |
| 244 | self.commit(["utility"]) |
| 245 | self.assertEqual(mod.detect_local_baseline_ref(self.root), previous) |
| 246 | |
| 247 | def test_initial_commit_has_no_baseline(self) -> None: |
| 248 | head = self.commit(["session", "utility"]) |
| 249 | self.git("update-ref", "refs/remotes/origin/main", head) |
| 250 | self.assertIsNone(mod.detect_local_baseline_ref(self.root)) |
| 251 | |
| 252 | |
| 253 | class SelectorTests(unittest.TestCase): |
| 254 | def test_free_selector_passes(self) -> None: |
| 255 | selector = {"kind": "free", "item": ["crate", "commands", "groups", "session", "run_save"]} |
| 256 | self.assertEqual(mod.validate_selector(selector, "s"), []) |
| 257 | |
| 258 | def test_inherent_selector_passes(self) -> None: |
| 259 | selector = { |
| 260 | "kind": "inherent", |
| 261 | "self_type": { |
| 262 | "tag": "path", |
| 263 | "absolute": True, |
| 264 | "segments": [ |
| 265 | {"name": "crate"}, |
| 266 | {"name": "commands", "args": []}, |
| 267 | {"name": "groups", "args": []}, |
| 268 | {"name": "session", "args": []}, |
| 269 | {"name": "branch", "args": []}, |
| 270 | {"name": "BranchCmd", "args": []}, |
| 271 | ], |
| 272 | }, |
| 273 | "method": "execute", |
| 274 | } |
| 275 | self.assertEqual(mod.validate_selector(selector, "s"), []) |
| 276 | |
| 277 | def test_trait_impl_selector_passes(self) -> None: |
| 278 | selector = { |
| 279 | "kind": "trait_impl", |
| 280 | "self_type": {"tag": "path", "absolute": True, "segments": [{"name": "BranchCmd"}]}, |
| 281 | "trait_path": {"tag": "path", "absolute": True, "segments": [{"name": "RegisterCommand"}]}, |
| 282 | "method": "execute", |
| 283 | } |
| 284 | self.assertEqual(mod.validate_selector(selector, "s"), []) |
| 285 | |
| 286 | def test_unknown_selector_kind_fails(self) -> None: |
| 287 | selector = {"kind": "static", "item": ["a", "b"]} |
| 288 | violations = mod.validate_selector(selector, "s") |
| 289 | self.assertTrue(any("unknown selector kind" in str(v) for v in violations)) |
| 290 | |
| 291 | def test_free_selector_missing_function_fails(self) -> None: |
| 292 | selector = {"kind": "free", "item": ["crate"]} |
| 293 | violations = mod.validate_selector(selector, "s") |
| 294 | self.assertTrue(any("module path array" in str(v) for v in violations)) |
| 295 | |
| 296 | def test_inherent_selector_missing_method_fails(self) -> None: |
| 297 | selector = {"kind": "inherent", "self_type": {"tag": "never"}} |
| 298 | violations = mod.validate_selector(selector, "s") |
| 299 | self.assertTrue(any("inherent.method" in str(v) for v in violations)) |
| 300 | |
| 301 | def test_unknown_self_type_tag_fails(self) -> None: |
| 302 | selector = {"kind": "inherent", "self_type": {"tag": "fn_ptr"}, "method": "execute"} |
| 303 | violations = mod.validate_selector(selector, "s") |
| 304 | self.assertTrue(any("unknown type node tag" in str(v) for v in violations)) |
| 305 | |
| 306 | |
| 307 | class TypeAlgebraTests(unittest.TestCase): |
| 308 | def test_primitive_and_never_pass(self) -> None: |
| 309 | self.assertEqual(mod.validate_type_node({"tag": "primitive", "name": "u8"}, "t"), []) |
| 310 | self.assertEqual(mod.validate_type_node({"tag": "never"}, "t"), []) |
| 311 | |
| 312 | def test_unknown_primitive_fails(self) -> None: |
| 313 | violations = mod.validate_type_node({"tag": "primitive", "name": "u24"}, "t") |
| 314 | self.assertTrue(violations) |
| 315 | |
| 316 | def test_tuple_and_slice_pass(self) -> None: |
| 317 | self.assertEqual( |
| 318 | mod.validate_type_node({"tag": "tuple", "elems": [{"tag": "never"}]}, "t"), [] |
| 319 | ) |
| 320 | self.assertEqual( |
| 321 | mod.validate_type_node({"tag": "slice", "inner": {"tag": "primitive", "name": "u8"}}, "t"), |
| 322 | [], |
| 323 | ) |
| 324 | |
| 325 | def test_reference_with_mut_passes(self) -> None: |
| 326 | self.assertEqual( |
| 327 | mod.validate_type_node( |
| 328 | {"tag": "reference", "mut": True, "inner": {"tag": "primitive", "name": "str"}}, |
| 329 | "t", |
| 330 | ), |
| 331 | [], |
| 332 | ) |
| 333 | |
| 334 | def test_array_with_byte_len_passes(self) -> None: |
| 335 | node = { |
| 336 | "tag": "array", |
| 337 | "inner": {"tag": "primitive", "name": "u8"}, |
| 338 | "len": {"tag": "int", "negative": False, "magnitude": "8", "suffix": None}, |
| 339 | } |
| 340 | self.assertEqual(mod.validate_type_node(node, "t"), []) |
| 341 | |
| 342 | def test_generic_const_argument_normalization(self) -> None: |
| 343 | # byte literal 255 with u8 suffix passes; 256 fails with field diagnostic |
| 344 | ok = {"tag": "const", "atom": {"tag": "int", "negative": False, "magnitude": "255", "suffix": "u8"}} |
| 345 | self.assertEqual(mod.validate_generic_arg(ok, "g"), []) |
| 346 | bad = {"tag": "const", "atom": {"tag": "int", "negative": False, "magnitude": "256", "suffix": "u8"}} |
| 347 | violations = mod.validate_generic_arg(bad, "g") |
| 348 | self.assertTrue(any("byte value 256 exceeds" in str(v) for v in violations)) |
| 349 | |
| 350 | |
| 351 | class ConstAtomTests(unittest.TestCase): |
| 352 | def test_bool_passes(self) -> None: |
| 353 | self.assertEqual(mod.validate_const_atom({"tag": "bool", "value": True}, "c"), []) |
| 354 | |
| 355 | def test_bool_non_bool_value_fails(self) -> None: |
| 356 | violations = mod.validate_const_atom({"tag": "bool", "value": 1}, "c") |
| 357 | self.assertTrue(violations) |
| 358 | |
| 359 | def test_integer_canonical_magnitude(self) -> None: |
| 360 | self.assertEqual( |
| 361 | mod.validate_const_atom( |
| 362 | {"tag": "int", "negative": False, "magnitude": "0", "suffix": None}, "c" |
| 363 | ), |
| 364 | [], |
| 365 | ) |
| 366 | self.assertEqual( |
| 367 | mod.validate_const_atom( |
| 368 | {"tag": "int", "negative": False, "magnitude": "42", "suffix": "u32"}, "c" |
| 369 | ), |
| 370 | [], |
| 371 | ) |
| 372 | violations = mod.validate_const_atom( |
| 373 | {"tag": "int", "negative": False, "magnitude": "042", "suffix": None}, "c" |
| 374 | ) |
| 375 | self.assertTrue(any("leading zeros" in str(v) for v in violations)) |
| 376 | |
| 377 | def test_negative_zero_fails(self) -> None: |
| 378 | violations = mod.validate_const_atom( |
| 379 | {"tag": "int", "negative": True, "magnitude": "0", "suffix": None}, "c" |
| 380 | ) |
| 381 | self.assertTrue(any("negative zero" in str(v) for v in violations)) |
| 382 | |
| 383 | def test_unknown_suffix_fails(self) -> None: |
| 384 | violations = mod.validate_const_atom( |
| 385 | {"tag": "int", "negative": False, "magnitude": "1", "suffix": "u33"}, "c" |
| 386 | ) |
| 387 | self.assertTrue(any("suffix" in str(v) for v in violations)) |
| 388 | |
| 389 | def test_char_scalar_passes(self) -> None: |
| 390 | self.assertEqual(mod.validate_const_atom({"tag": "char", "scalar": "x"}, "c"), []) |
| 391 | |
| 392 | def test_char_multi_scalar_fails(self) -> None: |
| 393 | violations = mod.validate_const_atom({"tag": "char", "scalar": "xy"}, "c") |
| 394 | self.assertTrue(any("exactly one" in str(v) for v in violations)) |
| 395 | |
| 396 | def test_path_const_passes(self) -> None: |
| 397 | atom = {"tag": "path", "absolute": True, "segments": ["SIZE"]} |
| 398 | self.assertEqual(mod.validate_const_atom(atom, "c"), []) |
| 399 | |
| 400 | def test_unknown_tag_fails(self) -> None: |
| 401 | violations = mod.validate_const_atom({"tag": "float", "value": 1.0}, "c") |
| 402 | self.assertTrue(any("unknown const atom tag" in str(v) for v in violations)) |
| 403 | |
| 404 | def test_extra_field_fails(self) -> None: |
| 405 | violations = mod.validate_const_atom( |
| 406 | {"tag": "int", "negative": False, "magnitude": "1", "suffix": None, "extra": True}, "c" |
| 407 | ) |
| 408 | self.assertTrue(any("exactly" in str(v) for v in violations)) |
| 409 | |
| 410 | |
| 411 | class LiveGateTests(unittest.TestCase): |
| 412 | def test_real_topology_passes_live_gate(self) -> None: |
| 413 | doc = mod.load_topology() |
| 414 | self.assertEqual(mod.validate_topology_document(doc), []) |
| 415 | |
| 416 | def test_topology_artifact_is_sorted_unique(self) -> None: |
| 417 | doc = mod.load_topology() |
| 418 | frontier = doc["frontier"] |
| 419 | self.assertEqual(frontier, sorted(frontier)) |
| 420 | self.assertEqual(len(frontier), len(set(frontier))) |
| 421 | # FEAT-018 removed utility, FEAT-019 removed memory, FEAT-020 removed plugins, |
| 422 | # FEAT-021 removed project, and FEAT-022 removed skills; four groups stay pending. |
| 423 | self.assertEqual( |
| 424 | set(frontier), |
| 425 | {"session", "config", "debug", "core"}, |
| 426 | ) |
| 427 | |
| 428 | |
| 429 | class SourceScanTests(unittest.TestCase): |
| 430 | """Hermetic fixtures for the AST-resolved source scan (Task 3.5/3.6).""" |
| 431 | |
| 432 | def _write_group(self, tmpdir: Path, group: str, files: dict[str, str]) -> Path: |
| 433 | """Write a synthetic group tree under a temp groups root.""" |
| 434 | base = tmpdir / group |
| 435 | base.mkdir(parents=True, exist_ok=True) |
| 436 | for name, content in files.items(): |
| 437 | (base / name).write_text(content, encoding="utf-8") |
| 438 | return tmpdir |
| 439 | |
| 440 | def test_parse_finds_concrete_app_free_fn(self) -> None: |
| 441 | source = ( |
| 442 | "use crate::tui::app::App;\n" |
| 443 | "pub fn run_config(app: &mut App, arg: Option<&str>) -> CommandResult {\n" |
| 444 | " CommandResult::ok()\n" |
| 445 | "}\n" |
| 446 | ) |
| 447 | import tempfile |
| 448 | with tempfile.TemporaryDirectory() as d: |
| 449 | root = Path(d) |
| 450 | (root / "config").mkdir() |
| 451 | (root / "config" / "mod.rs").write_text(source, encoding="utf-8") |
| 452 | items = mod.parse_rust_file(root / "config" / "mod.rs", root) |
| 453 | apps = [it for it in items if it.is_concrete_app] |
| 454 | self.assertEqual(len(apps), 1) |
| 455 | self.assertEqual(apps[0].kind, "free") |
| 456 | self.assertEqual(apps[0].qual_path, "crate::commands::groups::config::run_config") |
| 457 | |
| 458 | def test_parse_ignores_non_app_fns(self) -> None: |
| 459 | source = ( |
| 460 | "fn helper(value: u32) -> u32 { value }\n" |
| 461 | "fn run(app: &mut crate::tui::app::App, arg: Option<&str>) -> CommandResult {\n" |
| 462 | " CommandResult::ok()\n" |
| 463 | "}\n" |
| 464 | ) |
| 465 | import tempfile |
| 466 | with tempfile.TemporaryDirectory() as d: |
| 467 | root = Path(d) |
| 468 | (root / "core").mkdir() |
| 469 | (root / "core" / "mod.rs").write_text(source, encoding="utf-8") |
| 470 | items = mod.parse_rust_file(root / "core" / "mod.rs", root) |
| 471 | self.assertEqual(sum(1 for it in items if it.is_concrete_app), 1) |
| 472 | |
| 473 | def test_parse_finds_trait_impl_and_inherent_methods(self) -> None: |
| 474 | source = ( |
| 475 | "pub struct BranchCmd;\n" |
| 476 | "impl RegisterCommand for BranchCmd {\n" |
| 477 | " fn info() -> &'static CommandInfo { &INFO }\n" |
| 478 | " fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {\n" |
| 479 | " branch(app, arg)\n" |
| 480 | " }\n" |
| 481 | "}\n" |
| 482 | "impl BranchCmd {\n" |
| 483 | " pub fn helper(&self) -> u32 { 1 }\n" |
| 484 | "}\n" |
| 485 | ) |
| 486 | import tempfile |
| 487 | with tempfile.TemporaryDirectory() as d: |
| 488 | root = Path(d) |
| 489 | (root / "session").mkdir() |
| 490 | (root / "session" / "mod.rs").write_text(source, encoding="utf-8") |
| 491 | items = mod.parse_rust_file(root / "session" / "mod.rs", root) |
| 492 | trait_exec = [it for it in items if it.kind == "trait_impl" and it.name == "execute"] |
| 493 | self.assertEqual(len(trait_exec), 1) |
| 494 | self.assertTrue(trait_exec[0].is_concrete_app) |
| 495 | inherent = [it for it in items if it.kind == "inherent"] |
| 496 | self.assertEqual(len(inherent), 1) |
| 497 | self.assertEqual(inherent[0].name, "helper") |
| 498 | self.assertFalse(inherent[0].is_concrete_app) |
| 499 | |
| 500 | def test_scope_file_missing_fails(self) -> None: |
| 501 | violations = mod.scan_leaf_handlers(["crates/tui/src/commands/groups/core/ghost.rs"], Path("/nonexistent"))[1] |
| 502 | self.assertTrue(any("missing" in str(v) for v in violations)) |
| 503 | |
| 504 | def test_frontier_matches_group_source(self) -> None: |
| 505 | doc = sample_topology() |
| 506 | # utility scope has one concrete-App handler; session scope has one. |
| 507 | doc["topology"]["utility"]["scope"] = ["utility/mod.rs"] |
| 508 | doc["topology"]["session"]["scope"] = ["session/mod.rs"] |
| 509 | import tempfile |
| 510 | with tempfile.TemporaryDirectory() as d: |
| 511 | root = Path(d) |
| 512 | (root / "utility").mkdir(parents=True) |
| 513 | (root / "session").mkdir(parents=True) |
| 514 | (root / "utility" / "mod.rs").write_text( |
| 515 | "use crate::tui::app::App;\n" |
| 516 | "fn run_util(app: &mut App, arg: Option<&str>) -> CommandResult { CommandResult::ok() }\n", |
| 517 | encoding="utf-8", |
| 518 | ) |
| 519 | (root / "session" / "mod.rs").write_text( |
| 520 | "use crate::tui::app::App;\n" |
| 521 | "fn run_save(app: &mut App, arg: Option<&str>) -> CommandResult { CommandResult::ok() }\n", |
| 522 | encoding="utf-8", |
| 523 | ) |
| 524 | violations = mod.check_source_frontier(doc["topology"], doc["frontier"], root) |
| 525 | self.assertEqual(violations, []) |
| 526 | |
| 527 | def test_cheating_removal_fails(self) -> None: |
| 528 | doc = sample_topology() |
| 529 | doc["topology"]["utility"]["scope"] = ["utility/mod.rs"] |
| 530 | doc["topology"]["session"]["scope"] = ["session/mod.rs"] |
| 531 | import tempfile |
| 532 | with tempfile.TemporaryDirectory() as d: |
| 533 | root = Path(d) |
| 534 | (root / "utility").mkdir(parents=True) |
| 535 | (root / "session").mkdir(parents=True) |
| 536 | (root / "utility" / "mod.rs").write_text( |
| 537 | "use crate::tui::app::App;\n" |
| 538 | "fn run_util(app: &mut App, arg: Option<&str>) -> CommandResult { CommandResult::ok() }\n", |
| 539 | encoding="utf-8", |
| 540 | ) |
| 541 | (root / "session" / "mod.rs").write_text( |
| 542 | "use crate::tui::app::App;\n" |
| 543 | "fn run_save(app: &mut App, arg: Option<&str>) -> CommandResult { CommandResult::ok() }\n", |
| 544 | encoding="utf-8", |
| 545 | ) |
| 546 | # Cheat: remove utility from the frontier while its handler remains. |
| 547 | violations = mod.check_source_frontier(doc["topology"], ["session"], root) |
| 548 | self.assertTrue(any("stale-removal" in str(v) for v in violations)) |
| 549 | |
| 550 | def test_stale_frontier_entry_fails(self) -> None: |
| 551 | doc = sample_topology() |
| 552 | doc["topology"]["utility"]["scope"] = ["utility/mod.rs"] |
| 553 | doc["topology"]["session"]["scope"] = ["session/mod.rs"] |
| 554 | import tempfile |
| 555 | with tempfile.TemporaryDirectory() as d: |
| 556 | root = Path(d) |
| 557 | (root / "utility").mkdir(parents=True) |
| 558 | (root / "session").mkdir(parents=True) |
| 559 | # utility has NO concrete-App handler (migrated: uses a context) |
| 560 | (root / "utility" / "mod.rs").write_text( |
| 561 | "fn run_util(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { CommandResult::ok() }\n", |
| 562 | encoding="utf-8", |
| 563 | ) |
| 564 | (root / "session" / "mod.rs").write_text( |
| 565 | "use crate::tui::app::App;\n" |
| 566 | "fn run_save(app: &mut App, arg: Option<&str>) -> CommandResult { CommandResult::ok() }\n", |
| 567 | encoding="utf-8", |
| 568 | ) |
| 569 | violations = mod.check_source_frontier(doc["topology"], ["session", "utility"], root) |
| 570 | self.assertTrue(any("stale-entry" in str(v) for v in violations)) |
| 571 | |
| 572 | def test_retained_host_exempts_declared_machinery_from_stale_removal(self) -> None: |
| 573 | """A migrated group may declare dispatcher host machinery (FEAT-042) |
| 574 | that keeps `&mut App`; the gate exempts it and flags the rest.""" |
| 575 | topology = { |
| 576 | "alpha": { |
| 577 | "kind": "group", |
| 578 | "scope": ["alpha/mod.rs"], |
| 579 | "slices": [], |
| 580 | } |
| 581 | } |
| 582 | frontier: list[str] = [] |
| 583 | import tempfile |
| 584 | with tempfile.TemporaryDirectory() as d: |
| 585 | root = Path(d) |
| 586 | (root / "alpha").mkdir(parents=True, exist_ok=True) |
| 587 | (root / "alpha" / "mod.rs").write_text( |
| 588 | "use crate::tui::app::App;\n" |
| 589 | "fn retained(app: &mut App, arg: Option<&str>) {} \n" |
| 590 | "fn stale(app: &mut App, arg: Option<&str>) {} \n", |
| 591 | encoding="utf-8", |
| 592 | ) |
| 593 | # stub RETAINED_HOST_MACHINERY for the hermetic group |
| 594 | original = mod.RETAINED_HOST_MACHINERY |
| 595 | try: |
| 596 | mod.RETAINED_HOST_MACHINERY = { |
| 597 | "alpha": [ |
| 598 | {"kind": "free", "item": ["crate", "commands", "groups", "alpha", "retained"]} |
| 599 | ] |
| 600 | } |
| 601 | violations = mod.check_source_frontier(topology, frontier, root) |
| 602 | finally: |
| 603 | mod.RETAINED_HOST_MACHINERY = original |
| 604 | kinds = [v.category for v in violations] |
| 605 | self.assertNotIn("retained-host", kinds) |
| 606 | self.assertEqual(kinds.count("stale-removal"), 1, violations) |
| 607 | |
| 608 | def test_retained_host_fails_closed_when_signature_lost(self) -> None: |
| 609 | """If retained machinery loses its concrete-App signature, the gate fails.""" |
| 610 | topology = { |
| 611 | "alpha": { |
| 612 | "kind": "group", |
| 613 | "scope": ["alpha/mod.rs"], |
| 614 | "slices": [], |
| 615 | } |
| 616 | } |
| 617 | frontier: list[str] = [] |
| 618 | import tempfile |
| 619 | with tempfile.TemporaryDirectory() as d: |
| 620 | root = Path(d) |
| 621 | (root / "alpha").mkdir(parents=True, exist_ok=True) |
| 622 | (root / "alpha" / "mod.rs").write_text( |
| 623 | "fn retained(arg: Option<&str>) {} \n", |
| 624 | encoding="utf-8", |
| 625 | ) |
| 626 | original = mod.RETAINED_HOST_MACHINERY |
| 627 | try: |
| 628 | mod.RETAINED_HOST_MACHINERY = { |
| 629 | "alpha": [ |
| 630 | {"kind": "free", "item": ["crate", "commands", "groups", "alpha", "retained"]} |
| 631 | ] |
| 632 | } |
| 633 | violations = mod.check_source_frontier(topology, frontier, root) |
| 634 | finally: |
| 635 | mod.RETAINED_HOST_MACHINERY = original |
| 636 | self.assertTrue( |
| 637 | any(v.category == "retained-host" for v in violations), |
| 638 | f"expected retained-host violation, got {violations}", |
| 639 | ) |
| 640 | |
| 641 | def test_live_source_gate_passes(self) -> None: |
| 642 | doc = mod.load_topology() |
| 643 | violations = mod.check_source_frontier(doc["topology"], doc["frontier"]) |
| 644 | self.assertEqual(violations, []) |
| 645 | |
| 646 | |
| 647 | if __name__ == "__main__": |
| 648 | unittest.main() |
| 649 |