Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>`, 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)
Expand Down
79 changes: 60 additions & 19 deletions graphify/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -542,18 +565,25 @@ 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
``new_section`` to the end (with a blank-line separator if there's existing
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 ``##
Expand All @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down
Loading