@@ -57,7 +57,7 @@ public class RequireBundleChecker extends DependencyChecker {
5757 private final List <BundleCheckData > pendingChecks = new ArrayList <>();
5858 private final Map <Path , Set <String >> exportedPackagesCache = new HashMap <>();
5959 private final Map <Path , Map <String , String >> reexportCache = new HashMap <>();
60- private final Map <String , Path > lowestArtifactCache = new HashMap <>();
60+ private final Map <String , ArtifactVersion > lowestArtifactCache = new HashMap <>();
6161
6262 private record BundleCheckData (String bundleName , String bundleVersionStr , IInstallableUnit unit ,
6363 Version compiledAgainstVersion , org .eclipse .equinox .p2 .metadata .Version matchedBundleVersion ,
@@ -138,11 +138,16 @@ public void complete() throws MojoFailureException {
138138 // (compiled-against) version for accurate split-package detection
139139 Set <String > visited = new HashSet <>();
140140 visited .add (data .bundleName ());
141- List <Path > reexportArtifacts = resolveReexportChain (data .compiledAgainstArtifact (), visited ,
142- this ::findCurrentBundleArtifact );
143- for (Path reexportArtifact : reexportArtifacts ) {
144- exportedPkgs .addAll (getExportedPackagesFromJar (reexportArtifact ));
145- ClassCollection reCC = context .getClassCollection (reexportArtifact );
141+ List <ArtifactVersion > reexportArtifacts = resolveReexportChain (data .compiledAgainstArtifact (), visited ,
142+ this ::findCurrentBundleArtifactVersion );
143+ for (ArtifactVersion reexportAV : reexportArtifacts ) {
144+ Path reexportPath = reexportAV .getArtifact ();
145+ ClassCollection reCC = getClassCollectionWithFragments (reexportAV );
146+ if (reCC .provides ().findAny ().isEmpty ()) {
147+ log .warn ("Skip re-exported " +reexportPath +" because it seem not contain any classes" );
148+ continue ;
149+ }
150+ exportedPkgs .addAll (getExportedPackagesFromJar (reexportPath ));
146151 reCC .provides ().map (MethodSignature ::className ).forEach (classNames ::add );
147152 }
148153 bundleExportedPackages .put (data .bundleName (), exportedPkgs );
@@ -201,10 +206,26 @@ private void checkBundle(BundleCheckData data, Set<String> splitPackages, Set<St
201206 // Resolve re-exported bundles for this version and include their packages
202207 Set <String > visited = new HashSet <>();
203208 visited .add (bundleName );
204- List <Path > reexportArtifacts = resolveReexportChain (artifact , visited ,
205- this ::findLowestMatchingBundleArtifact );
206- for (Path reexportArtifact : reexportArtifacts ) {
207- exportedPackages .addAll (getExportedPackagesFromJar (reexportArtifact ));
209+ Map <String , String > reexportProvenance = new HashMap <>();
210+ List <ArtifactVersion > reexportArtifacts = resolveReexportChain (artifact , visited ,
211+ this ::findLowestMatchingBundleArtifactVersion );
212+ List <ArtifactVersion > nonEmptyReexports = new ArrayList <>();
213+ for (ArtifactVersion reexportAV : reexportArtifacts ) {
214+ Path reexportPath = reexportAV .getArtifact ();
215+ ClassCollection reCC = getClassCollectionWithFragments (reexportAV );
216+ if (reCC .provides ().findAny ().isEmpty ()) {
217+ continue ;
218+ }
219+ nonEmptyReexports .add (reexportAV );
220+ String reexportBundleName = findBundleNameFromJar (reexportPath );
221+ for (String pkg : getExportedPackagesFromJar (reexportPath )) {
222+ exportedPackages .add (pkg );
223+ if (!reexportBundleName .isEmpty ()) {
224+ reexportProvenance .put (pkg ,
225+ String .format ("re-exported by `%s` from require-bundle `%s`" ,
226+ reexportBundleName , bundleName ));
227+ }
228+ }
208229 }
209230 Set <MethodSignature > bundleMethods = new TreeSet <>();
210231 Map <MethodSignature , Collection <String >> references = new HashMap <>();
@@ -230,12 +251,12 @@ private void checkBundle(BundleCheckData data, Set<String> splitPackages, Set<St
230251 }
231252 // Check against combined collection: main bundle + re-exported bundles
232253 List <ClassCollection > collections = new ArrayList <>();
233- collections .add (context . getClassCollection ( artifact ));
234- for (Path reexportArtifact : reexportArtifacts ) {
235- collections .add (context . getClassCollection ( reexportArtifact ));
254+ collections .add (getClassCollectionWithFragments ( v ));
255+ for (ArtifactVersion reexportAV : nonEmptyReexports ) {
256+ collections .add (getClassCollectionWithFragments ( reexportAV ));
236257 }
237258 boolean ok = checkMethodsInCollections (collections , bundleMethods , bundleName , null , version , references ,
238- v , unit , bundleVersionStr , matchedBundleVersion , "Require-Bundle" );
259+ v , unit , bundleVersionStr , matchedBundleVersion , "Require-Bundle" , reexportProvenance );
239260 if (ok ) {
240261 lowestVersion .merge (bundleName , version , (v1 , v2 ) -> v1 .compareTo (v2 ) > 0 ? v2 : v1 );
241262 }
@@ -251,23 +272,24 @@ private void checkBundle(BundleCheckData data, Set<String> splitPackages, Set<St
251272 * @param bundleJarPath the JAR to read re-exports from
252273 * @param visited set of already-visited bundle names (for cycle
253274 * prevention)
254- * @param artifactFinder strategy to find a bundle artifact given name and range
255- * @return list of artifact paths for all transitively re-exported bundles
275+ * @param artifactFinder strategy to find a bundle artifact version given name
276+ * and range
277+ * @return list of artifact versions for all transitively re-exported bundles
256278 */
257- private List <Path > resolveReexportChain (Path bundleJarPath , Set <String > visited ,
258- BiFunction <String , VersionRange , Path > artifactFinder ) {
259- List <Path > result = new ArrayList <>();
279+ private List <ArtifactVersion > resolveReexportChain (Path bundleJarPath , Set <String > visited ,
280+ BiFunction <String , VersionRange , ArtifactVersion > artifactFinder ) {
281+ List <ArtifactVersion > result = new ArrayList <>();
260282 Map <String , String > reexports = getReexportedBundlesFromJar (bundleJarPath );
261283 for (Map .Entry <String , String > entry : reexports .entrySet ()) {
262284 String reexportName = entry .getKey ();
263285 if (!visited .add (reexportName )) {
264286 continue ;
265287 }
266288 VersionRange range = VersionRange .valueOf (entry .getValue ());
267- Path reexportArtifact = artifactFinder .apply (reexportName , range );
268- if (reexportArtifact != null ) {
269- result .add (reexportArtifact );
270- result .addAll (resolveReexportChain (reexportArtifact , visited , artifactFinder ));
289+ ArtifactVersion reexportAV = artifactFinder .apply (reexportName , range );
290+ if (reexportAV != null && reexportAV . getArtifact () != null ) {
291+ result .add (reexportAV );
292+ result .addAll (resolveReexportChain (reexportAV . getArtifact () , visited , artifactFinder ));
271293 }
272294 }
273295 return result ;
@@ -280,9 +302,9 @@ private List<Path> resolveReexportChain(Path bundleJarPath, Set<String> visited,
280302 *
281303 * @param bundleName the symbolic name of the bundle
282304 * @param range the version range to match
283- * @return the path to the lowest matching artifact, or {@code null}
305+ * @return the lowest matching artifact version , or {@code null}
284306 */
285- private Path findLowestMatchingBundleArtifact (String bundleName , VersionRange range ) {
307+ private ArtifactVersion findLowestMatchingBundleArtifactVersion (String bundleName , VersionRange range ) {
286308 String cacheKey = bundleName + ":" + range ;
287309 return lowestArtifactCache .computeIfAbsent (cacheKey , k -> {
288310 Optional <IInstallableUnit > bundleUnit = ArtifactMatcher .findBundle (bundleName , units );
@@ -293,21 +315,21 @@ private Path findLowestMatchingBundleArtifact(String bundleName, VersionRange ra
293315 return context .getVersionProviders ().stream ()
294316 .flatMap (avp -> avp .getBundleVersions (iu , bundleName , range , context .getProject ()))
295317 .filter (av -> av .getVersion () != null && av .getArtifact () != null )
296- .min (Comparator .comparing (ArtifactVersion ::getVersion )). map ( ArtifactVersion :: getArtifact )
318+ .min (Comparator .comparing (ArtifactVersion ::getVersion ))
297319 .orElse (null );
298320 });
299321 }
300322
301323 /**
302- * Finds the current (compiled-against / target platform) artifact for a bundle.
303- * Used during Phase 1 to determine class names for accurate split-package
304- * detection with re-exported bundles.
324+ * Finds the current (compiled-against / target platform) artifact version for a
325+ * bundle. Used during Phase 1 to determine class names for accurate
326+ * split-package detection with re-exported bundles.
305327 *
306328 * @param bundleName the symbolic name of the bundle
307329 * @param range the version range (used to filter available versions)
308- * @return the path to the current version's artifact, or {@code null}
330+ * @return the current version's artifact version , or {@code null}
309331 */
310- private Path findCurrentBundleArtifact (String bundleName , VersionRange range ) {
332+ private ArtifactVersion findCurrentBundleArtifactVersion (String bundleName , VersionRange range ) {
311333 Optional <IInstallableUnit > bundleUnit = ArtifactMatcher .findBundle (bundleName , units );
312334 if (bundleUnit .isEmpty ()) {
313335 return null ;
@@ -320,13 +342,37 @@ private Path findCurrentBundleArtifact(String bundleName, VersionRange range) {
320342 return context .getVersionProviders ().stream ()
321343 .flatMap (avp -> avp .getBundleVersions (iu , bundleName , range , context .getProject ()))
322344 .filter (av -> av .getVersion () != null && av .getVersion ().equals (current ) && av .getArtifact () != null )
323- .findFirst ().map ( ArtifactVersion :: getArtifact ). orElse (null );
345+ .findFirst ().orElse (null );
324346 }
325347
326348 private Set <String > getExportedPackagesFromJar (Path jarPath ) {
327349 return exportedPackagesCache .computeIfAbsent (jarPath , this ::readExportedPackagesFromJar );
328350 }
329351
352+ /**
353+ * Reads the {@code Bundle-SymbolicName} from a JAR's manifest.
354+ *
355+ * @param jarPath the JAR file to read
356+ * @return the symbolic name, or an empty string if unavailable
357+ */
358+ private String findBundleNameFromJar (Path jarPath ) {
359+ try (JarFile jar = new JarFile (jarPath .toFile ())) {
360+ Manifest manifest = jar .getManifest ();
361+ if (manifest != null ) {
362+ String bsn = manifest .getMainAttributes ().getValue (Constants .BUNDLE_SYMBOLICNAME );
363+ if (bsn != null ) {
364+ ManifestElement [] elements = ManifestElement .parseHeader (Constants .BUNDLE_SYMBOLICNAME , bsn );
365+ if (elements != null && elements .length > 0 ) {
366+ return elements [0 ].getValue ();
367+ }
368+ }
369+ }
370+ } catch (BundleException | java .io .IOException e ) {
371+ context .getLog ().debug ("Could not read bundle name from " + jarPath + ": " + e );
372+ }
373+ return "" ;
374+ }
375+
330376 private Set <String > readExportedPackagesFromJar (Path jarPath ) {
331377 Set <String > packages = new HashSet <>();
332378 try (JarFile jar = new JarFile (jarPath .toFile ())) {
@@ -348,6 +394,60 @@ private Set<String> readExportedPackagesFromJar(Path jarPath) {
348394 return packages ;
349395 }
350396
397+ /**
398+ * Checks whether a JAR's manifest declares {@code Eclipse-ExtensibleAPI: true},
399+ * indicating it is a host bundle that expects fragments to provide classes.
400+ *
401+ * @param jarPath the JAR file to check
402+ * @return {@code true} if the bundle has extensible API
403+ */
404+ private boolean isExtensibleAPI (Path jarPath ) {
405+ try (JarFile jar = new JarFile (jarPath .toFile ())) {
406+ Manifest manifest = jar .getManifest ();
407+ if (manifest != null ) {
408+ return "true" .equalsIgnoreCase (manifest .getMainAttributes ().getValue ("Eclipse-ExtensibleAPI" ));
409+ }
410+ } catch (java .io .IOException e ) {
411+ context .getLog ().debug ("Could not read manifest from " + jarPath + ": " + e );
412+ }
413+ return false ;
414+ }
415+
416+ /**
417+ * Gets a {@link ClassCollection} for a bundle artifact version, resolving
418+ * fragment classes when the JAR is an empty host bundle with
419+ * {@code Eclipse-ExtensibleAPI: true}. Uses
420+ * {@link ArtifactVersion#fragments()} to find fragments in the same
421+ * repository where the host was found.
422+ *
423+ * @param av the artifact version to analyze
424+ * @return the class collection (potentially augmented with fragment classes)
425+ */
426+ private ClassCollection getClassCollectionWithFragments (ArtifactVersion av ) {
427+ Path jarPath = av .getArtifact ();
428+ if (jarPath == null ) {
429+ return new ClassCollection ();
430+ }
431+ ClassCollection hostCC = context .getClassCollection (jarPath );
432+ if (hostCC .provides ().findAny ().isPresent ()) {
433+ return hostCC ;
434+ }
435+ // Empty JAR — check if it's an extensible API host
436+ if (!isExtensibleAPI (jarPath )) {
437+ return hostCC ;
438+ }
439+ // Ask the artifact version for fragments from the same repository
440+ ArtifactVersion fragment = av .fragments ()
441+ .filter (f -> f .getArtifact () != null )
442+ .findFirst ().orElse (null );
443+ if (fragment == null ) {
444+ context .getLog ().debug ("No fragment found for extensible API host " + av );
445+ return hostCC ;
446+ }
447+ context .getLog ().debug ("Resolved fragment for " + av + ": " + fragment );
448+ return context .getClassCollection (fragment .getArtifact ());
449+ }
450+
351451 /**
352452 * Reads a JAR's manifest for {@code Require-Bundle} entries with
353453 * {@code visibility:=reexport} and returns them as a map from bundle symbolic
0 commit comments