From c325aad3b06797a70897f65a746a2268d0785e3c Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 22 Sep 2026 14:58:22 +0530 Subject: [PATCH 1/3] Fix CRLF corruption and a bare substring idempotency guard in install _register_always_on_block had three problems, all only visible on the claude and codebuddy paths that call it. Path.write_text opens in text mode, so on Windows appending a few lines to an existing CLAUDE.md rewrote the WHOLE file to CRLF, turning a one line registration into an eighty five line diff for anyone who keeps that file in a dotfiles repo or syncs it across platforms. Separately, the re install guard was a bare check for the word graphify anywhere in the file, so a user whose file mentioned the project for any other reason got silently treated as already registered and never received the real block, and an edited or stale block could never be repaired by re running install since the word was still present either way. Switch the write to pass newline equals empty string so existing line endings are never translated, and switch the guard to _replace_or_append_section, the same marker anchored helper claude_install and gemini_install already use, so a stale block is correctly refreshed instead of silently skipped. That helper's boundary detection was hardcoded to the next H2 heading, which does not fit this block's own heading level, so it gains a boundary_prefix parameter mirroring the one _remove_marker_section already has, kept in sync with a new shared marker constant so the paired removal function matches on exactly the same heading. Deliberately kept at H1 rather than switched to the H2 markers the issue itself suggested, since the existing removal function anchors on H1 specifically to never collide with a genuine user authored H2 heading elsewhere in the same file, a design choice from a separate earlier issue that a plain heading level change would have quietly broken. gemini_install writes GEMINI.md through the exact same text mode write, so it gets the same newline fix. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/install.py | 79 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/graphify/install.py b/graphify/install.py index 2fb2192760..0001daa4dd 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -299,11 +299,16 @@ def _remove_claude_skill_registration(project_dir: Path) -> None: content = claude_md.read_text(encoding="utf-8") # 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="# ") + # _SKILL_REGISTRATION_MARKER is the single source of truth for this heading, + # shared with _register_always_on_block so an insert and its removal always + # agree on what they're matching (#3668). + cleaned = _remove_marker_section(content, _SKILL_REGISTRATION_MARKER, boundary_prefix="# ") if cleaned is None: return if cleaned: - claude_md.write_text(cleaned + "\n", encoding="utf-8") + # newline="" so the rest of the file's own line endings are never + # translated on write (#3668, same CRLF issue as the insert side). + claude_md.write_text(cleaned + "\n", encoding="utf-8", newline="") print(f" CLAUDE.md -> graphify skill registration removed from {claude_md}") else: claude_md.unlink() @@ -358,15 +363,30 @@ def _claude_pretooluse_hooks(strict: bool = False, project: bool = False) -> "li "hooks": [{"type": "command", "command": read_cmd, "timeout": 10}]}, ] def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str: + # Heading is "# graphify" (H1) to match _SKILL_REGISTRATION_MARKER, which + # _register_always_on_block anchors its idempotent replace-or-append on. return ( - "\n# graphify\n" + "# graphify\n" f"- **graphify** (`{skill_path}`) " "- any input to knowledge graph. Trigger: `/graphify`\n" "When the user types `/graphify`, use the installed graphify skill " "or instructions before doing anything else.\n" ) def _register_always_on_block(target: Path, prefix: str, registration: str) -> None: - """Append an always-on registration to *target*, degrading instead of raising. + """Idempotently add or refresh an always-on registration in *target*, degrading + instead of raising. + + Uses _replace_or_append_section (the same marker-anchored helper claude_install + and gemini_install already use, here with the H1 boundary_prefix so it stays + paired with _remove_claude_skill_registration's own H1 match) rather than a + bare append, so a stale or edited block gets refreshed on re-install instead + of the bare "graphify" substring check silently treating any unrelated + mention of the word as already-registered and skipping every re-run (#3668). + + Written with newline="" so an existing file's own line endings are never + translated -- Path.write_text otherwise opens in text mode, which on Windows + turns the WHOLE file's pre-existing bare-LF content into CRLF just to append a + few lines (#3668). The skill files are copied before this runs, so a *target* that cannot be read or written must not abort an otherwise-complete install (#3474). That @@ -375,16 +395,19 @@ def _register_always_on_block(target: Path, prefix: str, registration: str) -> N stow with read-only sources leave the same shape. """ try: - if target.exists(): - content = target.read_text(encoding="utf-8") - if "graphify" in content: - print(f"{prefix}already registered (no change)") - else: - target.write_text(content.rstrip() + registration, encoding="utf-8") - print(f"{prefix}skill registered in {target}") + existed = target.exists() + content = target.read_text(encoding="utf-8") if existed else "" + new_content = _replace_or_append_section( + content, _SKILL_REGISTRATION_MARKER, registration, boundary_prefix="# " + ) + if existed and new_content == content: + print(f"{prefix}already registered (no change)") + elif existed: + target.write_text(new_content, encoding="utf-8", newline="") + print(f"{prefix}skill registered in {target}") else: target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(registration.lstrip(), encoding="utf-8") + target.write_text(new_content, encoding="utf-8", newline="") print(f"{prefix}created at {target}") except OSError as exc: print(f"{prefix}skipped: {exc.__class__.__name__}: {exc}", file=sys.stderr) @@ -542,7 +565,9 @@ def _register_always_on_block(target: Path, prefix: str, registration: str) -> N def _canonical_platform(platform_name: str) -> str: """Resolve a CLI platform alias to its real _PLATFORM_CONFIG key.""" return _PLATFORM_ALIASES.get(platform_name, platform_name) -def _replace_or_append_section(content: str, marker: str, new_section: str) -> str: +def _replace_or_append_section( + content: str, marker: str, new_section: str, boundary_prefix: str = "## " +) -> str: """Idempotently update or append a graphify-owned section in shared files. If no line is exactly ``marker`` (the heading, at column 0), append @@ -550,10 +575,15 @@ def _replace_or_append_section(content: str, marker: str, new_section: str) -> s content). If a real ``marker`` heading exists, replace the existing section in place. - The section runs from that heading to the line before the next H2 heading - (``## `` at line start), or to EOF if no later H2 exists. This lets older - installs receive the updated copy without users having to uninstall and - reinstall (issue #580). + The section runs from that heading to the line before the next + ``boundary_prefix`` heading (default the next H2), or to EOF if none + follows. This lets older installs receive the updated copy without users + having to uninstall and reinstall (issue #580). + + ``boundary_prefix`` must match whatever level ``marker`` itself is (``"# "`` + for an H1 marker, the default ``"## "`` for an H2 one) — mirrors + ``_remove_marker_section``'s own ``boundary_prefix`` so an insert and its + matching removal agree on where a section ends (#3668). The heading is matched only when a line *is* exactly ``marker`` (after stripping surrounding whitespace), never as a substring. Matching ``## @@ -572,7 +602,7 @@ def _replace_or_append_section(content: str, marker: str, new_section: str) -> s start = starts[-1] end = len(lines) for j in range(start + 1, len(lines)): - if lines[j].startswith("## "): + if lines[j].startswith(boundary_prefix): end = j break @@ -746,6 +776,14 @@ def _print_install_usage() -> None: _CODEBUDDY_MD_MARKER = "## graphify" _AGENTS_MD_MARKER = "## graphify" _GEMINI_MD_MARKER = "## graphify" +# Deliberately H1, not H2 like the markers above: this one anchors the SKILL +# registration block _register_always_on_block writes into .claude/CLAUDE.md +# (or CODEBUDDY.md), a different section from the "always-on instructions" +# block the H2 markers above anchor. Kept at H1 specifically so it can never +# collide with a genuine user-authored "## graphify" heading elsewhere in the +# same file (#2062) — _remove_claude_skill_registration matches on this same +# constant so the two stay in sync. +_SKILL_REGISTRATION_MARKER = "# graphify" def _gemini_hook(project: bool = False) -> dict: """Gemini CLI BeforeTool hook, resolved to a shell-agnostic `graphify` call. @@ -777,7 +815,10 @@ def gemini_install(project_dir: Path | None = None, *, project: bool = False) -> if target.exists() and new_content == target.read_text(encoding="utf-8"): print(f"graphify already configured in {target.resolve()} (no change)") else: - target.write_text(new_content, encoding="utf-8") + # newline="" so an existing file's own line endings are never translated + # (Path.write_text otherwise opens in text mode, which on Windows turns + # the WHOLE file's pre-existing bare-LF content into CRLF, #3668). + target.write_text(new_content, encoding="utf-8", newline="") print(f"graphify section written to {target.resolve()}") # Always re-install the Gemini hook so an older payload (e.g. pre-issue-#580 From ddf2497f6834e33dfa42c2a92262af339132737c Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 22 Sep 2026 14:58:29 +0530 Subject: [PATCH 2/3] Add regression tests for the install CRLF and idempotency guard fix Cover an unrelated mention of the word graphify no longer suppressing the real registration block, a stale or hand edited block correctly getting refreshed on re install instead of being left alone, and both the CLAUDE.md and GEMINI.md writers now passing newline equals empty string so an existing file's own line endings are never translated on write. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_install.py | 115 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/test_install.py b/tests/test_install.py index 72d4e4dd26..8caa61a6c4 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -199,6 +199,91 @@ def test_install_claude_md_success_output_unchanged(tmp_path, monkeypatch, capsy assert " CLAUDE.md -> already registered (no change)" in second +def test_install_claude_md_does_not_skip_on_an_unrelated_mention_of_the_word(tmp_path, monkeypatch): + """#3668: the idempotency guard used to be a bare `"graphify" in content` + substring check, so any pre-existing mention of the word anywhere in the + file (a note to self, an unrelated project instruction) was wrongly + treated as "already registered" and the real block never got written.""" + from graphify.__main__ import install + + home = tmp_path / "home" + home.mkdir() + claude_md = home / ".claude" / "CLAUDE.md" + claude_md.parent.mkdir(parents=True) + claude_md.write_text("See https://github.com/Graphify-Labs/graphify for details.\n", encoding="utf-8") + + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.chdir(tmp_path) + with patch("graphify.__main__.Path.home", return_value=home): + install(platform="claude") + + content = claude_md.read_text(encoding="utf-8") + assert "# graphify\n" in content, ( + f"an unrelated mention of the word must not suppress the real " + f"registration block; got {content!r}" + ) + assert "See https://github.com/Graphify-Labs/graphify for details." in content, ( + "the user's own pre-existing content must survive" + ) + + +def test_install_claude_md_refreshes_a_stale_registration_block(tmp_path, monkeypatch): + """#3668: a previously-installed block that has since been hand-edited (or + predates a skill-path change) must be refreshed on re-install, not + silently left stale because the bare word "graphify" is still present.""" + from graphify.__main__ import install + + home = tmp_path / "home" + home.mkdir() + claude_md = home / ".claude" / "CLAUDE.md" + claude_md.parent.mkdir(parents=True) + claude_md.write_text( + "# graphify\n- an old, hand-edited line that does not match the " + "current registration text\n", + encoding="utf-8", + ) + + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.chdir(tmp_path) + with patch("graphify.__main__.Path.home", return_value=home): + install(platform="claude") + + content = claude_md.read_text(encoding="utf-8") + assert "hand-edited line" not in content, "the stale block must be replaced, not kept" + assert "Trigger: `/graphify`" in content, "the current registration text must be written" + + +def test_register_always_on_block_writes_without_newline_translation(tmp_path, monkeypatch): + """#3668: Path.write_text opens in text mode, which on Windows turns a + pre-existing bare-LF file's WHOLE content into CRLF just to append a few + lines. newline="" must be passed so no translation happens. The bug + itself is only observable on Windows, so this checks the call was made + correctly rather than depending on the host OS's own newline handling.""" + from graphify import install as install_mod + + target = tmp_path / "CLAUDE.md" + target.write_text("Some existing notes.\n", encoding="utf-8") + + calls: list[dict] = [] + orig_write_text = Path.write_text + + def _tracking_write_text(self, *args, **kwargs): + calls.append(kwargs) + return orig_write_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "write_text", _tracking_write_text) + + install_mod._register_always_on_block( + target, " CLAUDE.md -> ", install_mod._skill_registration() + ) + + assert calls, "write_text should have been called" + assert calls[-1].get("newline") == "", ( + f"write_text must pass newline='' so the rest of the file's line " + f"endings are never translated; got kwargs {calls[-1]!r}" + ) + + def test_install_codebuddy(tmp_path): _install(tmp_path, "codebuddy") assert (tmp_path / ".codebuddy" / "skills" / "graphify" / "SKILL.md").exists() @@ -1130,6 +1215,36 @@ def test_gemini_install_merges_existing_gemini_md(tmp_path): assert "graphify-out/GRAPH_REPORT.md" in content +def test_gemini_install_writes_gemini_md_without_newline_translation(tmp_path, monkeypatch): + """#3668: same CRLF issue as _register_always_on_block, here in the + GEMINI.md write. Path.write_text opens in text mode, which on Windows + would turn a pre-existing bare-LF GEMINI.md's WHOLE content into CRLF + just to merge in a few lines. Checks the call was made correctly rather + than depending on the host OS's own newline handling.""" + from graphify.__main__ import gemini_install + + gemini_md = tmp_path / "GEMINI.md" + gemini_md.write_text("# My project rules\n", encoding="utf-8") + + calls: list[dict] = [] + orig_write_text = Path.write_text + + def _tracking_write_text(self, *args, **kwargs): + if self == gemini_md: + calls.append(kwargs) + return orig_write_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "write_text", _tracking_write_text) + + gemini_install(tmp_path) + + assert calls, "write_text should have been called for GEMINI.md" + assert calls[-1].get("newline") == "", ( + f"write_text must pass newline='' so the rest of the file's line " + f"endings are never translated; got kwargs {calls[-1]!r}" + ) + + def test_gemini_uninstall_removes_section(tmp_path): from graphify.__main__ import gemini_install, gemini_uninstall From e984b93be7e76cd36b16fab09291ece54c996125 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 22 Sep 2026 14:58:42 +0530 Subject: [PATCH 3/3] Add changelog entry for issue 3668 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3e8767dc..a245b7ccee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: the incremental rebuild no longer purges AST nodes it just reported as fail-closed "kept" — the eviction pass re-checks the kept set, so a moved-file/symlink layout can't deadlock the shrink guard into refusing every update (#3697, #3695, thanks @hopstreax). - Fix: `graph.html` no longer crashes vis-network with a stack overflow on large graphs — nodes are seeded on a spiral before physics runs so overlap-avoidance can't blow the layout recursion (#3699, thanks @sanjaiyan-dev). - Fix: node and edge tooltips now show special characters literally (C++ templates like `vector`, generics, `&`, quotes) instead of raw HTML entities, while the HTML sinks that need escaping keep it (#3686, #3664, thanks @hopstreax). +- Fix: `graphify install` no longer rewrites an existing `CLAUDE.md`/`GEMINI.md` to CRLF on Windows just to append its registration block, and the re-install guard now matches the actual heading it writes instead of a bare `"graphify"` substring check — a file that merely mentions the project elsewhere is no longer treated as already registered, and a stale or hand-edited block is now refreshed on re-install rather than silently left alone (#3668, thanks @kevinishii-spec). - Docs: repository links now point at `Graphify-Labs/graphify` instead of the old account (including in generated wiki output), translated READMEs use the current logo, GitHub issue/PR templates were added, and the Enterprise link was corrected (#3692, #3694, #3693, thanks @Abdul535). ## 0.9.64 (2026-09-18)