fix(install): uninstall must not delete a user's ### graphify H3 section (#2062)
The uninstall strip used an unanchored regex `## graphify`, which matched inside a user's `### graphify` heading and deleted hand-written content; the `marker not in content` guard was a substring test that passed on the same mention. Add a shared `_remove_marker_section` helper that matches the heading only when a line is exactly the marker (mirroring the install-side #1688 hardening), running each section to the next same-level heading or EOF, and returning None (leave the file untouched) when no exact heading exists. Replace all six strip sites: GEMINI.md, copilot-instructions.md, AGENTS.md, CLAUDE.md (_strip_graphify_md_section), CODEBUDDY.md, and the H1 skill-registration (_remove_claude_skill_registration, which had the same bug with `# graphify`).
This commit is contained in:
+56
-33
@@ -263,9 +263,11 @@ def _remove_claude_skill_registration(project_dir: Path) -> None:
|
||||
if not claude_md.exists():
|
||||
return
|
||||
content = claude_md.read_text(encoding="utf-8")
|
||||
if "# graphify" not in content:
|
||||
# Match the exact H1 `# graphify` registration heading, never a substring of a
|
||||
# user's `## graphify`/`### graphify` (#2062). Section runs to the next H1.
|
||||
cleaned = _remove_marker_section(content, "# graphify", boundary_prefix="# ")
|
||||
if cleaned is None:
|
||||
return
|
||||
cleaned = re.sub(r"\n*# graphify\n.*?(?=\n# |\Z)", "", content, flags=re.DOTALL).rstrip()
|
||||
if cleaned:
|
||||
claude_md.write_text(cleaned + "\n", encoding="utf-8")
|
||||
print(f" CLAUDE.md -> graphify skill registration removed from {claude_md}")
|
||||
@@ -516,6 +518,46 @@ def _replace_or_append_section(content: str, marker: str, new_section: str) -> s
|
||||
if not out.endswith("\n"):
|
||||
out += "\n"
|
||||
return out
|
||||
|
||||
|
||||
def _remove_marker_section(content: str, marker: str, boundary_prefix: str = "## ") -> "str | None":
|
||||
"""Remove every section whose heading line is exactly ``marker``.
|
||||
|
||||
The heading is matched only when a line *is* exactly ``marker`` (after
|
||||
stripping surrounding whitespace), never as a substring. The old uninstall
|
||||
regex ``## graphify`` was unanchored, so it matched inside a user's
|
||||
``### graphify`` heading and deleted hand-written content (#2062) — the same
|
||||
class of bug the install side hardened against in #1688. Each section runs to
|
||||
the line before the next ``boundary_prefix`` heading (default the next H2) or
|
||||
EOF, mirroring ``_replace_or_append_section``. All exact-heading sections are
|
||||
removed (pre-#1688 installs could leave duplicates).
|
||||
|
||||
Returns None when no exact ``marker`` line exists — the caller must then leave
|
||||
the file untouched. This doubles as the guard: a substring mention (a bullet,
|
||||
an inline reference, a deeper ``###`` heading) never triggers a strip.
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
removed = False
|
||||
while True:
|
||||
starts = [i for i, line in enumerate(lines) if line.strip() == marker]
|
||||
if not starts:
|
||||
break
|
||||
start = starts[-1]
|
||||
end = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
if lines[j].startswith(boundary_prefix):
|
||||
end = j
|
||||
break
|
||||
head = "\n".join(lines[:start]).rstrip()
|
||||
tail = "\n".join(lines[end:]).lstrip()
|
||||
merged = head + "\n\n" + tail if head and tail else (head or tail)
|
||||
lines = merged.split("\n")
|
||||
removed = True
|
||||
if not removed:
|
||||
return None
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def _print_banner() -> None:
|
||||
"""Amber brain banner on graphify install. TTY-only, never raises."""
|
||||
if not sys.stdout.isatty():
|
||||
@@ -719,12 +761,10 @@ def gemini_uninstall(project_dir: Path | None = None, *, project: bool = False)
|
||||
print("No GEMINI.md found in current directory - nothing to do")
|
||||
return
|
||||
content = target.read_text(encoding="utf-8")
|
||||
if _GEMINI_MD_MARKER not in content:
|
||||
cleaned = _remove_marker_section(content, _GEMINI_MD_MARKER)
|
||||
if cleaned is None:
|
||||
print("graphify section not found in GEMINI.md - nothing to do")
|
||||
return
|
||||
cleaned = re.sub(
|
||||
r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL
|
||||
).rstrip()
|
||||
if cleaned:
|
||||
target.write_text(cleaned + "\n", encoding="utf-8")
|
||||
print(f"graphify section removed from {target.resolve()}")
|
||||
@@ -811,11 +851,9 @@ def vscode_uninstall(project_dir: Path | None = None) -> None:
|
||||
if not instructions.exists():
|
||||
return
|
||||
content = instructions.read_text(encoding="utf-8")
|
||||
if _VSCODE_INSTRUCTIONS_MARKER not in content:
|
||||
cleaned = _remove_marker_section(content, _VSCODE_INSTRUCTIONS_MARKER)
|
||||
if cleaned is None:
|
||||
return
|
||||
cleaned = re.sub(
|
||||
r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL
|
||||
).rstrip()
|
||||
if cleaned:
|
||||
instructions.write_text(cleaned + "\n", encoding="utf-8")
|
||||
print(f" graphify section removed from {instructions}")
|
||||
@@ -1541,7 +1579,8 @@ def _agents_uninstall(project_dir: Path, platform: str = "") -> None:
|
||||
return
|
||||
|
||||
content = target.read_text(encoding="utf-8")
|
||||
if _AGENTS_MD_MARKER not in content:
|
||||
cleaned = _remove_marker_section(content, _AGENTS_MD_MARKER)
|
||||
if cleaned is None:
|
||||
print("graphify section not found in AGENTS.md - nothing to do")
|
||||
if platform == "opencode":
|
||||
_uninstall_opencode_plugin(project_dir or Path("."))
|
||||
@@ -1549,12 +1588,6 @@ def _agents_uninstall(project_dir: Path, platform: str = "") -> None:
|
||||
_uninstall_kilo_plugin(project_dir or Path("."))
|
||||
return
|
||||
|
||||
cleaned = re.sub(
|
||||
r"\n*## graphify\n.*?(?=\n## |\Z)",
|
||||
"",
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
).rstrip()
|
||||
if cleaned:
|
||||
target.write_text(cleaned + "\n", encoding="utf-8")
|
||||
print(f"graphify section removed from {target.resolve()}")
|
||||
@@ -1766,15 +1799,11 @@ def _strip_graphify_md_section(target: Path) -> bool:
|
||||
# An unreadable/undecodable CLAUDE.md-style file (e.g. non-UTF-8, or a
|
||||
# directory of that name) must not abort uninstall - nothing to strip.
|
||||
return False
|
||||
if _CLAUDE_MD_MARKER not in content:
|
||||
# Remove graphify's ## graphify section (heading matched exactly, never as a
|
||||
# substring of a user's ### graphify) from the marker to the next H2 or EOF.
|
||||
cleaned = _remove_marker_section(content, _CLAUDE_MD_MARKER)
|
||||
if cleaned is None:
|
||||
return False
|
||||
# Remove the ## graphify section: from the marker to the next ## heading or EOF
|
||||
cleaned = re.sub(
|
||||
r"\n*## graphify\n.*?(?=\n## |\Z)",
|
||||
"",
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
).rstrip()
|
||||
if cleaned:
|
||||
target.write_text(cleaned + "\n", encoding="utf-8")
|
||||
print(f"graphify section removed from {target.resolve()}")
|
||||
@@ -1854,17 +1883,11 @@ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = Fals
|
||||
return
|
||||
|
||||
content = target.read_text(encoding="utf-8")
|
||||
if _CODEBUDDY_MD_MARKER not in content:
|
||||
cleaned = _remove_marker_section(content, _CODEBUDDY_MD_MARKER)
|
||||
if cleaned is None:
|
||||
print("graphify section not found in CODEBUDDY.md - nothing to do")
|
||||
return
|
||||
|
||||
# Remove the ## graphify section: from the marker to the next ## heading or EOF
|
||||
cleaned = re.sub(
|
||||
r"\n*## graphify\n.*?(?=\n## |\Z)",
|
||||
"",
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
).rstrip()
|
||||
if cleaned:
|
||||
target.write_text(cleaned + "\n", encoding="utf-8")
|
||||
print(f"graphify section removed from {target.resolve()}")
|
||||
|
||||
@@ -623,6 +623,65 @@ def test_agents_uninstall_no_op_when_not_installed(tmp_path, capsys):
|
||||
assert "nothing to do" in out
|
||||
|
||||
|
||||
def test_remove_marker_section_matches_exact_heading_only(tmp_path):
|
||||
"""#2062: the strip helper must match graphify's own `## graphify` heading
|
||||
exactly, never a substring inside a user's `### graphify` H3."""
|
||||
from graphify.install import _remove_marker_section
|
||||
m = "## graphify"
|
||||
|
||||
# Only a user H3 mention -> no exact marker line -> None (file left untouched).
|
||||
assert _remove_marker_section("# Doc\n\n### graphify\n\nmy notes\n", m) is None
|
||||
# An inline/bullet mention is likewise not a section.
|
||||
assert _remove_marker_section("see the ## graphify bullet\n", m) is None
|
||||
|
||||
# A real H2 section alongside a user H3: remove only the H2 section.
|
||||
content = "# Doc\n\n### graphify\n\nmy notes\n\n## graphify\n\ngraphify stuff\n"
|
||||
out = _remove_marker_section(content, m)
|
||||
assert out is not None
|
||||
assert "### graphify" in out and "my notes" in out
|
||||
assert not any(l.strip() == "## graphify" for l in out.splitlines())
|
||||
assert "graphify stuff" not in out
|
||||
|
||||
# The section runs to the next H2 (not stopping at a `###` inside it).
|
||||
c2 = "## graphify\n\nintro\n\n### sub\n\ninner\n\n## Keep\n\nkeep me\n"
|
||||
out2 = _remove_marker_section(c2, m)
|
||||
assert "## Keep" in out2 and "keep me" in out2
|
||||
assert "inner" not in out2 and "intro" not in out2
|
||||
|
||||
|
||||
def test_agents_uninstall_preserves_user_h3_graphify_heading(tmp_path):
|
||||
"""#2062 end-to-end: uninstall strips graphify's own H2 section but leaves a
|
||||
user-authored `### graphify` H3 (and everything else) byte-intact."""
|
||||
agents_md = tmp_path / "AGENTS.md"
|
||||
agents_md.write_text(
|
||||
"# My rules\n\n"
|
||||
"### graphify\n\n"
|
||||
"My own notes on how I use graphify. Keep this.\n\n"
|
||||
"## Other\n\nUnrelated content.\n"
|
||||
)
|
||||
_agents_install(tmp_path, "codex") # appends a genuine `## graphify` H2 section
|
||||
assert "## graphify" in agents_md.read_text()
|
||||
|
||||
_agents_uninstall(tmp_path)
|
||||
content = agents_md.read_text()
|
||||
assert "### graphify" in content, "user's H3 heading was deleted (#2062)"
|
||||
assert "My own notes on how I use graphify. Keep this." in content
|
||||
assert "## Other" in content and "Unrelated content." in content
|
||||
assert not any(l.strip() == "## graphify" for l in content.splitlines())
|
||||
|
||||
|
||||
def test_uninstall_untouched_when_only_user_h3_present(tmp_path, capsys):
|
||||
"""#2062: a file with only a user `### graphify` H3 (graphify never installed)
|
||||
must be left byte-identical, not stripped."""
|
||||
agents_md = tmp_path / "AGENTS.md"
|
||||
original = "# My rules\n\n### graphify\n\nHand-written. Do not touch.\n"
|
||||
agents_md.write_text(original)
|
||||
before = agents_md.read_bytes()
|
||||
_agents_uninstall(tmp_path)
|
||||
assert agents_md.read_bytes() == before
|
||||
assert "nothing to do" in capsys.readouterr().out
|
||||
|
||||
|
||||
# --- OpenCode plugin tests ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user