From 6022d8bcf0d6828aeaac2941fc43faee2bc868e8 Mon Sep 17 00:00:00 2001 From: Selim Ataballyev Date: Fri, 19 Jun 2026 20:46:50 +0500 Subject: [PATCH 1/2] Bound managed section by same-or-higher-level heading to avoid reset data loss --- src/utils/file-system.ts | 25 ++++++++++++++++++++----- tests/utils/file-system.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/utils/file-system.ts b/src/utils/file-system.ts index 7306015..de1b26e 100644 --- a/src/utils/file-system.ts +++ b/src/utils/file-system.ts @@ -91,7 +91,16 @@ export function parseGitignoreLines(content: string): Set { /** * Replace or remove a markdown section identified by a heading marker. - * Finds the marker, then locates the next level-2 heading to determine section bounds. + * + * The section runs from its marker heading to the next heading of the same or + * higher level (a level-1 `#` or level-2 `##` heading). Deeper headings (`###` + * and below) are treated as part of the section body. + * + * Bounding by the next same-or-higher-level heading matters for correctness: + * the previous implementation only looked for the next `## ` heading, so a + * trailing top-level `# heading` — or any user content that followed the + * managed section — was swallowed into it and deleted when the section was + * removed (e.g. during `bmalph reset`). * * @param content - The full file content * @param marker - The section heading to find (e.g. "## BMAD-METHOD Integration") @@ -105,10 +114,16 @@ export function replaceSection(content: string, marker: string, replacement: str const before = content.slice(0, sectionStart); const afterSection = content.slice(sectionStart); - const headingText = marker.startsWith("## ") ? marker.slice(3) : marker; - const headingTextEscaped = headingText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const nextHeadingMatch = afterSection.match(new RegExp(`\\n## (?!${headingTextEscaped})`)); - const after = nextHeadingMatch ? afterSection.slice(nextHeadingMatch.index ?? 0) : ""; + // Skip past the marker's own heading line so it is never matched as the + // section boundary, then find the next level-1 or level-2 heading. + const markerLineBreak = afterSection.indexOf("\n"); + const afterMarkerLine = markerLineBreak === -1 ? "" : afterSection.slice(markerLineBreak); + + const nextHeadingMatch = afterMarkerLine.match(/\n#{1,2} /); + const after = + nextHeadingMatch && nextHeadingMatch.index !== undefined + ? afterMarkerLine.slice(nextHeadingMatch.index) + : ""; return before.trimEnd() + replacement + after; } diff --git a/tests/utils/file-system.test.ts b/tests/utils/file-system.test.ts index 23f3e87..610e30d 100644 --- a/tests/utils/file-system.test.ts +++ b/tests/utils/file-system.test.ts @@ -354,5 +354,37 @@ describe("isDirectory() with mocked fs", () => { expect(result).toContain("Replaced."); expect(result).not.toContain("Last content."); }); + + it("preserves a trailing top-level heading when removing the section", () => { + const doc = "# Project\n\n## BMAD\n\nManaged body.\n\n# My Notes\n\nKeep this."; + const result = replaceSection(doc, "## BMAD", ""); + + expect(result).not.toContain("Managed body."); + expect(result).toContain("# My Notes"); + expect(result).toContain("Keep this."); + }); + + it("treats nested (### and deeper) headings as part of the section body", () => { + const doc = "## BMAD\n\nIntro.\n\n### Sub A\n\nDetails.\n\n## After\n\nUser section."; + const result = replaceSection(doc, "## BMAD", ""); + + // Everything in the BMAD section, including its ### subheading, is removed... + expect(result).not.toContain("Intro."); + expect(result).not.toContain("### Sub A"); + expect(result).not.toContain("Details."); + // ...but the following same-level section survives. + expect(result).toContain("## After"); + expect(result).toContain("User section."); + }); + + it("does not match the marker's own heading as the section boundary", () => { + const doc = "## BMAD\n\nold body\n\n## Other\n\nother body"; + const result = replaceSection(doc, "## BMAD", "\n## BMAD\n\nnew body\n"); + + expect(result).toContain("new body"); + expect(result).not.toContain("old body"); + expect(result).toContain("## Other"); + expect(result).toContain("other body"); + }); }); }); From ccdc5ce2df3dfcfd26f10663df83b13c7c05c484 Mon Sep 17 00:00:00 2001 From: Selim Ataballyev Date: Fri, 19 Jun 2026 23:02:37 +0500 Subject: [PATCH 2/2] Add CRLF and headerless-prose tests for replaceSection --- tests/utils/file-system.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/utils/file-system.test.ts b/tests/utils/file-system.test.ts index 610e30d..70b52cf 100644 --- a/tests/utils/file-system.test.ts +++ b/tests/utils/file-system.test.ts @@ -386,5 +386,25 @@ describe("isDirectory() with mocked fs", () => { expect(result).toContain("## Other"); expect(result).toContain("other body"); }); + + it("preserves a trailing heading and its content on CRLF (Windows) files", () => { + const doc = "# Project\r\n\r\n## BMAD\r\n\r\nManaged body.\r\n\r\n# Notes\r\n\r\nKeep this."; + const result = replaceSection(doc, "## BMAD", ""); + + // The managed body is removed, but the user's trailing section survives. + expect(result).not.toContain("Managed body."); + expect(result).toContain("# Notes"); + expect(result).toContain("Keep this."); + }); + + it("documents the limitation: headerless trailing prose is absorbed into the section", () => { + // Known limitation — without a heading boundary there is nothing to + // distinguish trailing prose from the managed section body, so it is + // removed. Pinned here so the behavior change is intentional, not silent. + const doc = "## BMAD\n\nManaged body.\n\nTrailing prose with no heading."; + const result = replaceSection(doc, "## BMAD", ""); + + expect(result).not.toContain("Trailing prose with no heading."); + }); }); });