From 97a1371478e108fdfefe96cb581b0f3fd0d0393e Mon Sep 17 00:00:00 2001 From: safishamsi Date: Mon, 6 Jul 2026 12:36:12 +0100 Subject: [PATCH] Fix: exact-match section heading in _replace_or_append_section to prevent CLAUDE.md data loss (#1688) The updater located its managed block by substring (`marker in content` and `next(... if marker in line)`), so a heading that merely appeared as a substring of another line, or a duplicate heading, matched the wrong offset and the rewrite could truncate or drop unrelated content in CLAUDE.md / AGENTS.md. It now matches the section heading exactly (`line.strip() == marker`), appends when the section is absent, and prefers the last exact match when several exist. Thanks @bdfinst for the report. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++ graphify/__main__.py | 33 +++++++------ tests/test_replace_or_append_section.py | 62 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 tests/test_replace_or_append_section.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0bc82..9f4e67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Fix: `--update`-style section writes to `CLAUDE.md`/`AGENTS.md` no longer corrupt or drop content (#1688, thanks @bdfinst). `_replace_or_append_section` located its managed block by substring (`marker in content`) and `next(... if marker in line)`, so a heading that appeared as a substring of another line (or duplicate headings) matched the wrong offset and the rewrite could truncate the file. It now matches the section heading exactly (`line.strip() == marker`), appends when absent, and prefers the last exact match when several exist, so unrelated content is preserved. + ## 0.9.7 (2026-07-06) - Fix: Java standard-library types are no longer emitted as `references` noise (#1603, thanks @NydiaChung). A `_JAVA_BUILTIN_TYPES` skip list now suppresses ubiquitous `java.lang`/`java.util`/`java.io`/`java.time`/`java.math`/`java.nio.file` type names (`String`, `List`, `Map`, `Optional`, `Integer`, `Exception`, ...) at the type-ref walker; they never resolve to a project node, so edges to them were pure noise (mirrors `_GO_PREDECLARED_TYPES`/`_PYTHON_ANNOTATION_NOISE`). Nested user-type generic arguments still resolve: `List` drops the `List` edge but keeps `Item`. diff --git a/graphify/__main__.py b/graphify/__main__.py index e620d97..9367e57 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -658,26 +658,31 @@ def _canonical_platform(platform_name: str) -> str: def _replace_or_append_section(content: str, marker: str, new_section: str) -> str: """Idempotently update or append a graphify-owned section in shared files. - If ``marker`` is not in ``content``, append ``new_section`` to the end - (with a blank-line separator if there's existing content). + 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 ``marker`` IS in ``content``, replace the existing section in place. - The section runs from the first line containing ``marker`` 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 — important for the issue #580 - fix where existing report-first text would otherwise silently linger. + 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 heading is matched only when a line *is* exactly ``marker`` (after + stripping surrounding whitespace), never as a substring. Matching ``## + graphify`` inside a bullet or an inline reference used to anchor the replace + on that mention and delete every line from there to the next heading, + silently destroying hand-curated content (#1688). When several exact + headings exist, the last one is used, since graphify's section is appended. """ - if marker not in content: + lines = content.split("\n") + starts = [i for i, line in enumerate(lines) if line.strip() == marker] + if not starts: if content.strip(): return content.rstrip() + "\n\n" + new_section.lstrip() return new_section.lstrip() - lines = content.split("\n") - start = next((i for i, line in enumerate(lines) if marker in line), None) - if start is None: - return content.rstrip() + "\n\n" + new_section.lstrip() - + start = starts[-1] end = len(lines) for j in range(start + 1, len(lines)): if lines[j].startswith("## "): diff --git a/tests/test_replace_or_append_section.py b/tests/test_replace_or_append_section.py new file mode 100644 index 0000000..f098a00 --- /dev/null +++ b/tests/test_replace_or_append_section.py @@ -0,0 +1,62 @@ +"""#1688 - graphify's shared-file section update must not destroy user content. + +_replace_or_append_section used to locate its marker (`## graphify`) as a +substring, so a bullet or inline reference to the section became the replace +anchor and every line from there to the next heading was deleted. The marker is +now matched only as an exact heading line. +""" +from __future__ import annotations + +from graphify.__main__ import _replace_or_append_section + +MARKER = "## graphify" +NEW = "## graphify\n\nThis project has a knowledge graph at graphify-out/.\n" + + +def test_inline_reference_to_marker_is_not_treated_as_the_section(): + before = ( + "# My Project\n\n" + "## Setup\n" + "- See the `## graphify` section for graph usage.\n\n" + "## Release Process\n" + "Critical steps that must not be lost.\n" + ) + after = _replace_or_append_section(before, MARKER, NEW) + assert "See the `## graphify` section" in after # bullet preserved + assert "Critical steps that must not be lost" in after # later section preserved + assert "knowledge graph at graphify-out/" in after # section still added + + +def test_real_section_is_replaced_in_place(): + before = ( + "# P\n\n## Setup\n- do things\n\n" + "## graphify\n\nOLD text.\n\n" + "## Release\nkeep me\n" + ) + after = _replace_or_append_section(before, MARKER, NEW) + assert "OLD text." not in after + assert "knowledge graph at graphify-out/" in after + assert "do things" in after and "keep me" in after + + +def test_reinstall_is_idempotent(): + once = _replace_or_append_section("# P\n\n## Setup\n- x\n", MARKER, NEW) + twice = _replace_or_append_section(once, MARKER, NEW) + assert once.split("\n").count(MARKER) == 1 + assert twice.split("\n").count(MARKER) == 1 + + +def test_append_when_no_real_heading(): + before = "# P\n\n## Setup\n- x\n" + after = _replace_or_append_section(before, MARKER, NEW) + assert "- x" in after + assert after.split("\n").count(MARKER) == 1 + + +def test_prefers_last_heading_when_duplicated(): + before = "## graphify\nstale early copy\n\n## Other\nmid\n\n## graphify\nreal trailing copy\n" + after = _replace_or_append_section(before, MARKER, NEW) + # the trailing real section is replaced; the earlier stray heading + the + # user's "mid" content are left intact + assert "mid" in after + assert "knowledge graph at graphify-out/" in after