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..70b52cf 100644 --- a/tests/utils/file-system.test.ts +++ b/tests/utils/file-system.test.ts @@ -354,5 +354,57 @@ 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"); + }); + + 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."); + }); }); });