Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/utils/file-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,16 @@ export function parseGitignoreLines(content: string): Set<string> {

/**
* 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")
Expand All @@ -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;
}
Expand Down
52 changes: 52 additions & 0 deletions tests/utils/file-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
});
});
});