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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
31211a0e7c
commit
97a1371478
@@ -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<Item>` drops the `List` edge but keeps `Item`.
|
||||
|
||||
+19
-14
@@ -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("## "):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user