From 8053816422d96bf04ba7c6c345064ab3a713da57 Mon Sep 17 00:00:00 2001 From: Wenyu Date: Thu, 16 Jul 2026 04:46:17 -0400 Subject: [PATCH] fix: replace legacy unmarked OpenWiki sections instead of appending a duplicate Versions of OpenWiki before the OPENWIKI:START/END managed-block markers wrote a bare "## OpenWiki" section into AGENTS.md and CLAUDE.md. When a newer version runs against such a file, the marker-based upsert never matches, so it appends a second "## OpenWiki" section next to the legacy one - this repository's own root CLAUDE.md and AGENTS.md both exhibit the duplication. writeCodeModeAgentSnippet now strips bare "## OpenWiki" sections outside the markers before the upsert, but only when the section references the generated wiki entrypoint (openwiki/quickstart.md); same-named user-authored sections that do not reference it are preserved. When the markers are absent, the refreshed block is inserted where the legacy section was instead of being appended at the end of the file. Adds five regression tests; three fail against the previous code. Co-Authored-By: Claude Fable 5 --- src/code-mode.ts | 92 +++++++++++++++++++++++++++++++++++++++--- test/code-mode.test.ts | 91 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 6 deletions(-) diff --git a/src/code-mode.ts b/src/code-mode.ts index f3447c5e..e17f9380 100644 --- a/src/code-mode.ts +++ b/src/code-mode.ts @@ -56,16 +56,96 @@ async function writeCodeModeAgentSnippet( } } - const startIndex = currentContent.indexOf(OPENWIKI_AGENTS_SNIPPET_START); - const endIndex = currentContent.indexOf(OPENWIKI_AGENTS_SNIPPET_END); - const nextContent = - startIndex !== -1 && endIndex !== -1 && endIndex > startIndex - ? `${currentContent.slice(0, startIndex)}${snippet}${currentContent.slice(endIndex + OPENWIKI_AGENTS_SNIPPET_END.length)}` - : `${currentContent.trimEnd()}${currentContent.trim().length > 0 ? "\n\n" : ""}${snippet}\n`; + const legacyScan = removeLegacyOpenWikiSections(currentContent); + const content = legacyScan.content; + + const startIndex = content.indexOf(OPENWIKI_AGENTS_SNIPPET_START); + const endIndex = content.indexOf(OPENWIKI_AGENTS_SNIPPET_END); + + let nextContent: string; + if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) { + nextContent = `${content.slice(0, startIndex)}${snippet}${content.slice(endIndex + OPENWIKI_AGENTS_SNIPPET_END.length)}`; + } else if (legacyScan.firstRemovalIndex !== null) { + const before = content.slice(0, legacyScan.firstRemovalIndex); + const after = content.slice(legacyScan.firstRemovalIndex); + nextContent = + after.trim().length > 0 + ? `${before}${snippet}\n\n${after}` + : `${before.trimEnd()}${before.trim().length > 0 ? "\n\n" : ""}${snippet}\n`; + } else { + nextContent = `${content.trimEnd()}${content.trim().length > 0 ? "\n\n" : ""}${snippet}\n`; + } await writeFile(agentsPath, nextContent, "utf8"); } +// Versions before the managed-block markers wrote a bare "## OpenWiki" +// section, so refreshes appended a second copy next to it instead of +// replacing it. Sections are treated as OpenWiki-owned only when they +// reference the generated wiki entrypoint; same-named user-authored +// sections are left alone. +const OPENWIKI_SECTION_SIGNATURE = "openwiki/quickstart.md"; + +function removeLegacyOpenWikiSections(content: string): { + content: string; + firstRemovalIndex: number | null; +} { + const startMarkerIndex = content.indexOf(OPENWIKI_AGENTS_SNIPPET_START); + const endMarkerIndex = content.indexOf(OPENWIKI_AGENTS_SNIPPET_END); + const hasMarkedBlock = + startMarkerIndex !== -1 && endMarkerIndex > startMarkerIndex; + + const headingPattern = /^## OpenWiki[ \t]*\r?$/gm; + const removals: Array<{ start: number; end: number }> = []; + + for ( + let match = headingPattern.exec(content); + match !== null; + match = headingPattern.exec(content) + ) { + const sectionStart = match.index; + const insideMarkedBlock = + hasMarkedBlock && + sectionStart > startMarkerIndex && + sectionStart < endMarkerIndex; + if (insideMarkedBlock) { + continue; + } + + const sectionEnd = findLegacySectionEnd(content, headingPattern.lastIndex); + const section = content.slice(sectionStart, sectionEnd); + if (!section.includes(OPENWIKI_SECTION_SIGNATURE)) { + continue; + } + + removals.push({ start: sectionStart, end: sectionEnd }); + headingPattern.lastIndex = sectionEnd; + } + + if (removals.length === 0) { + return { content, firstRemovalIndex: null }; + } + + let stripped = ""; + let cursor = 0; + for (const removal of removals) { + stripped += content.slice(cursor, removal.start); + cursor = removal.end; + } + stripped += content.slice(cursor); + + return { content: stripped, firstRemovalIndex: removals[0].start }; +} + +function findLegacySectionEnd(content: string, fromIndex: number): number { + const boundaryPattern = new RegExp( + `^(?:#{1,2} |${OPENWIKI_AGENTS_SNIPPET_START})`, + "m", + ); + const boundaryOffset = content.slice(fromIndex).search(boundaryPattern); + return boundaryOffset === -1 ? content.length : fromIndex + boundaryOffset; +} + function createCodeModeWorkflow(cronExpression: string): string { return `name: OpenWiki Update diff --git a/test/code-mode.test.ts b/test/code-mode.test.ts index 11df0650..9a7e481c 100644 --- a/test/code-mode.test.ts +++ b/test/code-mode.test.ts @@ -97,6 +97,97 @@ Trailing notes that must survive. expect(second).toEqual(first); }); + + test("replaces a legacy unmarked OpenWiki section in place instead of appending a duplicate", async () => { + const repo = await createTempRepo(); + const existing = `# My Project + +## OpenWiki + +This repository has documentation located in the /openwiki directory. + +- [OpenWiki quickstart](openwiki/quickstart.md) + +## Deployment + +Keep this section intact. +`; + await writeFile(path.join(repo, "AGENTS.md"), existing, "utf8"); + + await ensureCodeModeRepoSetup(repo); + + const content = await readIfPresent(path.join(repo, "AGENTS.md")); + expect(content).toContain("# My Project"); + expect(content).toContain("Keep this section intact."); + expect(content).not.toContain( + "documentation located in the /openwiki directory", + ); + // Exactly one OpenWiki section, and it is the managed block. + expect(content?.match(/^## OpenWiki/gm)).toHaveLength(1); + expect(content?.match(new RegExp(SNIPPET_START, "g"))).toHaveLength(1); + // Replaced where the legacy section was, not appended after everything. + expect(content?.indexOf(SNIPPET_START)).toBeLessThan( + content?.indexOf("## Deployment") ?? -1, + ); + }); + + test("removes a legacy unmarked OpenWiki section left alongside an existing managed block", async () => { + const repo = await createTempRepo(); + const existing = `## OpenWiki + +Start here: + +- [OpenWiki quickstart](openwiki/quickstart.md) + +${SNIPPET_START} +stale OpenWiki content +${SNIPPET_END} +`; + await writeFile(path.join(repo, "CLAUDE.md"), existing, "utf8"); + + await ensureCodeModeRepoSetup(repo); + + const content = await readIfPresent(path.join(repo, "CLAUDE.md")); + expect(content).not.toContain("Start here:"); + expect(content?.match(/^## OpenWiki/gm)).toHaveLength(1); + expect(content?.match(new RegExp(SNIPPET_START, "g"))).toHaveLength(1); + }); + + test("preserves an unrelated user-authored OpenWiki section", async () => { + const repo = await createTempRepo(); + const existing = `## OpenWiki + +My own notes about wikis in general, unrelated to the generated docs. +`; + await writeFile(path.join(repo, "AGENTS.md"), existing, "utf8"); + + await ensureCodeModeRepoSetup(repo); + + const content = await readIfPresent(path.join(repo, "AGENTS.md")); + expect(content).toContain("My own notes about wikis in general"); + expect(content).toContain(SNIPPET_START); + }); + + test("is idempotent after replacing a legacy unmarked section", async () => { + const repo = await createTempRepo(); + const existing = `## OpenWiki + +- [OpenWiki quickstart](openwiki/quickstart.md) + +## Notes + +Trailing user notes. +`; + await writeFile(path.join(repo, "CLAUDE.md"), existing, "utf8"); + + await ensureCodeModeRepoSetup(repo); + const first = await readIfPresent(path.join(repo, "CLAUDE.md")); + await ensureCodeModeRepoSetup(repo); + const second = await readIfPresent(path.join(repo, "CLAUDE.md")); + + expect(second).toEqual(first); + expect(first?.match(/^## OpenWiki/gm)).toHaveLength(1); + }); }); describe("ensureCodeModeRepoSetup workflow", () => {