001/* 002 * JDrupes Builder 003 * Copyright (C) 2026 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.bnd; 020 021import aQute.bnd.differ.Baseline; 022import aQute.bnd.differ.Baseline.BundleInfo; 023import aQute.bnd.differ.Baseline.Info; 024import aQute.bnd.differ.DiffPluginImpl; 025import aQute.bnd.osgi.Instructions; 026import aQute.bnd.osgi.Jar; 027import aQute.bnd.osgi.Processor; 028import aQute.bnd.service.diff.Diff; 029import com.google.common.flogger.FluentLogger; 030import java.nio.file.Files; 031import java.nio.file.Path; 032import java.util.Comparator; 033import java.util.Formatter; 034import java.util.List; 035import java.util.Locale; 036import java.util.Map; 037import java.util.Objects; 038import java.util.Optional; 039import java.util.stream.Stream; 040import org.jdrupes.builder.api.BuildException; 041import org.jdrupes.builder.api.Generator; 042import static org.jdrupes.builder.api.Intent.Supply; 043import org.jdrupes.builder.api.Project; 044import static org.jdrupes.builder.api.Project.Properties.Version; 045import org.jdrupes.builder.api.Resource; 046import org.jdrupes.builder.api.ResourceRequest; 047import org.jdrupes.builder.api.ResourceType; 048import org.jdrupes.builder.api.Resources; 049import static org.jdrupes.builder.bnd.BndTypes.*; 050import static org.jdrupes.builder.java.JavaTypes.*; 051import org.jdrupes.builder.java.LibraryJarFile; 052import static org.jdrupes.builder.mvnrepo.MvnProperties.*; 053import org.jdrupes.builder.mvnrepo.MvnRepoLookup; 054import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 055import org.jdrupes.builder.mvnrepo.PomFileGenerator; 056 057/// A [Generator] that performs a baseline evaluation between two OSGi 058/// bundles using the `bndlib` library [bnd](https://github.com/bndtools/bnd). 059/// 060/// Because OSGi repositories never became popular, Maven repository 061/// semantics are used to find the baseline bundle. The current bundle 062/// is the library supplied by the project. The [BndBaseliner] evaluates 063/// its Maven coordinates in the same way as the [PomFileGenerator] does. 064/// From these, coordinates used to lookup the previous version are derived 065/// in the form `groupId:artifactId:[,version)` 066/// 067/// The [BndBaseliner] then performs the baseline evaluation. Instructions 068/// `-diffignore` and `-diffpackages` are supported and forwarded to 069/// `bndlib`. 070/// 071@SuppressWarnings("PMD.TooManyStaticImports") 072public class BndBaseliner extends AbstractBndGenerator { 073 074 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 075 private boolean ignoreMismatched; 076 077 /// Initializes a new bnd baseliner. 078 /// 079 /// @param project the project 080 /// 081 public BndBaseliner(Project project) { 082 super(project); 083 } 084 085 /// Add the instruction specified by key and value. 086 /// 087 /// @param key the key 088 /// @param value the value 089 /// @return the bnd baseliner 090 /// 091 @Override 092 public BndBaseliner instruction(String key, String value) { 093 super.instruction(key, value); 094 return this; 095 } 096 097 /// Add the given instructions for the baseliner. 098 /// 099 /// @param instructions the instructions 100 /// @return the bnd baseliner 101 /// 102 @Override 103 public BndBaseliner instructions(Map<String, String> instructions) { 104 super.instructions(instructions); 105 return this; 106 } 107 108 /// Add the instructions from the given bnd (properties) file. 109 /// 110 /// @param bndFile the bnd file 111 /// @return the bnd baseliner 112 /// 113 @Override 114 public BndBaseliner instructions(Path bndFile) { 115 super.instructions(bndFile); 116 return this; 117 } 118 119 /// Ignore mismatches in the baseline evaluation. When invoked, 120 /// the [BndBaseliner] will not set the faulty flag on the 121 /// [BndBaselineEvaluation] if there are mismatches. 122 /// 123 /// @return the bnd baseliner 124 /// 125 public BndBaseliner ignoreMismatches() { 126 this.ignoreMismatched = true; 127 return this; 128 } 129 130 @Override 131 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 132 protected <T extends Resource> Stream<T> 133 doProvide(ResourceRequest<T> requested) { 134 if (!requested.accepts(BndBaselineEvaluationType)) { 135 return Stream.empty(); 136 } 137 138 // Get libraries 139 var libraries = newResource(new ResourceType<Resources< 140 LibraryJarFile>>() {}).addAll(project().providers(Supply) 141 .resources(project().of(LibraryJarFileType))); 142 if (libraries.stream().count() > 1) { 143 logger.atWarning().log("More than one library generated by %s," 144 + " baselining can only success for one.", project()); 145 } 146 @SuppressWarnings("unchecked") 147 var result = (Stream<T>) libraries.stream().map(this::baseline) 148 .filter(Optional::isPresent).map(Optional::get); 149 return result; 150 } 151 152 private Optional<BndBaselineEvaluation> baseline(LibraryJarFile lib) { 153 logger.atFiner().log("Baselining %s in %s", lib, project()); 154 155 var groupId = project().<String> get(GroupId); 156 var artifactId = Optional.ofNullable(project() 157 .<String> get(ArtifactId)).orElse(project().name()); 158 var version = project().<String> get(Version); 159 if (groupId == null) { 160 logger.atWarning().log("Cannot baseline in %s without a groupId", 161 project()); 162 return Optional.empty(); 163 } 164 logger.atFinest().log("Baselining %s:%s:%s", groupId, artifactId, 165 version); 166 167 // Retrieve previous, relying on version boundaries for selection 168 var repoAccess = new MvnRepoLookup().probe().resolve( 169 String.format("%s:%s:[0,%s)", groupId, artifactId, version)); 170 var baselineJar = repoAccess.resources( 171 of(MvnRepoLibraryJarFileType)).findFirst(); 172 if (baselineJar.isEmpty()) { 173 return Optional.of(new DefaultBndBaselineEvaluation( 174 BndBaselineEvaluationType, project(), lib.path()).name( 175 project().rootProject().relativize(lib.path()).toString()) 176 .withBaselineArtifactMissing()); 177 } 178 logger.atFinest().log("Baselining against %s", baselineJar); 179 180 return Optional.of(bndBaseline(baselineJar.get(), lib)); 181 } 182 183 @SuppressWarnings("PMD.AvoidCatchingGenericException") 184 private BndBaselineEvaluation bndBaseline(LibraryJarFile baseline, 185 LibraryJarFile current) { 186 try (Processor processor = new Processor(); 187 Jar baselineJar = new Jar(baseline.path().toFile()); 188 Jar currentJar = new Jar(current.path().toFile())) { 189 applyInstructions(processor); 190 DiffPluginImpl differ = new DiffPluginImpl(); 191 differ.setIgnore(processor.getProperty("-diffignore")); 192 Baseline baseliner = new Baseline(processor, differ); 193 194 List<Info> infos = baseliner.baseline(currentJar, baselineJar, 195 new Instructions(processor.getProperty("-diffpackages"))) 196 .stream() 197 .sorted(Comparator.comparing(info -> info.packageName)) 198 .toList(); 199 BundleInfo bundleInfo = baseliner.getBundleInfo(); 200 var reportLocation = writeReport(baselineJar, currentJar, 201 baseliner, infos, bundleInfo); 202 var result = new DefaultBndBaselineEvaluation( 203 BndBaselineEvaluationType, project(), baseline.path()) 204 .name(bundleInfo.bsn).withReportLocation(reportLocation); 205 if (bundleInfo.mismatch && !ignoreMismatched) { 206 result.setFaulty().withReason(bundleInfo.reason); 207 } 208 return result; 209 210 } catch (Exception e) { 211 throw new BuildException().from(this).cause(e); 212 } 213 } 214 215 @SuppressWarnings({ "PMD.AvoidCatchingGenericException", 216 "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity", 217 "PMD.NPathComplexity" }) 218 private Path writeReport(Jar baselineJar, Jar currentJar, 219 Baseline baseliner, List<Info> infos, BundleInfo bundleInfo) { 220 // Copied from gradle plugin and improved 221 Path reportLocation = project().buildDirectory().resolve("reports"); 222 reportLocation.toFile().mkdirs(); 223 reportLocation = reportLocation.resolve( 224 String.format("%s-baseline.txt", currentJar.getName())); 225 try (var report = Files.newOutputStream(reportLocation); 226 Formatter fmt = new Formatter(report, "UTF-8", Locale.US)) { 227 var formatInfo = new FormatInfo(currentJar, baselineJar, bundleInfo, 228 infos); 229 String format = formatInfo.formatString(); 230 fmt.format(formatInfo.separatorLine()); 231 fmt.format(format, " ", "Name", "Type", "Delta", "New", "Old", 232 "Suggest", ""); 233 Diff diff = baseliner.getDiff(); 234 fmt.format(format, bundleInfo.mismatch ? "*" : " ", 235 bundleInfo.bsn, diff.getType(), diff.getDelta(), 236 currentJar.getVersion(), baselineJar.getVersion(), 237 bundleInfo.mismatch 238 && Objects.nonNull(bundleInfo.suggestedVersion) 239 ? bundleInfo.suggestedVersion 240 : "-", 241 ""); 242 if (bundleInfo.mismatch) { 243 fmt.format("%#2S\n", diff); 244 } 245 246 if (!infos.isEmpty()) { 247 fmt.format(formatInfo.separatorLine()); 248 fmt.format(format, " ", "Name", "Type", "Delta", "New", "Old", 249 "Suggest", "If Prov."); 250 for (Info info : infos) { 251 diff = info.packageDiff; 252 fmt.format(format, info.mismatch ? "*" : " ", 253 diff.getName(), diff.getType(), diff.getDelta(), 254 info.newerVersion, 255 Objects.nonNull(info.olderVersion) 256 && info.olderVersion 257 .equals(aQute.bnd.version.Version.LOWEST) 258 ? "-" 259 : info.olderVersion, 260 Objects.nonNull(info.suggestedVersion) 261 && info.suggestedVersion 262 .compareTo(info.newerVersion) <= 0 ? "ok" 263 : info.suggestedVersion, 264 Objects.nonNull(info.suggestedIfProviders) 265 ? info.suggestedIfProviders 266 : "-"); 267 if (info.mismatch) { 268 fmt.format("%#2S\n", diff); 269 } 270 } 271 } 272 fmt.flush(); 273 } catch (Exception e) { 274 throw new BuildException().from(this).cause(e); 275 } 276 return reportLocation; 277 } 278 279 /// The Class FormatInfo. 280 /// 281 private class FormatInfo { 282 private final int maxNameLength; 283 private final int maxNewerLength; 284 private final int maxOlderLength; 285 286 /// Initializes a new format info. 287 /// 288 /// @param currentJar the current jar 289 /// @param baselineJar the baseline jar 290 /// @param bundleInfo the bundle info 291 /// @param infos the infos 292 /// @throws Exception the exception 293 /// 294 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 295 public FormatInfo(Jar currentJar, Jar baselineJar, 296 BundleInfo bundleInfo, List<Info> infos) throws Exception { 297 maxNameLength = Math.max(bundleInfo.bsn.length(), infos.stream() 298 .map(info -> info.packageDiff.getName().length()) 299 .sorted(Comparator.reverseOrder()).findFirst().orElse(0)); 300 maxNewerLength = Math.max(currentJar.getVersion().length(), 301 infos.stream() 302 .map(info -> info.newerVersion.toString().length()) 303 .sorted(Comparator.reverseOrder()).findFirst().orElse(0)); 304 maxOlderLength = Math.max(baselineJar.getVersion().length(), 305 infos.stream() 306 .map(info -> info.olderVersion.toString().length()) 307 .sorted(Comparator.reverseOrder()).findFirst().orElse(0)); 308 } 309 310 /// Format string. 311 /// 312 /// @return the string 313 /// 314 public String formatString() { 315 return "%s %-" + maxNameLength + "s %-10s %-10s %-" 316 + maxNewerLength + "s %-" + maxOlderLength + "s %-10s %s\n"; 317 } 318 319 /// Separator string. 320 /// 321 /// @return the string 322 /// 323 public String separatorLine() { 324 return String.valueOf('=').repeat( 325 50 + maxNameLength + maxNewerLength + maxOlderLength) + "\n"; 326 } 327 } 328 329}