From b96effd3aa2bada960df77cc0e659e0276e84211 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Tue, 21 Jul 2026 12:40:49 +0100 Subject: [PATCH] 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`). --- graphify/install.py | 89 +++++++++++++++++++++++++++---------------- tests/test_install.py | 59 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 33 deletions(-) diff --git a/graphify/install.py b/graphify/install.py index e2b3a5c..1a8d3e3 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -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()}") diff --git a/tests/test_install.py b/tests/test_install.py index 97e08d1..ac4497c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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 ---