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:
safishamsi
2026-07-21 12:40:49 +01:00
parent abff1b1ca4
commit b96effd3aa
2 changed files with 115 additions and 33 deletions
+56 -33
View File
@@ -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()}")
+59
View File
@@ -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 ---