This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new b7b4b615b2 Shorts collection aliases,
ChecksumUtils/ClasspathAssetCache extraction, Sonar+Eclipse cleanup
b7b4b615b2 is described below
commit b7b4b615b23cafc25157b0ee00d2310c2dffffcb
Author: James Bognar <[email protected]>
AuthorDate: Wed Aug 19 13:08:17 2026 -0400
Shorts collection aliases, ChecksumUtils/ClasspathAssetCache extraction,
Sonar+Eclipse cleanup
- Add Shorts collection aliases (tl/ts/los/ist/im) and
CollectionUtils.immutableSet/immutableMap
- Extract ChecksumUtils and ClasspathAssetCache (de-dup asset caching logic
shared by console-ui and views modules) with tests
- Sonar and Eclipse-warning cleanup across juneau-rest-server-console-ui,
juneau-rest-server-views, and CI workflow comments (keychain ->
macos-keychain rename follow-ups, supply-chain pinning policy notes)
- Add headless scripts/eclipse-warnings.py and minor todo-tracker script
fixes
---
.github/workflows/codeql-analysis.yml | 6 +
.github/workflows/maven.yml | 6 +
.github/workflows/sonarcloud.yml | 6 +
.../apache/juneau/commons/utils/ChecksumUtils.java | 45 ++++
.../juneau/commons/utils/CollectionUtils.java | 14 ++
.../org/apache/juneau/commons/utils/Shorts.java | 235 +++++++++++++++++++++
.../juneau/commons/utils/ChecksumUtils_Test.java | 61 ++++++
.../juneau/commons/utils/CollectionUtils_Test.java | 33 +++
.../apache/juneau/commons/utils/Shorts_Test.java | 78 ++++++-
.../rest/server/console/ConsoleChromeMixin.java | 77 ++-----
.../juneau/rest/server/views/ViewsMixin.java | 65 +-----
.../rest/server/views/ViewsMixin_Serving_Test.java | 19 ++
.../rest/server/util/ClasspathAssetCache.java | 160 ++++++++++++++
.../rest/server/util/ClasspathAssetCache_Test.java | 185 ++++++++++++++++
scripts/eclipse-warnings.py | 204 ++++++++++++++++++
scripts/todo-next-id.py | 4 +-
scripts/todo-status-audit.py | 30 ++-
17 files changed, 1100 insertions(+), 128 deletions(-)
diff --git a/.github/workflows/codeql-analysis.yml
b/.github/workflows/codeql-analysis.yml
index dd46600ef8..31d6824bb2 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -14,6 +14,12 @@
# * specific language governing permissions and limitations under the License.
*
#
***************************************************************************************************************************
#
+# Supply-chain pinning policy: every third-party Action below is pinned to a
full commit SHA,
+# not a floating tag, because a tag is mutable and a SHA pin is what makes
"what code ran in CI"
+# part of this project's provenance story. To bump: resolve the new tag to its
full commit SHA
+# and update the trailing version comment to match. Same policy applies to
maven.yml and
+# sonarcloud.yml.
+#
name: "CodeQL"
on:
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index 6cab9b15f7..781d9c9533 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -12,6 +12,12 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
+#
+# Supply-chain pinning policy: every third-party Action below is pinned to a
full commit SHA,
+# not a floating tag, because a tag is mutable and a SHA pin is what makes
"what code ran in CI"
+# part of this project's provenance story. To bump: resolve the new tag to its
full commit SHA
+# and update the trailing "# vX.Y.Z" comment to match. Same policy applies to
codeql-analysis.yml
+# and sonarcloud.yml.
name: Java CI
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index 55368ab86e..c3131c4c64 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -12,6 +12,12 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
+#
+# Supply-chain pinning policy: every third-party Action below is pinned to a
full commit SHA,
+# not a floating tag, because a tag is mutable and a SHA pin is what makes
"what code ran in CI"
+# part of this project's provenance story. To bump: resolve the new tag to its
full commit SHA
+# and update the trailing "# vX.Y.Z" comment to match. Same policy applies to
maven.yml and
+# codeql-analysis.yml.
name: SonarQube
on:
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/ChecksumUtils.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/ChecksumUtils.java
new file mode 100644
index 0000000000..558feef951
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/ChecksumUtils.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.utils;
+
+import java.util.zip.*;
+
+/**
+ * Content-checksum utility methods.
+ */
+public class ChecksumUtils {
+
+ /** Prevents instantiation. */
+ private ChecksumUtils() {}
+
+ /**
+ * Formats a CRC32 checksum of the specified bytes as a zero-padded,
lower-case, 8-hex-char content hash.
+ *
+ * <p>
+ * Suitable as a compact, content-sensitive cache-buster for
classpath-shipped assets (JS/CSS/images, etc.):
+ * the hash changes the instant the underlying bytes do, without
relying on a build/version string alone
+ * (which stays stable across {@code -SNAPSHOT} dev rebuilds and so
cannot bust a browser's cache on its own).
+ *
+ * @param bytes The bytes to checksum. Must not be <jk>null</jk>.
+ * @return The zero-padded 8-hex-char CRC32 checksum (e.g. {@code
"1a2b3c4d"}).
+ */
+ public static String hash8(byte[] bytes) {
+ var crc = new CRC32();
+ crc.update(bytes);
+ return String.format("%08x", crc.getValue());
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/CollectionUtils.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/CollectionUtils.java
index 14d1d42cb8..e9b2681730 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/CollectionUtils.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/CollectionUtils.java
@@ -1016,6 +1016,20 @@ public class CollectionUtils {
return new SimpleMap<>(array(k1, k2, k3, k4, k5, k6, k7, k8,
k9, k10), array(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10));
}
+ /**
+ * Convenience method for creating an unmodifiable set.
+ * Unlike Set.of(...), supports a null element and preserves insertion
order.
+ *
+ * @param <E> The element type.
+ * @param values The values to initialize the set with. Must not be
<jk>null</jk> (a <jk>null</jk> array throws {@link IllegalArgumentException}).
+ * @return A new unmodifiable set.
+ */
+ @SafeVarargs
+ public static <E> Set<E> immutableSet(E...values) {
+ assertArgNotNull(ARG_values, values);
+ return Collections.unmodifiableSet(new
LinkedHashSet<>(Arrays.asList(values)));
+ }
+
/**
* Convenience method for creating a {@link LinkedHashMap}.
*
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/Shorts.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/Shorts.java
index a81633282b..5698b08af7 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/Shorts.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/Shorts.java
@@ -1045,6 +1045,241 @@ public class Shorts {
@SafeVarargs
public static <E> SortedSet<E> ss(E...v) { return
CollectionUtils.sortedSet(v); }
+ /**
+ * Creates a modifiable {@link ArrayList} copy of the specified
collection.
+ *
+ * @param <E> The element type.
+ * @param v The collection to copy from. Can be <jk>null</jk> (returns
an empty list, not <jk>null</jk>).
+ * @return A new modifiable list.
+ * @see CollectionUtils#toList(Collection)
+ */
+ public static <E> List<E> tl(Collection<E> v) { return
CollectionUtils.toList(v); }
+
+ /**
+ * Creates a modifiable {@link LinkedHashSet} copy of the specified
collection.
+ *
+ * @param <E> The element type.
+ * @param v The collection to copy from. Can be <jk>null</jk> (returns
<jk>null</jk>).
+ * @return A new modifiable set, or <jk>null</jk> if the input was
<jk>null</jk>.
+ * @see CollectionUtils#toSet(Collection)
+ */
+ public static <E> Set<E> ts(Collection<E> v) { return
CollectionUtils.toSet(v); }
+
+ /**
+ * Creates a modifiable, empty {@link ArrayList} presized to the
specified capacity.
+ *
+ * @param <E> The element type.
+ * @param n The initial capacity.
+ * @return A new modifiable, empty list.
+ * @see CollectionUtils#listOfSize(int)
+ */
+ public static <E> List<E> los(int n) { return
CollectionUtils.listOfSize(n); }
+
+ /**
+ * Creates an unmodifiable set from the specified elements. Unlike
{@link Set#of(Object...)},
+ * permits a <jk>null</jk> element and preserves insertion order.
+ *
+ * @param <E> The element type.
+ * @param v The elements. Must not be <jk>null</jk> (a <jk>null</jk>
array throws {@link IllegalArgumentException}).
+ * @return A new unmodifiable set.
+ * @see CollectionUtils#immutableSet(Object...)
+ */
+ @SafeVarargs
+ public static <E> Set<E> ist(E...v) { return
CollectionUtils.immutableSet(v); }
+
+ /**
+ * Creates an unmodifiable, empty map.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @return A new unmodifiable empty map.
+ * @see CollectionUtils#immutableMap()
+ */
+ public static <K,V> Map<K,V> im() { return
CollectionUtils.immutableMap(); }
+
+ /**
+ * Creates an unmodifiable map with 1 entry. Unlike {@link
Map#of(Object,Object)}, permits a
+ * <jk>null</jk> key or value and preserves insertion order. Mutating
the returned map (e.g.
+ * <jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>) throws {@link
UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 The key.
+ * @param v1 The value.
+ * @return A new unmodifiable map.
+ * @see CollectionUtils#immutableMap(Object,Object)
+ */
+ public static <K,V> Map<K,V> im(K k1, V v1) { return
CollectionUtils.immutableMap(k1, v1); }
+
+ /**
+ * Creates an unmodifiable map with 2 entries. Unlike {@link
Map#of(Object,Object,Object,Object)}, permits
+ * <jk>null</jk> keys/values and preserves insertion order. Mutating
the returned map (e.g.
+ * <jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>) throws {@link
UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 The first key.
+ * @param v1 The first value.
+ * @param k2 The second key.
+ * @param v2 The second value.
+ * @return A new unmodifiable map.
+ * @throws IllegalArgumentException if <jv>k1</jv> and <jv>k2</jv> are
duplicate keys.
+ * @see CollectionUtils#immutableMap(Object,Object,Object,Object)
+ */
+ public static <K,V> Map<K,V> im(K k1, V v1, K k2, V v2) { return
CollectionUtils.immutableMap(k1, v1, k2, v2); }
+
+ /**
+ * Creates an unmodifiable map with 3 entries. Unlike {@link
Map#of(Object,Object,Object,Object,Object,Object)},
+ * permits <jk>null</jk> keys/values and preserves insertion order.
Mutating the returned map (e.g.
+ * <jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>) throws {@link
UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 The first key.
+ * @param v1 The first value.
+ * @param k2 The second key.
+ * @param v2 The second value.
+ * @param k3 The third key.
+ * @param v3 The third value.
+ * @return A new unmodifiable map.
+ * @throws IllegalArgumentException if any of the keys are duplicated.
+ * @see
CollectionUtils#immutableMap(Object,Object,Object,Object,Object,Object)
+ */
+ public static <K,V> Map<K,V> im(K k1, V v1, K k2, V v2, K k3, V v3) {
return CollectionUtils.immutableMap(k1, v1, k2, v2, k3, v3); }
+
+ /**
+ * Creates an unmodifiable map with 4 entries (keys/values in
alternating order). Unlike
+ * {@link
Map#of(Object,Object,Object,Object,Object,Object,Object,Object)}, permits
<jk>null</jk> keys/values
+ * and preserves insertion order. Mutating the returned map (e.g.
<jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>)
+ * throws {@link UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 Key 1. @param v1 Value 1. @param k2 Key 2. @param v2 Value
2.
+ * @param k3 Key 3. @param v3 Value 3. @param k4 Key 4. @param v4 Value
4.
+ * @return A new unmodifiable map.
+ * @throws IllegalArgumentException if any of the keys are duplicated.
+ * @see
CollectionUtils#immutableMap(Object,Object,Object,Object,Object,Object,Object,Object)
+ */
+ @SuppressWarnings({
+ "java:S107" // Fixed-arity terse map-factory overload; the many
parameters are intentional alternating key/value pairs mirroring map entries.
+ })
+ public static <K,V> Map<K,V> im(K k1, V v1, K k2, V v2, K k3, V v3, K
k4, V v4) { return CollectionUtils.immutableMap(k1, v1, k2, v2, k3, v3, k4,
v4); }
+
+ /**
+ * Creates an unmodifiable map with 5 entries (keys/values in
alternating order). Unlike
+ * {@link
Map#of(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)},
permits <jk>null</jk>
+ * keys/values and preserves insertion order. Mutating the returned
map (e.g.
+ * <jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>) throws {@link
UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 Key 1. @param v1 Value 1. @param k2 Key 2. @param v2 Value
2. @param k3 Key 3. @param v3 Value 3.
+ * @param k4 Key 4. @param v4 Value 4. @param k5 Key 5. @param v5 Value
5.
+ * @return A new unmodifiable map.
+ * @throws IllegalArgumentException if any of the keys are duplicated.
+ * @see
CollectionUtils#immutableMap(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)
+ */
+ @SuppressWarnings({
+ "java:S107" // Fixed-arity terse map-factory overload; the many
parameters are intentional alternating key/value pairs mirroring map entries.
+ })
+ public static <K,V> Map<K,V> im(K k1, V v1, K k2, V v2, K k3, V v3, K
k4, V v4, K k5, V v5) { return CollectionUtils.immutableMap(k1, v1, k2, v2, k3,
v3, k4, v4, k5, v5); }
+
+ /**
+ * Creates an unmodifiable map with 6 entries (keys/values in
alternating order). Unlike
+ * {@link
Map#of(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)},
permits
+ * <jk>null</jk> keys/values and preserves insertion order. Mutating
the returned map (e.g.
+ * <jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>) throws {@link
UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 Key 1. @param v1 Value 1. @param k2 Key 2. @param v2 Value
2. @param k3 Key 3. @param v3 Value 3.
+ * @param k4 Key 4. @param v4 Value 4. @param k5 Key 5. @param v5 Value
5. @param k6 Key 6. @param v6 Value 6.
+ * @return A new unmodifiable map.
+ * @throws IllegalArgumentException if any of the keys are duplicated.
+ * @see
CollectionUtils#immutableMap(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)
+ */
+ @SuppressWarnings({
+ "java:S107" // Fixed-arity terse map-factory overload; the many
parameters are intentional alternating key/value pairs mirroring map entries.
+ })
+ public static <K,V> Map<K,V> im(K k1, V v1, K k2, V v2, K k3, V v3, K
k4, V v4, K k5, V v5, K k6, V v6) { return CollectionUtils.immutableMap(k1, v1,
k2, v2, k3, v3, k4, v4, k5, v5, k6, v6); }
+
+ /**
+ * Creates an unmodifiable map with 7 entries (keys/values in
alternating order). Unlike
+ * {@link
Map#of(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)},
+ * permits <jk>null</jk> keys/values and preserves insertion order.
Mutating the returned map (e.g.
+ * <jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>) throws {@link
UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 Key 1. @param v1 Value 1. @param k2 Key 2. @param v2 Value
2. @param k3 Key 3. @param v3 Value 3. @param k4 Key 4. @param v4 Value 4.
+ * @param k5 Key 5. @param v5 Value 5. @param k6 Key 6. @param v6 Value
6. @param k7 Key 7. @param v7 Value 7.
+ * @return A new unmodifiable map.
+ * @throws IllegalArgumentException if any of the keys are duplicated.
+ * @see
CollectionUtils#immutableMap(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)
+ */
+ @SuppressWarnings({
+ "java:S107" // Fixed-arity terse map-factory overload; the many
parameters are intentional alternating key/value pairs mirroring map entries.
+ })
+ public static <K,V> Map<K,V> im(K k1, V v1, K k2, V v2, K k3, V v3, K
k4, V v4, K k5, V v5, K k6, V v6, K k7, V v7) { return
CollectionUtils.immutableMap(k1, v1, k2, v2, k3, v3, k4, v4, k5, v5, k6, v6,
k7, v7); }
+
+ /**
+ * Creates an unmodifiable map with 8 entries (keys/values in
alternating order). Unlike
+ * {@link
Map#of(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)},
+ * permits <jk>null</jk> keys/values and preserves insertion order.
Mutating the returned map (e.g.
+ * <jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>) throws {@link
UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 Key 1. @param v1 Value 1. @param k2 Key 2. @param v2 Value
2. @param k3 Key 3. @param v3 Value 3. @param k4 Key 4. @param v4 Value 4.
+ * @param k5 Key 5. @param v5 Value 5. @param k6 Key 6. @param v6 Value
6. @param k7 Key 7. @param v7 Value 7. @param k8 Key 8. @param v8 Value 8.
+ * @return A new unmodifiable map.
+ * @throws IllegalArgumentException if any of the keys are duplicated.
+ * @see
CollectionUtils#immutableMap(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)
+ */
+ @SuppressWarnings({
+ "java:S107" // Fixed-arity terse map-factory overload; the many
parameters are intentional alternating key/value pairs mirroring map entries.
+ })
+ public static <K,V> Map<K,V> im(K k1, V v1, K k2, V v2, K k3, V v3, K
k4, V v4, K k5, V v5, K k6, V v6, K k7, V v7, K k8, V v8) { return
CollectionUtils.immutableMap(k1, v1, k2, v2, k3, v3, k4, v4, k5, v5, k6, v6,
k7, v7, k8, v8); }
+
+ /**
+ * Creates an unmodifiable map with 9 entries (keys/values in
alternating order). Unlike
+ * {@link
Map#of(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)},
+ * permits <jk>null</jk> keys/values and preserves insertion order.
Mutating the returned map (e.g.
+ * <jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>) throws {@link
UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 Key 1. @param v1 Value 1. @param k2 Key 2. @param v2 Value
2. @param k3 Key 3. @param v3 Value 3. @param k4 Key 4. @param v4 Value 4.
@param k5 Key 5. @param v5 Value 5.
+ * @param k6 Key 6. @param v6 Value 6. @param k7 Key 7. @param v7 Value
7. @param k8 Key 8. @param v8 Value 8. @param k9 Key 9. @param v9 Value 9.
+ * @return A new unmodifiable map.
+ * @throws IllegalArgumentException if any of the keys are duplicated.
+ * @see
CollectionUtils#immutableMap(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)
+ */
+ @SuppressWarnings({
+ "java:S107" // Fixed-arity terse map-factory overload; the many
parameters are intentional alternating key/value pairs mirroring map entries.
+ })
+ public static <K,V> Map<K,V> im(K k1, V v1, K k2, V v2, K k3, V v3, K
k4, V v4, K k5, V v5, K k6, V v6, K k7, V v7, K k8, V v8, K k9, V v9) { return
CollectionUtils.immutableMap(k1, v1, k2, v2, k3, v3, k4, v4, k5, v5, k6, v6,
k7, v7, k8, v8, k9, v9); }
+
+ /**
+ * Creates an unmodifiable map with 10 entries (keys/values in
alternating order). Unlike
+ * {@link
Map#of(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)},
+ * permits <jk>null</jk> keys/values and preserves insertion order.
Mutating the returned map (e.g.
+ * <jk>put</jk>/<jk>remove</jk>/<jk>clear</jk>) throws {@link
UnsupportedOperationException}.
+ *
+ * @param <K> The key type.
+ * @param <V> The value type.
+ * @param k1 Key 1. @param v1 Value 1. @param k2 Key 2. @param v2 Value
2. @param k3 Key 3. @param v3 Value 3. @param k4 Key 4. @param v4 Value 4.
@param k5 Key 5. @param v5 Value 5.
+ * @param k6 Key 6. @param v6 Value 6. @param k7 Key 7. @param v7 Value
7. @param k8 Key 8. @param v8 Value 8. @param k9 Key 9. @param v9 Value 9.
@param k10 Key 10. @param v10 Value 10.
+ * @return A new unmodifiable map.
+ * @throws IllegalArgumentException if any of the keys are duplicated.
+ * @see
CollectionUtils#immutableMap(Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object,Object)
+ */
+ @SuppressWarnings({
+ "java:S107" // Fixed-arity terse map-factory overload; the many
parameters are intentional alternating key/value pairs mirroring map entries.
+ })
+ public static <K,V> Map<K,V> im(K k1, V v1, K k2, V v2, K k3, V v3, K
k4, V v4, K k5, V v5, K k6, V v6, K k7, V v7, K k8, V v8, K k9, V v9, K k10, V
v10) { return CollectionUtils.immutableMap(k1, v1, k2, v2, k3, v3, k4, v4, k5,
v5, k6, v6, k7, v7, k8, v8, k9, v9, k10, v10); }
+
/**
* Returns an unmodifiable view of the specified list.
*
diff --git
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/ChecksumUtils_Test.java
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/ChecksumUtils_Test.java
new file mode 100644
index 0000000000..ae05a7b79e
--- /dev/null
+++
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/ChecksumUtils_Test.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.utils;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.charset.*;
+import java.util.zip.*;
+
+import org.apache.juneau.commons.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests {@link ChecksumUtils}.
+ */
+class ChecksumUtils_Test extends TestBase {
+
+ @Test void a01_hash8_emptyInput_isAllZeros() {
+ // CRC32 of zero bytes is 0, so the zero-padded hex form is all
zeros.
+ assertEquals("00000000", ChecksumUtils.hash8(new byte[0]));
+ }
+
+ @Test void a02_hash8_knownVector_matchesJavaUtilZipCrc32() {
+ var bytes = "The quick brown fox jumps over the lazy
dog".getBytes(StandardCharsets.UTF_8);
+ var crc = new CRC32();
+ crc.update(bytes);
+ assertEquals(String.format("%08x", crc.getValue()),
ChecksumUtils.hash8(bytes));
+ // Widely-published CRC32 test vector for this exact string -
pins the value, not just the delegation.
+ assertEquals("414fa339", ChecksumUtils.hash8(bytes));
+ }
+
+ @Test void a03_hash8_leadingZeroPadding_isNotTruncated() {
+ // CRC32("62") == 0x0012d20a - fewer than 8 significant hex
digits, so this pins that hash8 zero-pads
+ // rather than truncates (a raw
Long.toHexString(...)/Integer.toHexString(...) would drop the leading
+ // zeros and return a shorter string).
+ assertEquals("0012d20a",
ChecksumUtils.hash8("62".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test void a04_hash8_distinctInputs_produceDifferentHashes() {
+
assertNotEquals(ChecksumUtils.hash8("a".getBytes(StandardCharsets.UTF_8)),
ChecksumUtils.hash8("b".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test void a05_hash8_sameInput_isDeterministic() {
+ var bytes = "deterministic".getBytes(StandardCharsets.UTF_8);
+ assertEquals(ChecksumUtils.hash8(bytes),
ChecksumUtils.hash8(bytes));
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/CollectionUtils_Test.java
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/CollectionUtils_Test.java
index 9bdd1fc9b8..6d12cd46c9 100644
---
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/CollectionUtils_Test.java
+++
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/CollectionUtils_Test.java
@@ -1750,4 +1750,37 @@ class CollectionUtils_Test extends TestBase {
List<Object> deepNested = deepStream((Object[])
nested).toList();
assertEquals(list("a", "b", "c"), deepNested); // fully
flattened
}
+
+
//====================================================================================================
+ // immutableSet(E...)
+
//====================================================================================================
+ @Test
+ void a102_immutableSet() {
+ // Empty
+ Set<String> empty = immutableSet();
+ assertNotNull(empty);
+ assertTrue(empty.isEmpty());
+
+ // Multiple elements, insertion order preserved
+ Set<String> s1 = immutableSet("z", "a", "m");
+ assertEquals(3, s1.size());
+ assertEquals(list("z", "a", "m"), new ArrayList<>(s1));
+
+ // Null element permitted (unlike Set.of)
+ Set<String> withNull = immutableSet("a", null, "b");
+ assertEquals(3, withNull.size());
+ assertTrue(withNull.contains(null));
+
+ // Duplicate elements de-duplicated like a normal Set
+ Set<String> deduped = immutableSet("a", "b", "a");
+ assertEquals(2, deduped.size());
+
+ // Null array rejected
+ assertThrows(IllegalArgumentException.class, () ->
immutableSet((String[]) null));
+
+ // Immutability
+ assertThrows(UnsupportedOperationException.class, () ->
s1.add("x"));
+ assertThrows(UnsupportedOperationException.class, () ->
s1.remove("z"));
+ assertThrows(UnsupportedOperationException.class, s1::clear);
+ }
}
diff --git
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/Shorts_Test.java
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/Shorts_Test.java
index b3ad163f56..7c870c2145 100644
---
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/Shorts_Test.java
+++
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/Shorts_Test.java
@@ -227,7 +227,83 @@ class Shorts_Test extends TestBase {
void c008_ints_intArray() { assertArrayEquals(new int[]{1, 2}, ints(1,
2)); }
@Test
- void c009_tl_toList() { assertEquals(List.of("a"),
CollectionUtils.toList(Set.of("a"))); }
+ void c009_tl_toList() { assertEquals(List.of("a"), tl(Set.of("a"))); }
+
+ @Test
+ void c010_tl_toList_nullReturnsEmpty() { assertEquals(List.of(),
tl(null)); }
+
+ @Test
+ void c011_ts_toSet() { assertEquals(Set.of("a"), ts(List.of("a",
"a"))); }
+
+ @Test
+ void c012_ts_toSet_nullReturnsNull() { assertNull(ts(null)); }
+
+ @Test
+ void c013_los_listOfSize() {
+ List<String> l = los(5);
+ assertTrue(l.isEmpty());
+ l.add("x");
+ assertEquals(List.of("x"), l);
+ }
+
+ @Test
+ void c014_ist_immutableSet() {
+ Set<String> s = ist("a", null, "b");
+ assertEquals(3, s.size());
+ assertTrue(s.contains(null));
+ assertThrows(UnsupportedOperationException.class, () ->
s.add("c"));
+ }
+
+ @Test
+ void c015_im_immutableMap_empty() {
+ Map<String,Integer> m = im();
+ assertTrue(m.isEmpty());
+ assertThrows(UnsupportedOperationException.class, () ->
m.put("x", 1));
+ }
+
+ @Test
+ void c016_im_immutableMap_insertionOrder() {
+ Map<String,Integer> m = im("z", 1, "a", 2, "m", 3, "b", 4);
+ assertEquals(List.of("z", "a", "m", "b"), new
ArrayList<>(m.keySet()));
+ }
+
+ @Test
+ void c017_im_immutableMap_nullKeyAndValue() {
+ Map<String,Integer> nullKey = im(null, 1);
+ assertEquals(1, nullKey.get(null));
+ assertTrue(nullKey.containsKey(null));
+
+ Map<String,Integer> nullValue = im("a", null);
+ assertNull(nullValue.get("a"));
+ assertTrue(nullValue.containsKey("a"));
+ }
+
+ @Test
+ void c018_im_immutableMap_duplicateKeyThrows() {
+ assertThrows(IllegalArgumentException.class, () -> im("a", 1,
"b", 2, "a", 3));
+ }
+
+ @Test
+ void c019_im_immutableMap_immutability() {
+ Map<String,Integer> m = im("a", 1, "b", 2);
+ assertThrows(UnsupportedOperationException.class, () ->
m.put("c", 3));
+ assertThrows(UnsupportedOperationException.class, () ->
m.remove("a"));
+ assertThrows(UnsupportedOperationException.class, m::clear);
+ }
+
+ @Test
+ void c020_im_immutableMap_allArities() {
+ assertEquals(1, im("a", 1).size());
+ assertEquals(2, im("a", 1, "b", 2).size());
+ assertEquals(3, im("a", 1, "b", 2, "c", 3).size());
+ assertEquals(4, im("a", 1, "b", 2, "c", 3, "d", 4).size());
+ assertEquals(5, im("a", 1, "b", 2, "c", 3, "d", 4, "e",
5).size());
+ assertEquals(6, im("a", 1, "b", 2, "c", 3, "d", 4, "e", 5, "f",
6).size());
+ assertEquals(7, im("a", 1, "b", 2, "c", 3, "d", 4, "e", 5, "f",
6, "g", 7).size());
+ assertEquals(8, im("a", 1, "b", 2, "c", 3, "d", 4, "e", 5, "f",
6, "g", 7, "h", 8).size());
+ assertEquals(9, im("a", 1, "b", 2, "c", 3, "d", 4, "e", 5, "f",
6, "g", 7, "h", 8, "i", 9).size());
+ assertEquals(10, im("a", 1, "b", 2, "c", 3, "d", 4, "e", 5,
"f", 6, "g", 7, "h", 8, "i", 9, "j", 10).size());
+ }
// ---- ClassUtils aliases ----
diff --git
a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
index 2ae17522f3..a913a84473 100644
---
a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
+++
b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
@@ -23,16 +23,13 @@ import java.nio.charset.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
-import java.util.zip.*;
import org.apache.juneau.commons.io.*;
import org.apache.juneau.commons.utils.*;
import org.apache.juneau.http.*;
-import org.apache.juneau.http.entity.*;
-import org.apache.juneau.http.header.*;
-import org.apache.juneau.http.resource.*;
import org.apache.juneau.http.response.*;
import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.util.*;
/**
* Mixin that serves the admin-console chrome stylesheet at {@code
/juneau-console/chrome.css}.
@@ -162,8 +159,12 @@ public class ConsoleChromeMixin {
/** The shipped static chrome.css bytes, read once from the classpath
(shared - the static file never varies by theme). */
private static volatile String staticCss;
- /** Per-resource content-hash cache for the configured
logo/page-background assets (populated on first request). */
- private static final Map<String,String> ASSET_HASH_CACHE = new
ConcurrentHashMap<>();
+ /**
+ * Read+cache+hash+serve helper for the configured logo/page-background
assets, anchored on this class so
+ * {@link ClasspathAssetCache#buildVersion()} resolves this module's
own implementation version (see that
+ * class's javadoc's version-anchor section).
+ */
+ private static final ClasspathAssetCache ASSET_CACHE = new
ClasspathAssetCache(ConsoleChromeMixin.class);
private final boolean cacheAssets;
private final Theme theme;
@@ -221,7 +222,7 @@ public class ConsoleChromeMixin {
)
public HttpResource getChromeCss(RestRequest req) throws IOException {
var body = cacheAssets ? cachedBody(req) : buildBody(req);
- return httpResource(body, CONTENT_TYPE);
+ return ASSET_CACHE.wrap(body, CONTENT_TYPE, CACHE_CONTROL);
}
/**
@@ -238,7 +239,7 @@ public class ConsoleChromeMixin {
public HttpResource getLogoAsset() throws IOException {
if (logoResource == null)
throw new NotFound("No logo asset configured.");
- return serveAsset(logoResource);
+ return ASSET_CACHE.serve(logoResource,
MimeTypeDetector.DEFAULT.getContentType(logoResource), CACHE_CONTROL);
}
/**
@@ -255,24 +256,7 @@ public class ConsoleChromeMixin {
public HttpResource getPageBackgroundAsset() throws IOException {
if (pageBackgroundResource == null)
throw new NotFound("No page-background asset
configured.");
- return serveAsset(pageBackgroundResource);
- }
-
- /** Reads and wraps a validated, already-configured classpath resource
as a cacheable {@link HttpResource}. */
- private static HttpResource serveAsset(String classpathResource) throws
IOException {
- byte[] bytes;
- try (var in =
ConsoleChromeMixin.class.getResourceAsStream(classpathResource)) {
- bytes = IoUtils.readBytes(in);
- }
- return httpResource(bytes,
MimeTypeDetector.DEFAULT.getContentType(classpathResource));
- }
-
- /** Wraps pre-computed bytes as a cacheable {@link HttpResource}
carrying the given content type. */
- private static HttpResource httpResource(byte[] bytes, String
contentType) {
- return HttpResourceBean.of(
- ByteArrayBody.of(bytes, contentType),
- CollectionUtils.list(ContentType.of(contentType),
CacheControl.of(CACHE_CONTROL))
- );
+ return ASSET_CACHE.serve(pageBackgroundResource,
MimeTypeDetector.DEFAULT.getContentType(pageBackgroundResource), CACHE_CONTROL);
}
/**
@@ -305,9 +289,10 @@ public class ConsoleChromeMixin {
/**
* Builds the response body: the static structural CSS, then
Theme.OPEN's block, then (if different) the active
* theme's override block, then (if configured) the
logo/page-background asset override rules. Each override
- * rule's {@code ?v=<buildVersion>-<hash8>} cache-buster is
content-sensitive (see {@link #assetContentHash},
- * mirroring {@code ViewsMixin}) so a {@code -SNAPSHOT} rebuild of the
configured asset busts the browser cache
- * without relying on {@code buildVersion} (stable across dev rebuilds)
alone.
+ * rule's {@code ?v=<buildVersion>-<hash8>} cache-buster is
content-sensitive (see
+ * {@link ClasspathAssetCache#cacheBuster}, mirroring {@code
ViewsMixin}) so a {@code -SNAPSHOT} rebuild of the
+ * configured asset busts the browser cache without relying on {@code
buildVersion} (stable across dev rebuilds)
+ * alone.
*/
private byte[] buildBody(RestRequest req) throws IOException {
buildCount.incrementAndGet();
@@ -318,11 +303,11 @@ public class ConsoleChromeMixin {
sb.append('\n').append(rootBlock(active));
if (pageBackgroundResource != null)
sb.append('\n').append("html,
body{background-image:url(\"").append(assetUrl(req, PAGE_BG_ASSET_PATH,
PAGE_BG_ASSET_PATH_UNPREFIXED))
-
.append("?v=").append(buildVersion()).append('-').append(assetContentHash(pageBackgroundResource))
+
.append(ASSET_CACHE.cacheBuster(pageBackgroundResource))
.append("\"), var(--jc-page-bg);}");
if (logoResource != null)
sb.append('\n').append(".jc-logo{background-image:url(\"").append(assetUrl(req,
LOGO_ASSET_PATH, LOGO_ASSET_PATH_UNPREFIXED))
-
.append("?v=").append(buildVersion()).append('-').append(assetContentHash(logoResource))
+ .append(ASSET_CACHE.cacheBuster(logoResource))
.append("\");}");
return sb.toString().getBytes(StandardCharsets.UTF_8);
}
@@ -348,36 +333,6 @@ public class ConsoleChromeMixin {
return req.getUriResolver().resolve("servlet:" + (standalone ?
unprefixedPath : prefixedPath));
}
- /**
- * Resolves the framework build version for asset cache-busting,
falling back to {@code "dev"} when unset
- * (e.g. running from IDE/test classpath rather than a packaged jar).
- */
- private static String buildVersion() {
- var v =
ConsoleChromeMixin.class.getPackage().getImplementationVersion();
- return v == null ? "dev" : v; // HTT: the non-null branch only
fires when running from a packaged jar with a manifest Implementation-Version -
unreachable when tests run against unpackaged target/classes.
- }
-
- /** Computes (and caches) the given classpath resource's 8-hex-char
content hash, read once per resource. */
- private static String assetContentHash(String classpathResource) {
- return ASSET_HASH_CACHE.computeIfAbsent(classpathResource,
ConsoleChromeMixin::readAndHash);
- }
-
- /** Reads a validated, already-configured classpath resource and hashes
its bytes. */
- private static String readAndHash(String classpathResource) {
- try (var in =
ConsoleChromeMixin.class.getResourceAsStream(classpathResource)) {
- return hash8(IoUtils.readBytes(in));
- } catch (IOException e) { // HTT: unreachable -
validateAssetResource already confirmed the resource exists.
- throw new UncheckedIOException(e);
- }
- }
-
- /** Formats a CRC32 checksum of {@code bytes} as a zero-padded
8-hex-char content hash. */
- private static String hash8(byte[] bytes) {
- var crc = new CRC32();
- crc.update(bytes);
- return String.format("%08x", crc.getValue());
- }
-
/**
* Test-only diagnostic: the number of times this instance has
(re)assembled its response body.
*
diff --git
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
index 6b3a594455..524527f45a 100644
---
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
+++
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
@@ -16,19 +16,9 @@
*/
package org.apache.juneau.rest.server.views;
-import static org.apache.juneau.commons.utils.CollectionUtils.*;
-
-import java.io.*;
-import java.util.*;
-import java.util.concurrent.*;
-import java.util.zip.*;
-
-import org.apache.juneau.commons.utils.*;
import org.apache.juneau.http.*;
-import org.apache.juneau.http.entity.*;
-import org.apache.juneau.http.header.*;
-import org.apache.juneau.http.resource.*;
import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.util.*;
/**
* Mixin that serves the first-party rich-view runtime assets — {@code
juneau-views.js},
@@ -142,11 +132,12 @@ public class ViewsMixin {
/** {@code Cache-Control} header emitted for every asset (1 day). */
static final String CACHE_CONTROL = "max-age=86400, public";
- /** Per-resource byte cache, populated on first request (the shipped
files never vary). */
- private static final Map<String,byte[]> CACHE = new
ConcurrentHashMap<>();
-
- /** Per-path content-hash cache, populated on first {@link
#viewAssetUrl(String)} call (mirrors {@link #CACHE}). */
- private static final Map<String,String> HASH_CACHE = new
ConcurrentHashMap<>();
+ /**
+ * Read+cache+hash+serve helper for this mixin's shipped assets,
anchored on this class so
+ * {@link ClasspathAssetCache#buildVersion()} resolves this module's
own implementation version (see that
+ * class's javadoc's version-anchor section).
+ */
+ private static final ClasspathAssetCache ASSET_CACHE = new
ClasspathAssetCache(ViewsMixin.class);
/**
* [GET /juneau-views.js] — serve the client initializer.
@@ -248,7 +239,7 @@ public class ViewsMixin {
* @return The servlet-relative asset URL with the version+content-hash
cache-buster appended.
*/
public static String viewAssetUrl(String path) {
- return "servlet:" + path + "?v=" + buildVersion() + "-" +
contentHash(path);
+ return "servlet:" + path +
ASSET_CACHE.cacheBuster(resourceFor(path));
}
/**
@@ -270,41 +261,12 @@ public class ViewsMixin {
* @return The absolute asset URL with the version+content-hash
cache-buster appended.
*/
public static String viewAssetUrl(RestRequest req, String path) {
- return req.getUriResolver().resolve("servlet:" + path) + "?v="
+ buildVersion() + "-" + contentHash(path);
+ return req.getUriResolver().resolve("servlet:" + path) +
ASSET_CACHE.cacheBuster(resourceFor(path));
}
/** Reads (and caches) the classpath asset and wraps it as a cacheable
{@link HttpResource}. */
private static HttpResource serve(String resource, String contentType) {
- var bytes = CACHE.computeIfAbsent(resource, ViewsMixin::load);
- return HttpResourceBean.of(
- ByteArrayBody.of(bytes, contentType),
- list(ContentType.of(contentType),
CacheControl.of(CACHE_CONTROL))
- );
- }
-
- /** Reads a shipped classpath asset into a byte array, wrapping the
(effectively unreachable) IO failure. */
- private static byte[] load(String resource) {
- try (var in = ViewsMixin.class.getResourceAsStream(resource)) {
- if (in == null)
- throw new IOException("Classpath resource not
found: " + resource);
- return IoUtils.readBytes(in);
- } catch (IOException e) { // HTT: unreachable - the asset
ships in the same jar as this class.
- throw new UncheckedIOException(e);
- }
- }
-
- /**
- * Resolves the framework build version for asset cache-busting,
falling back to {@code "dev"} when unset (e.g.
- * running from an IDE/test classpath rather than a packaged jar).
- */
- private static String buildVersion() {
- var v =
ViewsMixin.class.getPackage().getImplementationVersion();
- return v == null ? "dev" : v; // HTT: the non-null branch only
fires from a packaged jar manifest - unreachable against unpackaged
target/classes.
- }
-
- /** Computes (and caches) the given asset path's 8-hex-char content
hash from its served bytes. */
- private static String contentHash(String path) {
- return HASH_CACHE.computeIfAbsent(path, p ->
hash8(CACHE.computeIfAbsent(resourceFor(p), ViewsMixin::load)));
+ return ASSET_CACHE.serve(resource, contentType, CACHE_CONTROL);
}
/** Maps a public asset path constant to its classpath resource
constant (content-hashing only; routing itself is by {@code
@RestGet(path=...)}). */
@@ -317,11 +279,4 @@ public class ViewsMixin {
if (PAGES_JS_PATH.equals(path)) return PAGES_JS_RESOURCE;
throw new IllegalArgumentException("Unknown asset path: " +
path);
}
-
- /** Formats a CRC32 checksum of {@code bytes} as a zero-padded
8-hex-char content hash. */
- private static String hash8(byte[] bytes) {
- var crc = new CRC32();
- crc.update(bytes);
- return String.format("%08x", crc.getValue());
- }
}
diff --git
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
index d1887ae3a7..5014699b3a 100644
---
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
+++
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -24,6 +24,7 @@ import java.nio.charset.*;
import org.apache.juneau.*;
import org.apache.juneau.bean.html5.Div;
+import org.apache.juneau.commons.utils.*;
import org.apache.juneau.http.*;
import org.apache.juneau.http.entity.*;
import org.apache.juneau.http.header.*;
@@ -223,6 +224,24 @@ class ViewsMixin_Serving_Test extends TestBase {
assertNotEquals(url1,
ViewsMixin.viewAssetUrl(ViewsMixin.RIBBON_JS_PATH), "distinct assets must not
collide on their content hash");
}
+ /**
+ * Pins {@link ViewsMixin#viewAssetUrl(String)}'s content-hash suffix
against an independently-computed
+ * {@link ChecksumUtils#hash8} of each served asset's actual bytes
(fetched over the mock client, not read off
+ * the classpath directly) - a regression guard for the {@code
ClasspathAssetCache} extraction (TODO-443):
+ * proves the shared helper still produces byte-for-byte the same hash
the hand-rolled pre-refactor
+ * {@code hash8}/{@code contentHash} pair did.
+ */
+ @Test void
c04_viewAssetUrl_contentHash_matchesIndependentlyComputedHash8OfServedBytes()
throws Exception {
+ for (var path : new String[]{
+ ViewsMixin.VIEWS_JS_PATH,
ViewsMixin.RIBBON_JS_PATH, ViewsMixin.RENDERS_JS_PATH,
+ ViewsMixin.VIEWS_CSS_PATH,
ViewsMixin.ICONS_JS_PATH, ViewsMixin.PAGES_JS_PATH}) {
+ var servedBytes =
cWithMixin.get(path).run().assertStatus(200).getContent().asBytes();
+ var expectedHash = ChecksumUtils.hash8(servedBytes);
+ var url = ViewsMixin.viewAssetUrl(path);
+ assertTrue(url.endsWith("-" + expectedHash), () -> path
+ ": expected suffix '-" + expectedHash + "' in '" + url + "'");
+ }
+ }
+
//------------------------------------------------------------------------------------------------------------------
// d) Contract-version handshake constant (single source of truth =
ViewDef.CONTRACT_VERSION)
//------------------------------------------------------------------------------------------------------------------
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/ClasspathAssetCache.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/ClasspathAssetCache.java
new file mode 100644
index 0000000000..1b86e4a1cb
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/ClasspathAssetCache.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.util;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.io.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.commons.utils.*;
+import org.apache.juneau.http.*;
+import org.apache.juneau.http.entity.*;
+import org.apache.juneau.http.header.*;
+import org.apache.juneau.http.resource.*;
+
+/**
+ * A themeable/configurable asset-serving mixin's cache of classpath-shipped
assets: reads each configured
+ * resource's bytes once, computes and caches its {@link
ChecksumUtils#hash8(byte[]) content hash}, and wraps
+ * bytes as a cacheable {@link HttpResource} - the
read+cache+hash+cache-buster-URL+serve bundle that
+ * {@code ConsoleChromeMixin} (juneau-rest-server-console-ui) and {@code
ViewsMixin} (juneau-rest-server-views)
+ * previously each hand-rolled as their own private statics and static cache
maps.
+ *
+ * <h5 class='section'>Version-anchor:</h5>
+ * <p>
+ * {@link #buildVersion()} resolves {@link Package#getImplementationVersion()}
from the constructor-supplied
+ * <b>anchor</b> class, not from this class's own class/package. A mixin
composes one {@link ClasspathAssetCache}
+ * per mixin class (mirroring the static caches this replaces) and anchors it
on <b>its own</b> class, so the
+ * resolved implementation version - and therefore every {@code
?v=<buildVersion>-<hash8>} cache-buster URL this
+ * cache builds - matches what that mixin's own jar shipped with, exactly as
it did before extraction. Anchoring
+ * on this class instead would resolve {@code juneau-rest-server}'s own
implementation version for every
+ * consuming mixin, silently changing every consumer's cache-buster URLs.
+ *
+ * <h5 class='section'>Thread-safety:</h5>
+ * <p>
+ * Backed by {@link ConcurrentHashMap}s; safe to share across concurrent
requests. Classpath-shipped resources
+ * are assumed immutable for the lifetime of the JVM, so bytes and hashes are
computed at most once per resource.
+ *
+ * @since 10.0.0
+ */
+public class ClasspathAssetCache {
+
+ private final Class<?> anchor;
+ private final ConcurrentHashMap<String,byte[]> byteCache = new
ConcurrentHashMap<>();
+ private final ConcurrentHashMap<String,String> hashCache = new
ConcurrentHashMap<>();
+
+ /**
+ * Constructor.
+ *
+ * @param anchor
+ * The class that anchors both classpath resource resolution
({@link Class#getResourceAsStream(String)}) and
+ * {@link #buildVersion()}'s {@link
Package#getImplementationVersion()} resolution. Typically the owning
+ * mixin's own class (see the class javadoc's version-anchor
section). Must not be <jk>null</jk>.
+ */
+ public ClasspathAssetCache(Class<?> anchor) {
+ this.anchor = rnn(anchor);
+ }
+
+ /** Test-only accessor for the constructor-supplied anchor class. */
+ Class<?> anchor() {
+ return anchor;
+ }
+
+ /**
+ * Reads (and caches) the given classpath resource's bytes, resolved
relative to this cache's anchor class.
+ *
+ * @param classpathResource A classpath-root-absolute resource path
(e.g. {@code "/org/apache/juneau/foo/bar.js"}).
+ * Must not be <jk>null</jk> and must resolve to an existing
resource on the anchor's classpath.
+ * @return The resource's bytes (served from cache on every call after
the first for that resource).
+ */
+ public byte[] bytes(String classpathResource) {
+ return byteCache.computeIfAbsent(classpathResource, this::read);
+ }
+
+ private byte[] read(String classpathResource) {
+ try (var in = anchor.getResourceAsStream(classpathResource)) {
+ if (in == null)
+ throw new IOException("Classpath resource not
found: " + classpathResource);
+ return IoUtils.readBytes(in);
+ } catch (IOException e) { // HTT: unreachable - callers only
pass already-validated, jar-shipped classpath resources.
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Computes (and caches) the given classpath resource's 8-hex-char
content hash.
+ *
+ * @param classpathResource A classpath-root-absolute resource path.
Same constraints as {@link #bytes(String)}.
+ * @return The resource's {@link ChecksumUtils#hash8(byte[])} content
hash.
+ */
+ public String hash(String classpathResource) {
+ return hashCache.computeIfAbsent(classpathResource, r ->
ChecksumUtils.hash8(bytes(r)));
+ }
+
+ /**
+ * Resolves the framework build version for asset cache-busting from
this cache's anchor class's package,
+ * falling back to {@code "dev"} when unset (e.g. running from an
IDE/test classpath rather than a packaged
+ * jar) - see the class javadoc's version-anchor section.
+ *
+ * @return The anchor's {@link Package#getImplementationVersion()}, or
{@code "dev"} if unset.
+ */
+ public String buildVersion() {
+ var v = anchor.getPackage().getImplementationVersion();
+ return v == null ? "dev" : v; // HTT: the non-null branch only
fires when running from a packaged jar with a manifest Implementation-Version -
unreachable when tests run against unpackaged target/classes.
+ }
+
+ /**
+ * Builds the {@code ?v=<buildVersion>-<hash8>} content-sensitive
cache-buster query suffix for the given
+ * classpath resource, suitable for appending to that resource's served
URL.
+ *
+ * @param classpathResource A classpath-root-absolute resource path.
Same constraints as {@link #bytes(String)}.
+ * @return The {@code "?v=" + buildVersion() + "-" +
hash(classpathResource)} suffix.
+ */
+ public String cacheBuster(String classpathResource) {
+ return "?v=" + buildVersion() + "-" + hash(classpathResource);
+ }
+
+ /**
+ * Wraps pre-computed bytes as a cacheable {@link HttpResource}
carrying the given content type and
+ * {@code Cache-Control} header.
+ *
+ * @param bytes The response body bytes. Must not be <jk>null</jk>.
+ * @param contentType The {@code Content-Type} header value (e.g.
{@code "text/css;charset=utf-8"}).
+ * @param cacheControl The {@code Cache-Control} header value (e.g.
{@code "max-age=86400, public"}).
+ * @return The wrapped, cacheable resource.
+ */
+ public HttpResource wrap(byte[] bytes, String contentType, String
cacheControl) {
+ return HttpResourceBean.of(
+ ByteArrayBody.of(bytes, contentType),
+ list(ContentType.of(contentType),
CacheControl.of(cacheControl)));
+ }
+
+ /**
+ * Reads (and caches) the given classpath resource and wraps it as a
cacheable {@link HttpResource} - the
+ * {@link #bytes(String)} + {@link #wrap(byte[],String,String)} bundle
every classpath-asset-serving endpoint
+ * needs.
+ *
+ * @param classpathResource A classpath-root-absolute resource path.
Same constraints as {@link #bytes(String)}.
+ * @param contentType The {@code Content-Type} header value.
+ * @param cacheControl The {@code Cache-Control} header value.
+ * @return The wrapped, cacheable resource.
+ */
+ public HttpResource serve(String classpathResource, String contentType,
String cacheControl) {
+ return wrap(bytes(classpathResource), contentType,
cacheControl);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/util/ClasspathAssetCache_Test.java
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/util/ClasspathAssetCache_Test.java
new file mode 100644
index 0000000000..4d88f7966a
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/util/ClasspathAssetCache_Test.java
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.util;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+
+import org.apache.juneau.commons.utils.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests {@link ClasspathAssetCache}.
+ */
+class ClasspathAssetCache_Test {
+
+ /** A resource guaranteed present on this module's own test classpath
(this very test's compiled class file). */
+ private static final String OWN_CLASS_RESOURCE =
"/org/apache/juneau/rest/server/util/ClasspathAssetCache_Test.class";
+
+ /** A second, distinct resource on the same classpath, for
cross-resource hash-collision checks. */
+ private static final String OTHER_CLASS_RESOURCE =
"/org/apache/juneau/rest/server/util/ClasspathAssetCache.class";
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a) bytes(...): read-and-cache
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_bytes_returnsTheResourcesActualContent() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ var bytes = cache.bytes(OWN_CLASS_RESOURCE);
+ assertTrue(bytes.length > 0);
+ // A compiled .class file always starts with the JVM class-file
magic number.
+ assertEquals((byte) 0xCA, bytes[0]);
+ assertEquals((byte) 0xFE, bytes[1]);
+ }
+
+ @Test void a02_bytes_sameResource_isServedFromCacheOnSecondCall() {
+ // computeIfAbsent returns the SAME array instance on a cache
hit; a fresh read would return a distinct
+ // (if equal-content) array, so reference equality pins that
the resource is read at most once.
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ var first = cache.bytes(OWN_CLASS_RESOURCE);
+ var second = cache.bytes(OWN_CLASS_RESOURCE);
+ assertSame(first, second, "expected the cached byte[] instance,
not a freshly-read copy");
+ }
+
+ @Test void a03_bytes_distinctResources_areNotConflated() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+
assertFalse(java.util.Arrays.equals(cache.bytes(OWN_CLASS_RESOURCE),
cache.bytes(OTHER_CLASS_RESOURCE)));
+ }
+
+ @Test void a04_bytes_missingResource_throwsUncheckedIOException() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ assertThrows(UncheckedIOException.class, () ->
cache.bytes("/no/such/resource.bin"));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // b) hash(...): content-hash cache, backed by ChecksumUtils.hash8
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_hash_matchesChecksumUtilsHash8OfTheSameBytes() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ var bytes = cache.bytes(OWN_CLASS_RESOURCE);
+ assertEquals(ChecksumUtils.hash8(bytes),
cache.hash(OWN_CLASS_RESOURCE));
+ }
+
+ @Test void b02_hash_isStableAcrossCalls() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ assertEquals(cache.hash(OWN_CLASS_RESOURCE),
cache.hash(OWN_CLASS_RESOURCE));
+ }
+
+ @Test void b03_hash_distinctResources_doNotCollide() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ assertNotEquals(cache.hash(OWN_CLASS_RESOURCE),
cache.hash(OTHER_CLASS_RESOURCE));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // c) buildVersion(): resolved from the constructor-supplied anchor,
"dev" fallback
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void
c01_buildVersion_fallsBackToDev_whenAnchorPackageHasNoImplementationVersion() {
+ // Every anchor in this unpackaged-classes test run has a null
Package#getImplementationVersion() (no
+ // manifest), so this pins the documented "dev" fallback
directly.
+
assertNull(ClasspathAssetCache_Test.class.getPackage().getImplementationVersion(),
"test precondition: expected an unpackaged (manifest-less) test run");
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ assertEquals("dev", cache.buildVersion());
+ }
+
+ @Test void
c02_buildVersion_isConsistentWithDirectPackageLookupOnTheAnchor() {
+ // Same anchor, computed two ways: cache.buildVersion() and
Package#getImplementationVersion() read
+ // directly off the SAME anchor class - equal in either branch
(real version or "dev" fallback), so this
+ // stays valid even if a future packaged run gives the anchor a
real Implementation-Version.
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ var direct =
ClasspathAssetCache_Test.class.getPackage().getImplementationVersion();
+ assertEquals(direct == null ? "dev" : direct,
cache.buildVersion());
+ }
+
+ @Test void
c03_constructor_storesTheSuppliedAnchor_notTheCachesOwnClass() {
+ // Guards the version-anchor design point directly: the cache
must resolve buildVersion() from the
+ // MIXIN-supplied anchor, not from ClasspathAssetCache's own
class/package - a hardcode of `getClass()`
+ // (or similar) in place of the constructor argument would flip
this to ClasspathAssetCache.class, and
+ // this assertion catches that even though both currently
resolve to "dev".
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ assertSame(ClasspathAssetCache_Test.class, cache.anchor());
+ }
+
+ @Test void c04_constructor_rejectsNullAnchor() {
+ assertThrows(NullPointerException.class, () -> new
ClasspathAssetCache(null));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // d) cacheBuster(...): "?v=<buildVersion>-<hash8>"
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_cacheBuster_isVEqualsBuildVersionDashHash8() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ assertEquals("?v=" + cache.buildVersion() + "-" +
cache.hash(OWN_CLASS_RESOURCE), cache.cacheBuster(OWN_CLASS_RESOURCE));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // e) wrap(...) / serve(...): cacheable HttpResource with the requested
Content-Type/Cache-Control
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_wrap_setsContentTypeHeader() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ var resource = cache.wrap(new byte[]{1, 2, 3}, "image/svg+xml",
"max-age=86400, public");
+ assertEquals("image/svg+xml", resource.getContentType());
+ }
+
+ @Test void e02_wrap_setsCacheControlHeader() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ var resource = cache.wrap(new byte[]{1, 2, 3}, "image/svg+xml",
"max-age=86400, public");
+ assertEquals("max-age=86400, public", cacheControlOf(resource));
+ }
+
+ @Test void e03_wrap_bodyBytes_matchTheSuppliedBytes() throws
IOException {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ var expected = new byte[]{1, 2, 3, 4};
+ var resource = cache.wrap(expected, "application/octet-stream",
"max-age=86400, public");
+ assertArrayEquals(expected, writtenBytes(resource));
+ }
+
+ @Test void e04_serve_bodyBytes_matchTheClasspathResourcesActualBytes()
throws IOException {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ var resource = cache.serve(OWN_CLASS_RESOURCE,
"application/octet-stream", "max-age=86400, public");
+ assertArrayEquals(cache.bytes(OWN_CLASS_RESOURCE),
writtenBytes(resource));
+ }
+
+ @Test void e05_serve_setsContentTypeAndCacheControl() {
+ var cache = new
ClasspathAssetCache(ClasspathAssetCache_Test.class);
+ var resource = cache.serve(OWN_CLASS_RESOURCE,
"application/octet-stream", "max-age=3600, public");
+ assertEquals("application/octet-stream",
resource.getContentType());
+ assertEquals("max-age=3600, public", cacheControlOf(resource));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Test helpers
+
//------------------------------------------------------------------------------------------------------------------
+
+ private static byte[] writtenBytes(org.apache.juneau.http.HttpBody
body) throws IOException {
+ var out = new ByteArrayOutputStream();
+ body.writeTo(out);
+ return out.toByteArray();
+ }
+
+ private static String
cacheControlOf(org.apache.juneau.http.HttpResource resource) {
+ return resource.getHeaders().stream()
+ .filter(h ->
h.getName().equalsIgnoreCase("Cache-Control"))
+ .findFirst()
+ .map(org.apache.juneau.http.HttpHeader::getValue)
+ .orElseThrow(() -> new AssertionError("no Cache-Control
header found"));
+ }
+}
diff --git a/scripts/eclipse-warnings.py b/scripts/eclipse-warnings.py
new file mode 100644
index 0000000000..13b18c9ee8
--- /dev/null
+++ b/scripts/eclipse-warnings.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Headless reproduction of Eclipse JDT compiler warnings via `ecj`.
+
+`ecj` is the exact JDT compiler Eclipse embeds. Driven by a module's own
+.settings/org.eclipse.jdt.core.prefs (the file scripts/apply-eclipse-prefs.py
+seeds/syncs), it reproduces Eclipse's Problems-view diagnostics verbatim,
+including exact message strings -- without a live Eclipse session or the
+AssistAI Eclipse MCP server. See the `eclipse-warnings` skill's "Headless
+fallback" section for the full technique writeup and when to prefer it.
+
+Usage (run from the repo root):
+ python3 scripts/eclipse-warnings.py <module-path> # e.g.
juneau-rest/juneau-rest-server
+ python3 scripts/eclipse-warnings.py --all # every source module
apply-eclipse-prefs.py would touch
+
+Requires:
+ - `ecj` 3.45.0. Resolved automatically from
~/.m2/repository/org/eclipse/jdt/ecj/3.45.0/
+ (already present if the project has ever built; the ecj Maven coordinate
is
+ org.eclipse.jdt:ecj:3.45.0). Override with the ECJ_JAR env var if it
lives elsewhere.
+ - the module's own .settings/org.eclipse.jdt.core.prefs to exist (run
+ apply-eclipse-prefs.py first if missing/stale).
+ - `mvn` on PATH, to resolve the module's runtime+test classpath.
+
+Javadoc diagnostics additionally need -enableJavadoc plus explicit javadoc
+severities (passed below unconditionally) because the repo's tracked .prefs
+files set no javadoc options at all -- those particular diagnostics come from
+workspace-level Eclipse settings that no tracked file controls. Omitting the
+flags means a javadoc warning Eclipse reports simply won't reproduce here.
+
+Caveat -- classpath uses INSTALLED reactor jars, not live sources: the runtime
+classpath comes from `mvn dependency:build-classpath`, which resolves other
+reactor modules' jars from ~/.m2 as of their last `mvn install`, not their
+current on-disk source. A class added/changed in an upstream module since the
+last install won't be visible here ("X cannot be resolved" for a genuinely
+new/renamed type is the tell) -- `mvn install` the affected upstream module(s)
+first. Also expect occasional "invalid Class-Path header in manifest of jar
+file ..." lines from ecj scanning a third-party dependency's own bundled
+manifest; that's noise unrelated to Juneau's code and safe to ignore.
+"""
+
+import glob
+import importlib.util
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+DEFAULT_ECJ_JAR = Path.home() /
".m2/repository/org/eclipse/jdt/ecj/3.45.0/ecj-3.45.0.jar"
+
+
+def find_ecj_jar():
+ override = os.environ.get("ECJ_JAR")
+ if override:
+ return Path(override)
+ return DEFAULT_ECJ_JAR
+
+
+def load_apply_prefs_module(script_dir):
+ """Reuse apply-eclipse-prefs.py's module discovery so both scripts agree
+ on what counts as a module and how it's classified source vs. test."""
+ spec = importlib.util.spec_from_file_location(
+ "apply_eclipse_prefs", script_dir / "apply-eclipse-prefs.py"
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def build_classpath(module_dir):
+ """Run `mvn dependency:build-classpath` from inside the module dir itself
+ (not `-pl <module>` from the reactor root). `-pl` still triggers a
+ multi-module reactor pass, and multiple modules' plugin executions racing
+ to write the same `-Dmdep.outputFile` target corrupts the output (jar
+ paths get concatenated with missing `:` separators). Running from the
+ module's own directory keeps this to a single-module build."""
+ with tempfile.NamedTemporaryFile(prefix="ecj-cp-", suffix=".txt",
delete=False) as tmp:
+ cp_file = tmp.name
+ try:
+ result = subprocess.run(
+ ["mvn", "-q", "dependency:build-classpath",
f"-Dmdep.outputFile={cp_file}"],
+ cwd=module_dir, capture_output=True, text=True, timeout=180,
+ )
+ if result.returncode != 0:
+ print(f"Warning: `mvn dependency:build-classpath` failed for
{module_dir}:", file=sys.stderr)
+ print(result.stderr, file=sys.stderr)
+ return ""
+ return Path(cp_file).read_text().strip()
+ finally:
+ os.unlink(cp_file)
+
+
+def java_sources(module_dir):
+ files = []
+ for sub in ("src/main/java", "src/test/java"):
+ src_dir = module_dir / sub
+ if src_dir.exists():
+ files.extend(glob.glob(str(src_dir / "**" / "*.java"),
recursive=True))
+ return files
+
+
+def run_ecj(ecj_jar, module_dir, module_path, classpath):
+ prefs = module_dir / ".settings" / "org.eclipse.jdt.core.prefs"
+ if not prefs.exists():
+ print(f"Skipping {module_path}: no
.settings/org.eclipse.jdt.core.prefs "
+ f"(run apply-eclipse-prefs.py first)", file=sys.stderr)
+ return None
+
+ sources = java_sources(module_dir)
+ if not sources:
+ print(f"Skipping {module_path}: no .java sources found",
file=sys.stderr)
+ return None
+
+ target_classes = module_dir / "target" / "classes"
+ test_classes = module_dir / "target" / "test-classes"
+ cp_parts = [classpath] if classpath else []
+ for extra in (target_classes, test_classes):
+ if extra.exists():
+ cp_parts.append(str(extra))
+ full_cp = os.pathsep.join(p for p in cp_parts if p)
+
+ with tempfile.TemporaryDirectory(prefix="ecj-out-") as out_dir:
+ cmd = [
+ "java", "-jar", str(ecj_jar),
+ "-properties", str(prefs),
+ "-enableJavadoc", "-warn:+allJavadoc,invalidJavadoc,javadoc",
+ "-cp", full_cp,
+ "-d", out_dir,
+ ] + sources
+ result = subprocess.run(cmd, cwd=module_dir, capture_output=True,
text=True, timeout=300)
+ return result.stdout + result.stderr
+
+
+def summarize(output):
+ """Pull ecj's trailing '<n> problems (<e> errors, <w> warnings)' line, if
present."""
+ for line in reversed(output.strip().splitlines()):
+ if "problem" in line and ("error" in line or "warning" in line):
+ return line.strip()
+ return None
+
+
+def main():
+ script_dir = Path(__file__).parent
+ root_dir = script_dir.parent
+
+ if len(sys.argv) != 2:
+ print(__doc__)
+ sys.exit(1)
+
+ ecj_jar = find_ecj_jar()
+ if not ecj_jar.exists():
+ print(f"ecj jar not found at {ecj_jar}.", file=sys.stderr)
+ print("Resolve it first, e.g.: mvn dependency:get
-Dartifact=org.eclipse.jdt:ecj:3.45.0", file=sys.stderr)
+ print("or set ECJ_JAR to point at an existing copy.", file=sys.stderr)
+ sys.exit(2)
+
+ if sys.argv[1] == "--all":
+ apply_prefs = load_apply_prefs_module(script_dir)
+ source_modules, test_modules = apply_prefs.discover_projects(root_dir)
+ module_paths = source_modules + test_modules
+ else:
+ module_paths = [sys.argv[1]]
+
+ any_problems = False
+ for module_path in module_paths:
+ module_dir = root_dir / module_path
+ if not module_dir.exists():
+ print(f"Warning: module not found: {module_path}", file=sys.stderr)
+ continue
+
+ print(f"\n{'=' * 60}\n{module_path}\n{'=' * 60}")
+ classpath = build_classpath(module_dir)
+ output = run_ecj(ecj_jar, module_dir, module_path, classpath)
+ if output is None:
+ continue
+
+ print(output)
+ summary = summarize(output)
+ if summary:
+ print(f">>> {module_path}: {summary}")
+ if "0 errors, 0 warnings" not in summary:
+ any_problems = True
+
+ sys.exit(1 if any_problems else 0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/todo-next-id.py b/scripts/todo-next-id.py
index 5a74ec73f7..485a2ee5c6 100755
--- a/scripts/todo-next-id.py
+++ b/scripts/todo-next-id.py
@@ -34,7 +34,7 @@ Mirrors the exact scan scope documented in this repo's
TODO-management skill, in
Note that this scan cannot distinguish an illustrative id from a live
one: writing
"for example, TODO-5" anywhere in TODO.md permanently consumes id 5.
Write "TODO-<n>" in
prose.
- 2. Every
"TODO-"/"READY-"/"MAYBE-"/"FINISHED-"/"CANCELLED-<n>[<letter>]-*.md" filename
+ 2. Every
"TODO-"/"READY-"/"MAYBE-"/"HOLD-"/"FINISHED-"/"CANCELLED-<n>[<letter>]-*.md"
filename
directly under .work/todo/ and .work/todo/finished/.
next = 1 + max(all numeric ids found). A child's letter suffix (TODO-174a,
FINISHED-337f, ...)
@@ -96,7 +96,7 @@ DEFAULT_REPO_ROOT = Path(__file__).resolve().parent.parent
TODO_TOKEN_RE = re.compile(r"(?<![\w:])TODO-(\d+)([a-z]*)\b")
# Every lifecycle-state filename directly under .work/todo/ or
.work/todo/finished/.
-FILENAME_RE =
re.compile(r"^(?:TODO|READY|MAYBE|FINISHED|CANCELLED)-(\d+)([a-z]*)-.*\.md$")
+FILENAME_RE =
re.compile(r"^(?:TODO|READY|MAYBE|HOLD|FINISHED|CANCELLED)-(\d+)([a-z]*)-.*\.md$")
def collect_ids(todo_dir: Path) -> tuple[set, set]:
diff --git a/scripts/todo-status-audit.py b/scripts/todo-status-audit.py
index 177260d4a9..6b6e37cdbc 100755
--- a/scripts/todo-status-audit.py
+++ b/scripts/todo-status-audit.py
@@ -19,9 +19,9 @@ Repo-agnostic: the repository root is derived from this
file's own location
every repository that adopts the convention. Only the REPO_LABEL / SKILL_NAME
constants
below and the license header differ between copies.
-Checks every TODO-<id>-*.md / READY-<id>-*.md / MAYBE-<id>-*.md file directly
under .work/todo/
-(FINISHED-/CANCELLED-*.md archives are explicitly out of scope -- per this
repo's
-TODO-management skill, "status line is not required in FINISHED archives")
against that
+Checks every TODO-<id>-*.md / READY-<id>-*.md / MAYBE-<id>-*.md /
HOLD-<id>-*.md file directly
+under .work/todo/ (FINISHED-/CANCELLED-*.md archives are explicitly out of
scope -- per this
+repo's TODO-management skill, "status line is not required in FINISHED
archives") against that
skill's "Per-file `Current status:` and `Complexity:` header" rules, and flags
candidate
inconsistencies. This is a PRE-FILTER, not a validator: it flags candidates
for a human
(or agent) to look at, and will not catch everything on format-drifted files
-- tolerant,
@@ -35,10 +35,10 @@ Checks performed (each file may accumulate multiple flags):
- unrecognized_status_phrase The status text doesn't start with one of the
skill's documented
phrases for this file's prefix (TODO/READY:
"Waiting for user
input on open questions.", "Ready to
execute.", "In progress.";
- MAYBE: must start with "Parked"). Free-form
variants that legitimately
- extend a recognized prefix (e.g. "Ready to
execute (all items
- independently actionable)." ) are NOT flagged
-- only prefix
- mismatches are.
+ MAYBE: must start with "Parked"; HOLD: must
start with "On hold").
+ Free-form variants that legitimately extend a
recognized prefix
+ (e.g. "Ready to execute (all items
independently actionable)." )
+ are NOT flagged -- only prefix mismatches are.
- ready_but_has_open_questions
Status starts with "Ready to execute" but the
file still has a
"## Open questions" section containing at
least one numbered item
@@ -51,6 +51,9 @@ Checks performed (each file may accumulate multiple flags):
- parked_status_wrong_prefix A TODO-*.md/READY-*.md file whose status
starts with "Parked" (that
wording is reserved for MAYBE-*.md files).
- maybe_prefix_non_parked A MAYBE-*.md file whose status does NOT start
with "Parked".
+ - on_hold_status_wrong_prefix A TODO-*.md/READY-*.md file whose status
starts with "On hold" (that
+ wording is reserved for HOLD-*.md files).
+ - hold_prefix_non_on_hold A HOLD-*.md file whose status does NOT start
with "On hold".
A MISSING scan directory is a hard error (exit 2). An EMPTY-but-present one is
a clean pass
(exit 0). The original version conflated the two and returned 0 for both, so
pointing the
@@ -93,7 +96,7 @@ SKILL_NAME = "juneau-todo-management"
DEFAULT_REPO_ROOT = Path(__file__).resolve().parent.parent
-FILENAME_RE = re.compile(r"^(TODO|READY|MAYBE)-\d+[a-z]*-.*\.md$")
+FILENAME_RE = re.compile(r"^(TODO|READY|MAYBE|HOLD)-\d+[a-z]*-.*\.md$")
STATUS_LINE_RE = re.compile(r"^\s*Current status:\s*(.*)$", re.IGNORECASE |
re.MULTILINE)
COMPLEXITY_LINE_RE = re.compile(r"^\s*Complexity:\s*(.*)$", re.IGNORECASE |
re.MULTILINE)
@@ -115,6 +118,7 @@ TODO_READY_STATUS_PREFIXES = (
"in progress",
)
MAYBE_STATUS_PREFIX = "parked"
+HOLD_STATUS_PREFIX = "on hold"
def find_plan_files(todo_dir: Path) -> list:
@@ -159,10 +163,12 @@ def open_questions_are_unresolved(section_body: str) ->
bool:
def status_prefix_ok(prefix: str, status: str) -> bool:
- """True if status's wording matches one of the recognized phrases for this
file's TODO/READY/MAYBE prefix."""
+ """True if status's wording matches one of the recognized phrases for this
file's TODO/READY/MAYBE/HOLD prefix."""
normalized = status.strip().lower()
if prefix == "MAYBE":
return normalized.startswith(MAYBE_STATUS_PREFIX)
+ if prefix == "HOLD":
+ return normalized.startswith(HOLD_STATUS_PREFIX)
return any(normalized.startswith(p) for p in TODO_READY_STATUS_PREFIXES)
@@ -207,6 +213,12 @@ def audit_file(path: Path) -> list:
if prefix == "MAYBE" and not normalized.startswith("parked"):
flags.append(("maybe_prefix_non_parked", f"MAYBE-prefixed file but
status doesn't start with 'Parked': '{status}'"))
+ if prefix in ("TODO", "READY") and
normalized.startswith(HOLD_STATUS_PREFIX):
+ flags.append(("on_hold_status_wrong_prefix", "Status says 'On
hold...' but filename is not HOLD-prefixed."))
+
+ if prefix == "HOLD" and not normalized.startswith(HOLD_STATUS_PREFIX):
+ flags.append(("hold_prefix_non_on_hold", f"HOLD-prefixed file but
status doesn't start with 'On hold': '{status}'"))
+
return flags