Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions platform-integrations/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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))

Expand Down
42 changes: 42 additions & 0 deletions tests/platform_integrations/test_idempotency.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import json
import re

import pytest

Expand Down Expand Up @@ -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"
Expand Down
Loading