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
92 changes: 86 additions & 6 deletions src/code-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
91 changes: 91 additions & 0 deletions test/code-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down