diff --git a/platform-integrations/install.sh b/platform-integrations/install.sh index c09db6c2..67dbb67d 100755 --- a/platform-integrations/install.sh +++ b/platform-integrations/install.sh @@ -342,7 +342,6 @@ class FileOps: mode_block = "\n".join(mode_lines).strip() start = _sentinel_start(slug) end = _sentinel_end(slug) - block = f"\n{start}\n {mode_block.replace(chr(10), chr(10) + ' ')}\n{end}\n" try: with open(target_yaml_path) as f: @@ -353,9 +352,34 @@ class FileOps: if not existing.strip() or "customModes:" not in existing: existing = "customModes:\n" - if start in existing: - pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL) - new_content = pattern.sub(block.strip(), existing) + # Match the list-item indentation already used under `customModes:` so the + # inserted block doesn't mix 0-indent and 2-indent sequence items (which is + # invalid YAML). The source uses 2-space items; a target written by + # yaml.safe_dump (Bob/marketplace tooling) may use 0-space. Detect and match. + item_indent = " " + seen_modes = False + for ln in existing.splitlines(): + if ln.strip() == "customModes:": + seen_modes = True + continue + if seen_modes and ln.lstrip().startswith("- "): + item_indent = ln[: len(ln) - len(ln.lstrip())] + break + block_body = "\n".join(item_indent + ln if ln else ln for ln in mode_block.split("\n")) + block = f"\n{start}\n{block_body}\n{end}\n" + + # Match a *real* sentinel block only: the start and end markers must each + # sit at the beginning of a line. A bare sentinel substring inside another + # mode's quoted scalar (e.g. the install-evolve-lite mode documents the + # literal `# >>>evolve:evolve-lite<<<` in its customInstructions) must NOT + # be treated as an existing block — otherwise the replace finds no matching + # end, no-ops, and the merge is silently dropped while still reporting ✓. + block_re = re.compile( + r"^[ \t]*" + re.escape(start) + r".*?^[ \t]*" + re.escape(end) + r"[^\n]*$", + re.DOTALL | re.MULTILINE, + ) + if block_re.search(existing): + new_content = block_re.sub(lambda _m: block.strip(), existing) else: new_content = existing.rstrip() + block @@ -370,9 +394,11 @@ class FileOps: text = f.read() start = _sentinel_start(slug) end = _sentinel_end(slug) + # Line-anchored so a sentinel literal mentioned inside another mode's + # quoted text is never mistaken for a real block (see merge above). pattern = re.compile( - r"\n?" + re.escape(start) + r".*?" + re.escape(end) + r"\n?", - re.DOTALL + r"^[ \t]*" + re.escape(start) + r".*?" + re.escape(end) + r"[^\n]*$\n?", + re.DOTALL | re.MULTILINE, ) self.atomic_write_text(target_yaml_path, pattern.sub("", text)) diff --git a/tests/platform_integrations/test_idempotency.py b/tests/platform_integrations/test_idempotency.py index 8ca06f78..02ebbb83 100644 --- a/tests/platform_integrations/test_idempotency.py +++ b/tests/platform_integrations/test_idempotency.py @@ -3,6 +3,7 @@ """ import json +import re import pytest @@ -140,6 +141,47 @@ def test_uninstall_removes_namespaced_shared_lib(self, temp_project_dir, install file_assertions.assert_dir_not_exists(bob_dir / "lib" / "evolve-lite") + def test_install_merges_mode_despite_sentinel_literal_in_another_mode(self, temp_project_dir, install_runner, file_assertions): + """A sentinel literal quoted inside another mode's text must not block the merge. + + Regression: the install-evolve-lite marketplace mode documents the literal + `# >>>evolve:evolve-lite<<<` in its customInstructions. A naive `if start in + existing` substring check treated that as an existing block, took the replace + branch, found no matching end sentinel, and silently dropped the merge while + still reporting success. The sentinel match must be line-anchored. + """ + bob_dir = temp_project_dir / ".bob" + modes_file = bob_dir / "custom_modes.yaml" + modes_file.parent.mkdir(parents=True, exist_ok=True) + # Reproduce the exact user failure: a 0-indent list (as yaml.safe_dump / + # Bob marketplace tooling writes it) whose quoted text mentions the + # sentinel literal. This trips BOTH the substring false-match and the + # 0-indent-vs-2-indent mismatch. + modes_file.write_text( + "customModes:\n" + "- slug: install-evolve-lite\n" + " name: Install Evolve Lite\n" + ' customInstructions: "Merged between # >>>evolve:evolve-lite<<< sentinel comments."\n' + " groups:\n" + " - read\n" + ) + + install_runner.run("install", platform="bob", mode="lite") + + content = modes_file.read_text() + # The evolve-lite mode was actually merged in (real sentinel block written). + assert "# >>>evolve:evolve-lite<<<" in content + + # All top-level list items share one indentation — a 0-indent/2-indent mix + # would be invalid YAML (the indentation-matching fix). + indents = set(re.findall(r"(?m)^([ \t]*)- slug:", content)) + assert len(indents) == 1, f"mixed custom-mode list indentation: {indents}" + + slugs = re.findall(r"(?m)^[ \t]*- slug:\s*(\S+)", content) + assert "evolve-lite" in slugs, f"evolve-lite mode not merged; slugs={slugs}" + # ...and the pre-existing mode is preserved. + assert "install-evolve-lite" in slugs + def test_install_preserves_user_content_during_legacy_purge(self, temp_project_dir, install_runner, bob_fixtures, file_assertions): """The legacy purge MUST NOT clobber non-evolve user skills/commands.""" bob_dir = temp_project_dir / ".bob"