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) 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 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