From 88b22b0abc2e5106d43fccec607e2ab1396ae0a8 Mon Sep 17 00:00:00 2001 From: DreamLinx Date: Tue, 18 Aug 2026 16:08:24 +0800 Subject: [PATCH] fix(writer): preserve complete sections when truncating --- CHANGELOG.md | 9 ++++++++ src/codeindex/writers/README_AI.md | 2 +- src/codeindex/writers/utils.py | 35 ++++++++++++++++++++---------- tests/test_smart_writer.py | 2 +- tests/writers/test_utils.py | 21 ++++++++++++++++++ 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a81c77..e325013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Detailed `README_AI.md` truncation preserves complete file sections** (GH + #178). The 10KB navigation budget now reserves its notice first, then cuts + at the last complete Markdown heading (`##` through `######`) instead of + raw bytes inside a `### file` symbol list. The fallback notice now directs + agents to the relevant source files rather than assuming per-module README + files exist. Tiny configured budgets remain bounded too. + ## [0.37.0] - 2026-08-15 ### Removed (BREAKING) diff --git a/src/codeindex/writers/README_AI.md b/src/codeindex/writers/README_AI.md index 96e08b2..1363d84 100644 --- a/src/codeindex/writers/README_AI.md +++ b/src/codeindex/writers/README_AI.md @@ -1,5 +1,5 @@ - + # writers diff --git a/src/codeindex/writers/utils.py b/src/codeindex/writers/utils.py index 8d4047d..282bd36 100644 --- a/src/codeindex/writers/utils.py +++ b/src/codeindex/writers/utils.py @@ -299,19 +299,32 @@ def truncate_content(content: str, max_size: int) -> tuple[str, bool]: if len(content_bytes) <= max_size: return content, False - # Find a good truncation point - truncated = content_bytes[:max_size - 200].decode('utf-8', errors='ignore') - - # Try to truncate at a section boundary - last_section = truncated.rfind("\n## ") - if last_section > len(truncated) // 2: - truncated = truncated[:last_section] - - # Add truncation notice - truncated += ( + notice = ( "\n\n---\n" "_Content truncated due to size limit. " - "See individual module README files for details._\n" + "Read the relevant source files for details._\n" ) + if len(notice.encode('utf-8')) > max_size: + notice = "_Truncated._\n" + if len(notice.encode('utf-8')) > max_size: + return notice[:max_size], True + budget = max(0, max_size - len(notice.encode('utf-8'))) + truncated = content_bytes[:budget].decode('utf-8', errors='ignore') + + # README generators use ## for document sections and ### for files. Prefer + # the last complete heading boundary so detailed output never ends midway + # through a file's symbol list. + last_section = max( + truncated.rfind(f"\n{'#' * level} ") + for level in range(2, 7) + ) + if last_section >= 0: + truncated = truncated[:last_section] + else: + last_line = truncated.rfind("\n") + if last_line > 0: + truncated = truncated[:last_line] + + truncated = truncated.rstrip() + notice return truncated, True diff --git a/tests/test_smart_writer.py b/tests/test_smart_writer.py index 0921d4c..a52ff83 100644 --- a/tests/test_smart_writer.py +++ b/tests/test_smart_writer.py @@ -267,7 +267,7 @@ def test_smart_writer_size_limit(): assert result.success assert result.truncated - assert result.size_bytes <= 1024 + 200 # Allow some margin for truncation notice + assert result.size_bytes <= 1024 def test_determine_level(): diff --git a/tests/writers/test_utils.py b/tests/writers/test_utils.py index 419f0b2..2356508 100644 --- a/tests/writers/test_utils.py +++ b/tests/writers/test_utils.py @@ -325,9 +325,30 @@ def test_truncates_at_section_boundary(self): result, truncated = truncate_content(sections, 60) assert truncated assert "truncated" in result.lower() + assert len(result.encode("utf-8")) <= 60 def test_truncation_adds_notice(self): content = "x" * 2000 result, truncated = truncate_content(content, 500) assert truncated assert "Content truncated" in result + + def test_detailed_file_section_is_never_cut_mid_list_item(self): + """Detailed output uses ``### file`` headings, not only ``##``.""" + content = ( + "# cli\n\n" + "## Files\n\n" + "### alpha.py\n" + "- `def alpha() -> None`\n\n" + "### beta.py\n" + "- `def beta_with_a_very_long_signature(" + "arg, " * 80 + ") -> None`\n" + ) + + result, truncated = truncate_content(content, 300) + + assert truncated + assert "### alpha.py" in result + assert "### beta.py" not in result + assert result.count("`") % 2 == 0 + assert "Read the relevant source files" in result + assert len(result.encode("utf-8")) <= 300