ClaudeMdMerge.java
package network.ike.plugin.ws.bootstrap;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Non-destructive regeneration of a subproject {@code CLAUDE.md}
* (IKE-Network/ike-issues#917, #920).
*
* <p>{@code ws:scaffold-init} regenerates each subproject's {@code CLAUDE.md}
* from a template on every run. A naive full overwrite destroyed two kinds of
* content the template does not own:
*
* <ul>
* <li><b>#920</b> — the {@code ike-managed} regions that
* {@code ike:scaffold-publish} appends
* ({@code <!-- BEGIN ike-managed: <name> --> … <!-- END ike-managed: <name> -->}),
* causing the two generators to ping-pong the file on alternate runs.</li>
* <li><b>#917</b> — hand-authored {@code ##} sections a developer added
* (e.g. a project's fast-dev build notes), silently dropped on
* regeneration.</li>
* </ul>
*
* <p>{@link #merge(String, String)} is a pure function so it can be unit-tested
* without touching the filesystem. It:
*
* <ol>
* <li>extracts every {@code ike-managed} region from the existing file
* <em>verbatim</em> (byte-for-byte, on its own lines, so the emitting
* plugin's line-exact marker matching still locates it) and re-appends
* them after the freshly generated template;</li>
* <li>returns any top-level ({@code ## }) section whose heading the template
* does not itself produce as a {@linkplain Section rescued} section, for
* the caller to preserve in {@code CLAUDE-<name>.md} — the file the
* scaffold never overwrites — rather than carrying it into the
* regenerated {@code CLAUDE.md}.</li>
* </ol>
*
* <p>Template-owned sections are regenerated from the template (that is the
* point of regeneration); only sections the template does not emit are treated
* as hand-authored. A section added as prose <em>inside</em> a template-owned
* section (no new {@code ## } heading) is a residual gap — it is regenerated
* with its section — but the reported real-world losses were whole sections,
* which this preserves.
*/
final class ClaudeMdMerge {
private ClaudeMdMerge() {}
/** Matches a {@code <!-- BEGIN ike-managed: <name> -->} marker line. */
private static final Pattern MANAGED_BEGIN = Pattern.compile(
"^\\s*<!--\\s*BEGIN ike-managed:.*-->\\s*$");
/** Matches a {@code <!-- END ike-managed: <name> -->} marker line. */
private static final Pattern MANAGED_END = Pattern.compile(
"^\\s*<!--\\s*END ike-managed:.*-->\\s*$");
/**
* A hand-authored section rescued from a regenerated {@code CLAUDE.md}.
*
* @param title the heading text after {@code ## } (used for logging/dedupe)
* @param text the full section including its {@code ## } heading line,
* with a trailing newline
*/
record Section(String title, String text) {}
/**
* The result of merging an existing {@code CLAUDE.md} with a freshly
* generated template.
*
* @param claudeMd the content to write back to {@code CLAUDE.md}
* @param rescued hand-authored sections to preserve elsewhere; empty when
* the existing file held nothing the template does not own
*/
record Result(String claudeMd, List<Section> rescued) {}
/**
* Merge {@code existing} CLAUDE.md content with a freshly {@code generated}
* template.
*
* @param existing the current on-disk {@code CLAUDE.md}, or {@code null}
* when the file does not exist yet (fresh write)
* @param generated the freshly generated template content
* @return the merged content plus any hand-authored sections to rescue
*/
static Result merge(String existing, String generated) {
if (existing == null || existing.isBlank()) {
return new Result(generated, List.of());
}
List<String> managed = new ArrayList<>();
String withoutManaged = extractManaged(existing, managed);
Set<String> templateTitles = sectionTitles(generated);
List<Section> rescued = new ArrayList<>();
for (Section section : parseSections(withoutManaged)) {
if (!templateTitles.contains(section.title())) {
rescued.add(section);
}
}
StringBuilder sb = new StringBuilder(generated);
for (String block : managed) {
while (sb.length() > 0 && sb.charAt(sb.length() - 1) == '\n') {
sb.deleteCharAt(sb.length() - 1);
}
sb.append("\n\n").append(block.stripTrailing()).append('\n');
}
return new Result(sb.toString(), rescued);
}
/**
* Split off every {@code ike-managed} region from {@code content},
* collecting each verbatim (from its {@code BEGIN} line through its
* {@code END} line) into {@code managed}, and returning the remaining
* (non-managed) content.
*
* <p>An unterminated {@code BEGIN} (no matching {@code END}) is left in the
* remaining content rather than swallowing the rest of the file — the
* emitter always writes balanced pairs, so this only guards against
* hand-corrupted markers.
*/
private static String extractManaged(String content, List<String> managed) {
String[] lines = content.split("\n", -1);
StringBuilder remaining = new StringBuilder();
int i = 0;
while (i < lines.length) {
if (MANAGED_BEGIN.matcher(lines[i]).matches()) {
int end = -1;
for (int j = i + 1; j < lines.length; j++) {
if (MANAGED_END.matcher(lines[j]).matches()) {
end = j;
break;
}
}
if (end >= 0) {
StringBuilder block = new StringBuilder();
for (int k = i; k <= end; k++) {
if (k > i) {
block.append('\n');
}
block.append(lines[k]);
}
managed.add(block.toString());
i = end + 1;
// Skip a single blank separator line left behind.
if (i < lines.length && lines[i].isBlank()) {
i++;
}
continue;
}
}
remaining.append(lines[i]);
if (i < lines.length - 1) {
remaining.append('\n');
}
i++;
}
return remaining.toString();
}
/**
* Parse the top-level ({@code ## }) sections of {@code content}. Content
* before the first {@code ## } heading (the {@code # } title, description)
* is the preamble and is not returned — only headed sections can be
* hand-authored additions.
*/
private static List<Section> parseSections(String content) {
String[] lines = content.split("\n", -1);
List<Section> sections = new ArrayList<>();
StringBuilder current = null;
String currentTitle = null;
for (String line : lines) {
if (line.startsWith("## ")) {
if (current != null) {
sections.add(new Section(currentTitle,
current.toString().stripTrailing() + "\n"));
}
current = new StringBuilder();
currentTitle = line.substring(3).strip();
}
if (current != null) {
current.append(line).append('\n');
}
}
if (current != null) {
sections.add(new Section(currentTitle,
current.toString().stripTrailing() + "\n"));
}
return sections;
}
/**
* The set of {@code ## } section titles the template itself emits — the
* headings a regenerated file legitimately owns.
*/
private static Set<String> sectionTitles(String generated) {
Set<String> titles = new LinkedHashSet<>();
for (Section section : parseSections(generated)) {
titles.add(section.title());
}
return titles;
}
}