DocDiffMojo.java
package network.ike.docs.plugin;
import network.ike.docs.koncept.KonceptInlineMacro;
import network.ike.docs.plugin.diff.AdocDiffMarker;
import network.ike.docs.plugin.diff.ChangeManifest;
import network.ike.docs.plugin.diff.ChangeStatus;
import network.ike.docs.plugin.diff.GitSource;
import network.ike.docs.plugin.diff.PacketAssembler;
import network.ike.docs.plugin.diff.RegistryDelta;
import network.ike.docs.plugin.diff.RegistryIndex;
import network.ike.docs.plugin.diff.StampRegistry;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import org.asciidoctor.Asciidoctor;
import org.asciidoctor.Attributes;
import org.asciidoctor.AttributesBuilder;
import org.asciidoctor.Options;
import org.asciidoctor.SafeMode;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Generate and render a doc-diff <em>review packet</em>: every changed
* AsciiDoc fragment between two refs (or a ref and the working tree),
* marked inline with {@code [.diff-ins]}/{@code [.diff-del]} roles and
* composed into a book with a cover summary, Record of Changes, Change
* Glossary, Change Index, registry delta, and assembly scaffolding
* diffs (ike-issues#648, #649, #650; design note
* {@code dev-doc-diff-pipeline} in ike-lab-documents).
*
* <p>The goal works at the Maven subproject level and adapts to the
* module it runs in:
*
* <ul>
* <li><b>Topics-library module</b> (its source root holds
* {@code topic-registry.yaml}): the corpus packet — every changed
* fragment, the full registry delta including each assembly's
* membership changes, and the module's own scaffolding.</li>
* <li><b>Assembly module</b>: the packet is the <em>projection</em>
* of the corpus diff onto this assembly — changed topics
* intersected with the assembly's flattened {@code topic-refs}
* (assembly id defaults to the artifactId; topic ids resolve to
* files through the per-domain registries), plus the module's own
* master-file scaffolding diff and a membership delta for just
* this assembly. Topics deleted in range are reported by the
* membership delta rather than projected.</li>
* <li><b>Other modules</b>: no source directory → skipped silently;
* no changes → skipped with a note. Invoking the goal from the
* aggregator therefore produces a packet for every subproject
* able to generate one.</li>
* </ul>
*
* <p>Typical invocations:
*
* <pre>
* mvn idoc:diff # HEAD vs working tree
* mvn idoc:diff -Dike.diff.from=v1.2.3 # tag vs working tree
* mvn idoc:diff -Dike.diff.from=A -Dike.diff.to=B # any two commits
* </pre>
*
* <p>Change entities come from {@code changes.yaml} — looked up first
* beside the module pom, then at the repository work-tree root. For
* commit-to-commit comparisons without one, they are derived by
* grouping the range's commits on their {@code Refs:}/{@code Fixes:}
* trailers (ike-issues#652); a working-tree comparison without one gets
* a single synthetic "uncommitted changes" entity.
*
* <p>Both sides' content always renders through the <em>current</em>
* toolchain — the diff is of knowledge, never of the renderer. Deleted
* fragments are listed on the cover and in the registry delta rather
* than rendered struck-through (v1 decision per #649). Diagram blocks
* inside marked topics render as source listings in this goal's output;
* full diagram fidelity comes from the regular pipeline render of a
* packet wired as a doc module.
*
* <p>Outputs under {@code target/doc-diff/}: {@code asciidoc/} (the
* generated packet sources), {@code html/}, and {@code pdf/} (prawn).
* Skip rendering with {@code -Dike.diff.render=false}.
*
* @see <a href="https://github.com/IKE-Network/ike-issues/issues/648">ike-issues#648</a>
*/
@Mojo(name = "diff")
public class DocDiffMojo implements org.apache.maven.api.plugin.Mojo {
private static final String REGISTRY_SUFFIX = "src/docs/asciidoc/topic-registry.yaml";
@org.apache.maven.api.di.Inject
private org.apache.maven.api.plugin.Log log;
/**
* Access the Maven logger.
*
* @return the logger
*/
protected org.apache.maven.api.plugin.Log getLog() { return log; }
/** The from side: any committish. */
@Parameter(property = "ike.diff.from", defaultValue = "HEAD")
String from;
/** The to side: any committish, or {@code WORKTREE} for the working tree. */
@Parameter(property = "ike.diff.to", defaultValue = GitSource.WORKTREE)
String to;
/** The module's AsciiDoc source root. */
@Parameter(property = "ike.diff.sourceDirectory",
defaultValue = "${project.basedir}/src/docs/asciidoc")
File sourceDirectory;
/**
* Assembly id used for projection when this module is an assembly;
* by IKE naming convention this is the artifactId.
*/
@Parameter(property = "ike.diff.assemblyId", defaultValue = "${project.artifactId}")
String assemblyId;
/**
* Optional comma-separated topic filter for a tighter packet —
* topic ids (resolved through the registry) or path suffixes. When
* set, only matching topics are marked; scaffolding and membership
* sections are unaffected. Meant for share-scoped excerpts; for a
* scope you reuse, register a small assembly instead and let the
* projection do this permanently.
*/
@Parameter(property = "ike.diff.topics")
String topics;
/** Authored change manifest; optional (see class javadoc). */
@Parameter(property = "ike.diff.changesFile",
defaultValue = "${project.basedir}/changes.yaml")
File changesFile;
/** Output root for generated sources and renders. */
@Parameter(property = "ike.diff.outputDirectory",
defaultValue = "${project.build.directory}/doc-diff")
File outputDirectory;
/**
* Prawn theme for the PDF render — by default the unpacked
* ike-doc-resources theme, which carries the diff roles.
*/
@Parameter(property = "ike.diff.pdfTheme",
defaultValue = "${project.build.directory}/ike-doc-resources/theme/ike-default-theme.yml")
File pdfTheme;
/** Optional fonts directory passed to the PDF render when present. */
@Parameter(property = "ike.diff.pdfFontsDir",
defaultValue = "${project.build.directory}/fonts")
File pdfFontsDir;
/** Render HTML and PDF after generating sources. */
@Parameter(property = "ike.diff.render", defaultValue = "true")
boolean render;
/**
* Kroki diagram server URL — same property the pipeline render
* uses, so one override controls both goals.
*/
@Parameter(property = "ike.asciidoc.diagramServerUrl",
defaultValue = "https://kroki.komet.sh")
String diagramServerUrl;
/** Title of the generated packet document. */
@Parameter(property = "ike.diff.title",
defaultValue = "Documentation Review Packet")
String title;
/**
* Stamp each change boundary with its provenance coordinate as a
* reusable endnote (ike-issues#656); auto-suppressed when the whole
* packet carries a single stamp, which is then stated on the cover.
*/
@Parameter(property = "ike.diff.stamps", defaultValue = "true")
boolean stamps;
@Override
public void execute() throws MojoException {
if (sourceDirectory == null || !sourceDirectory.isDirectory()) {
getLog().info("idoc:diff: no src/docs/asciidoc in this module — skipping");
return;
}
try (GitSource git = GitSource.open(sourceDirectory.toPath().getParent())) {
Path workTree = git.workTree();
Path srcAbs = sourceDirectory.toPath().toAbsolutePath().normalize();
if (!srcAbs.startsWith(workTree)) {
throw new MojoException("sourceDirectory is outside the git work tree: " + srcAbs);
}
String srcRel = workTree.relativize(srcAbs).toString().replace(File.separatorChar, '/');
boolean corpus = git.read(to, srcRel + "/topic-registry.yaml") != null;
String registryRoot = corpus ? srcRel : discoverRegistryRoot(git);
List<GitSource.Change> own = git.changes(from, to, srcRel + "/", ".adoc");
String topicsPrefix = (corpus ? srcRel : registryRoot) == null
? srcRel + "/topics/"
: (corpus ? srcRel : registryRoot) + "/topics/";
List<GitSource.Change> markCandidates;
List<GitSource.Change> scaffoldCandidates;
String membershipDelta = null;
String packetTitle = title;
if (corpus) {
markCandidates = own.stream()
.filter(c -> c.displayPath().startsWith(topicsPrefix)).toList();
scaffoldCandidates = own.stream()
.filter(c -> !c.displayPath().startsWith(topicsPrefix)).toList();
} else if (registryRoot != null) {
RegistryIndex index = RegistryIndex.load(git, to, registryRoot);
List<String> refs = index.assemblyRefs(assemblyId);
if (refs == null) {
getLog().warn("idoc:diff: assembly '" + assemblyId
+ "' is not registered in " + registryRoot
+ "/topic-registry/assemblies.yaml — scaffolding-only packet");
markCandidates = List.of();
} else {
Set<String> included = new LinkedHashSet<>();
for (String ref : refs) {
String file = index.topicFile(ref);
if (file != null) {
included.add(registryRoot + "/" + file);
}
}
markCandidates = git.changes(from, to, topicsPrefix, ".adoc").stream()
.filter(c -> c.status() != ChangeStatus.DELETED)
.filter(c -> included.contains(c.displayPath()))
.toList();
membershipDelta = RegistryIndex.membershipDelta(
git, from, to, registryRoot, assemblyId);
packetTitle = title + " — " + assemblyId;
}
scaffoldCandidates = own;
} else {
markCandidates = own.stream()
.filter(c -> c.displayPath().startsWith(topicsPrefix)).toList();
scaffoldCandidates = own.stream()
.filter(c -> !c.displayPath().startsWith(topicsPrefix)).toList();
}
if (topics != null && !topics.isBlank()) {
RegistryIndex index = registryRoot == null
? null : RegistryIndex.load(git, to, registryRoot);
Set<String> wanted = new LinkedHashSet<>();
for (String token : topics.split(",")) {
String t = token.strip();
String file = index == null ? null : index.topicFile(t);
wanted.add(file != null ? registryRoot + "/" + file : t);
}
markCandidates = markCandidates.stream()
.filter(c -> wanted.stream().anyMatch(
w -> c.displayPath().equals(w) || c.displayPath().endsWith(w)))
.toList();
getLog().info("idoc:diff topic filter active: " + markCandidates.size()
+ " of the changed topics match " + topics);
}
Path diffDir = outputDirectory.toPath().resolve("asciidoc/_diff");
Files.createDirectories(diffDir);
ChangeManifest manifest = resolveManifest(git, markCandidates, own);
// Stamp registry (#656): one stamp per range commit plus one
// for uncommitted content; suppressed when only one exists.
String stampToEnd = GitSource.WORKTREE.equals(to) ? "HEAD" : to;
List<GitSource.CommitMeta> rangeCommits = git.commitsBetween(from, stampToEnd);
Set<String> rangeIds = new LinkedHashSet<>();
rangeCommits.forEach(c -> rangeIds.add(c.id()));
boolean hasUncommitted = GitSource.WORKTREE.equals(to)
&& !git.changes("HEAD", GitSource.WORKTREE, "", ".adoc").isEmpty();
StampRegistry stampRegistry = new StampRegistry(rangeCommits, git.branch(),
git.userName(), java.time.LocalDate.now().toString(),
from + ".." + (GitSource.WORKTREE.equals(to) ? "worktree" : to));
boolean stampsOn = stamps && stampRegistry.multipleStampsPossible(hasUncommitted);
List<PacketAssembler.TopicEntry> topics = new ArrayList<>();
List<String> deleted = new ArrayList<>();
List<PacketAssembler.ScaffoldEntry> scaffolds = new ArrayList<>();
List<List<String>> anchorsByChange = new ArrayList<>();
manifest.changes().forEach(c -> anchorsByChange.add(new ArrayList<>()));
for (GitSource.Change c : markCandidates) {
if (c.status() == ChangeStatus.DELETED) {
deleted.add(c.displayPath());
continue;
}
List<String> newLines = lines(git.read(to, c.newPath()));
List<String> oldLines = c.status() == ChangeStatus.ADDED
? List.of() : lines(git.read(from, c.oldPath()));
AdocDiffMarker.StampSource stampSource = null;
String addedStampRef = null;
if (stampsOn) {
String domain = domainOf(c.displayPath(), topicsPrefix);
List<String> lineIds = git.blameInRange(to, c.newPath(), rangeIds);
// blameInRange speaks precisely: a commit id, UNCOMMITTED,
// or null (out of range / indexing gap) — null falls to the
// coarse range stamp, never to uncommitted.
boolean rangeEmpty = rangeCommits.isEmpty();
stampSource = new AdocDiffMarker.StampSource() {
@Override
public String activeRef(int newLineIndex) {
String id = newLineIndex >= 0 && newLineIndex < lineIds.size()
? lineIds.get(newLineIndex) : null;
return stampRegistry.footnote(id,
StampRegistry.Status.ACTIVE, domain);
}
@Override
public String inactiveRef() {
// A pure deletion is attributable only coarsely:
// to the range when one exists, else to the
// working tree.
return stampRegistry.footnote(
rangeEmpty ? GitSource.UNCOMMITTED : null,
StampRegistry.Status.INACTIVE, domain);
}
};
if (c.status() == ChangeStatus.ADDED) {
addedStampRef = stampRegistry.footnote(
lineIds.isEmpty() ? null : lineIds.get(0),
StampRegistry.Status.ACTIVE, domain);
}
}
AdocDiffMarker.MarkResult result = c.status() == ChangeStatus.ADDED
? AdocDiffMarker.markAdded(newLines, from, addedStampRef)
: AdocDiffMarker.mark(oldLines, newLines, stampSource);
String outName = c.displayPath().substring(topicsPrefix.length()).replace('/', '-');
List<String> owners = ownerTitles(manifest, c.displayPath(), anchorsByChange, result);
List<String> marked = PacketAssembler.injectChangeTerms(result.lines(), owners);
if (!oldLines.isEmpty()) {
marked = AdocDiffMarker.withDiagramHistory(oldLines, marked);
}
Files.writeString(diffDir.resolve(outName),
String.join("\n", marked) + "\n", StandardCharsets.UTF_8);
topics.add(new PacketAssembler.TopicEntry(c, outName, result));
getLog().info("idoc:diff marked " + c.displayPath() + " (+" + result.insWords()
+ "/-" + result.delWords() + ", " + result.notes().size() + " notes)");
}
for (GitSource.Change c : scaffoldCandidates) {
if (c.status() == ChangeStatus.DELETED) {
deleted.add(c.displayPath());
continue;
}
List<String> oldLines = c.oldPath() == null
? List.of() : lines(git.read(from, c.oldPath()));
scaffolds.add(new PacketAssembler.ScaffoldEntry(c.displayPath(),
PacketAssembler.unifiedDiff(c.displayPath(), oldLines,
lines(git.read(to, c.newPath())))));
}
boolean hasRegistry;
if (corpus) {
hasRegistry = writeRegistryDelta(git, srcRel, diffDir);
} else if (membershipDelta != null && !membershipDelta.isEmpty()) {
Files.writeString(diffDir.resolve("registry-delta.adoc"),
membershipDelta, StandardCharsets.UTF_8);
hasRegistry = true;
} else {
hasRegistry = false;
}
if (topics.isEmpty() && deleted.isEmpty() && scaffolds.isEmpty() && !hasRegistry) {
getLog().info("idoc:diff: no documentation changes between " + from + " and "
+ (GitSource.WORKTREE.equals(to) ? "the working tree" : to)
+ " for this module — skipping packet");
return;
}
String singleStampLine = (stamps && !stampsOn)
? stampRegistry.singleStampLine("(packet)") : null;
String master = PacketAssembler.masterDoc(packetTitle, from,
GitSource.WORKTREE.equals(to) ? "working tree" : to,
topics, deleted, manifest, anchorsByChange, hasRegistry, scaffolds,
singleStampLine, stampRegistry.usedStamps());
Path masterFile = outputDirectory.toPath().resolve("asciidoc/review-packet.adoc");
Files.writeString(masterFile, master, StandardCharsets.UTF_8);
getLog().info("idoc:diff packet: " + topics.size() + " marked topics, "
+ deleted.size() + " deleted, " + scaffolds.size()
+ " scaffolding diffs, " + manifest.changes().size() + " change entities"
+ (corpus ? " [corpus]" : membershipDelta != null
? " [assembly " + assemblyId + "]" : ""));
if (render) {
renderPacket(masterFile);
}
} catch (IOException e) {
throw new MojoException("idoc:diff failed: " + e.getMessage(), e);
}
}
/**
* The topic's domain — the STAMP module analogue — from its path
* under the topics prefix.
*
* @param displayPath the repository-relative topic path
* @param topicsPrefix the topics tree prefix
* @return the domain directory name, or {@code "(doc)"} when the
* path has no domain segment
*/
private static String domainOf(String displayPath, String topicsPrefix) {
if (!displayPath.startsWith(topicsPrefix)) {
return "(doc)";
}
String rest = displayPath.substring(topicsPrefix.length());
int slash = rest.indexOf('/');
return slash > 0 ? rest.substring(0, slash) : "(doc)";
}
/**
* Locate the topic-registry source root anywhere in the repository
* on the to side, so an assembly module can resolve its membership.
*
* @param git repository access
* @return the registry's source root (repository-relative), or
* {@code null} when the repository has no registry
* @throws IOException on repository access failure
*/
private String discoverRegistryRoot(GitSource git) throws IOException {
String found = git.findPath(to, REGISTRY_SUFFIX);
if (found == null) {
return null;
}
return found.substring(0, found.length() - "/topic-registry.yaml".length());
}
private ChangeManifest resolveManifest(GitSource git, List<GitSource.Change> marked,
List<GitSource.Change> own) throws IOException {
if (changesFile != null && changesFile.isFile()) {
return ChangeManifest.load(changesFile.toPath());
}
Path repoManifest = git.workTree().resolve("changes.yaml");
if (Files.isRegularFile(repoManifest)) {
return ChangeManifest.load(repoManifest);
}
if (!GitSource.WORKTREE.equals(to)) {
return ChangeManifest.derive(git.commitsBetween(from, to));
}
// Working-tree review without an authored manifest: derive entities
// from any committed part of the range (from..HEAD), then append one
// synthetic entity for the uncommitted remainder (HEAD..WORKTREE).
List<GitSource.CommitMeta> committed = git.commitsBetween(from, "HEAD");
Set<String> uncommitted = new LinkedHashSet<>();
git.changes("HEAD", GitSource.WORKTREE, "", ".adoc")
.forEach(c -> uncommitted.add(c.displayPath()));
if (committed.isEmpty() && uncommitted.isEmpty()) {
Set<String> files = new LinkedHashSet<>();
marked.forEach(c -> files.add(c.displayPath()));
own.forEach(c -> files.add(c.displayPath()));
uncommitted.addAll(files);
}
Path yaml = Files.createTempFile("doc-diff-synthetic", ".yaml");
try {
StringBuilder sb = new StringBuilder("changes:\n");
if (!uncommitted.isEmpty()) {
sb.append(" - id: chg-uncommitted\n")
.append(" title: \"Uncommitted changes\"\n")
.append(" description: >\n")
.append(" Working-tree changes not yet committed on top of HEAD.\n")
.append(" files:\n");
uncommitted.forEach(f -> sb.append(" - ").append(f).append('\n'));
}
Files.writeString(yaml, sb.toString(), StandardCharsets.UTF_8);
ChangeManifest synthetic = ChangeManifest.load(yaml);
if (committed.isEmpty()) {
return synthetic;
}
List<ChangeManifest.ChangeEntity> merged =
new ArrayList<>(ChangeManifest.derive(committed).changes());
merged.addAll(synthetic.changes());
return ChangeManifest.of(merged);
} finally {
Files.deleteIfExists(yaml);
}
}
private List<String> ownerTitles(ChangeManifest manifest, String path,
List<List<String>> anchorsByChange,
AdocDiffMarker.MarkResult result) {
List<String> owners = new ArrayList<>();
String anchor = PacketAssembler.anchorOf(result.lines());
List<ChangeManifest.ChangeEntity> changes = manifest.changes();
for (int i = 0; i < changes.size(); i++) {
if (changes.get(i).files().contains(path)) {
owners.add(changes.get(i).title());
if (anchor != null) {
anchorsByChange.get(i).add(anchor);
}
}
}
return owners;
}
private boolean writeRegistryDelta(GitSource git, String srcRel, Path diffDir)
throws IOException {
String root = srcRel + "/topic-registry.yaml";
if (git.read(from, root) == null && git.read(to, root) == null) {
return false;
}
String delta = new RegistryDelta(git, from, to)
.render(root, srcRel + "/topic-registry");
if (delta.isEmpty()) {
return false;
}
Files.writeString(diffDir.resolve("registry-delta.adoc"), delta, StandardCharsets.UTF_8);
return true;
}
private void renderPacket(Path masterFile) {
try (Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
asciidoctor.registerLogHandler(new AsciidocMojo.MavenLogHandler(getLog()));
asciidoctor.requireLibrary("asciidoctor-diagram");
asciidoctor.javaExtensionRegistry().inlineMacro(KonceptInlineMacro.class);
Path htmlDir = outputDirectory.toPath().resolve("html");
convert(asciidoctor, masterFile, "html5", htmlDir, baseAttributes().build());
AttributesBuilder pdfAttrs = baseAttributes();
Path theme = resolvePdfTheme();
if (theme != null) {
pdfAttrs.attribute("pdf-theme", theme.toAbsolutePath().toString());
}
if (pdfFontsDir != null && pdfFontsDir.isDirectory()) {
pdfAttrs.attribute("pdf-fontsdir", pdfFontsDir.getAbsolutePath());
}
Path pdfDir = outputDirectory.toPath().resolve("pdf");
convert(asciidoctor, masterFile, "pdf", pdfDir, pdfAttrs.build());
}
}
/**
* Resolve the prawn theme for this run, making the goal
* self-contained (ike-issues#649): an explicit/unpacked theme wins;
* otherwise the branded theme is extracted from the plugin's own
* classpath when the pipeline fonts are present; otherwise a
* bundled fallback theme (stock typography, IKE diff roles) keeps a
* bare {@code mvn idoc:diff} fully styled on a fresh checkout.
*
* @return the theme file to use, or {@code null} when even the
* fallback cannot be provisioned
*/
private Path resolvePdfTheme() {
if (pdfTheme != null && pdfTheme.isFile()) {
return pdfTheme.toPath();
}
boolean fontsAvailable = pdfFontsDir != null && pdfFontsDir.isDirectory();
String resource = fontsAvailable
? "/theme/ike-default-theme.yml"
: "/diff/diff-fallback-theme.yml";
try {
Path dir = outputDirectory.toPath().resolve(".theme");
Files.createDirectories(dir);
Path target = dir.resolve("ike-default-theme.yml");
try (InputStream in = DocDiffMojo.class.getResourceAsStream(resource)) {
if (in == null) {
getLog().warn("idoc:diff: theme resource " + resource
+ " not on plugin classpath — PDF renders unthemed");
return null;
}
Files.copy(in, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
getLog().info("idoc:diff theme: " + (fontsAvailable
? "branded (self-provisioned from ike-doc-resources)"
: "fallback (stock typography + diff roles; run after a "
+ "pipeline build for branded output)"));
return target;
} catch (IOException e) {
getLog().warn("idoc:diff: theme provisioning failed — PDF renders unthemed: "
+ e.getMessage());
return null;
}
}
private AttributesBuilder baseAttributes() {
return Attributes.builder()
.attribute("toc", "left")
.attribute("toclevels", "1")
.attribute("icons", "font")
.attribute("source-highlighter", "rouge")
.attribute("allow-uri-read", "true")
// Diagram rendering — same recipe as the pipeline render
// (kroki server, inline SVG, no PlantUML preprocessing).
.attribute("diagram-server-url", diagramServerUrl)
.attribute("diagram-server-type", "kroki_io")
.attribute("diagram-format", "svg")
.attribute("diagram-svg-type", "inline")
.attribute("plantuml-preprocess", "false")
.attribute("preprocess", "false");
}
private void convert(Asciidoctor asciidoctor, Path masterFile, String backend,
Path outDir, Attributes attrs) {
try {
Files.createDirectories(outDir);
Options options = Options.builder()
.backend(backend)
.safe(SafeMode.UNSAFE)
.baseDir(masterFile.getParent().toFile())
.toDir(outDir.toFile())
.mkDirs(true)
.standalone(true)
.attributes(attrs)
.build();
asciidoctor.convertFile(masterFile.toFile(), options);
getLog().info("idoc:diff rendered " + backend + " -> " + outDir);
} catch (RuntimeException | IOException e) {
getLog().error("idoc:diff " + backend + " render failed — packet sources remain at "
+ masterFile.getParent() + ": " + e.getMessage());
}
}
private static List<String> lines(String text) {
return text == null ? List.of() : Arrays.asList(text.split("\n", -1));
}
}