diff --git a/.claude/settings.json b/.claude/settings.json index f7bbfb98ff..08c475740e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -53,16 +53,17 @@ ] }, "hooks": { - "PreToolUse": [ + "SessionStart": [ { - "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/protect-version-file.sh" + "command": "$CLAUDE_PROJECT_DIR/init-submodules" } ] - }, + } + ], + "PreToolUse": [ { "matcher": "Bash", "hooks": [ diff --git a/.gitmodules b/.gitmodules index 5b3352bf4e..1978333b99 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,5 +5,5 @@ path = .agents/shared url = https://github.com/SpineEventEngine/agents.git branch = master - update = checkout + update = merge ignore = all diff --git a/AGENTS.md b/AGENTS.md index d96636dd27..3d0ba5938a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,15 @@ links to a shared requirements file (e.g. `jvm-project.md`), read that too. Shared skills, scripts, and guidelines come from the `.agents/shared` submodule (the [`agents`][agents-repo] repository) exposed via symlinks. -`./config/pull` initializes and floats it automatically; on a fresh clone that skips -`pull`, run `git submodule update --init --remote .agents/shared`. +`./config/pull` initializes and floats them automatically. But a fresh `git worktree` +(and some shallow clones / cloud checkouts) start with NO submodules checked out, so +those symlinks dangle and no skills are found. Bootstrap such a tree with +**`./init-submodules`** — a root script that materializes the missing submodules +(`config`, `.agents/shared`, …) at their pinned commits. It depends on no pre-existing +`config` submodule, so it works before `./config/pull` (which lives inside the `config` +submodule) can. Claude Code runs it automatically via a `SessionStart` hook; other +agents and humans run it by hand, then `./config/pull` to float the shared submodules +to their branch tips. ## Commit and history safety @@ -115,7 +122,7 @@ In consumer repositories, skip without comment any path matching: - `.claude/**`, `.idea/**`, `.junie/**` - `.github/copilot-instructions.md` - `buildSrc/**` (except `buildSrc/src/main/kotlin/module.gradle.kts`) -- `gradle/`, `gradlew`, `gradlew.bat` +- `gradle/`, `gradlew`, `gradlew.bat`, `init-submodules` - `.codecov.yml`, `.gitignore`, `gradle.properties`, `lychee.toml` - `.github/workflows/` — unless the workflow was introduced by this repo diff --git a/base/src/main/java/io/spine/base/Field.java b/base/src/main/java/io/spine/base/Field.java index 6591389e30..8ce0cae5ba 100644 --- a/base/src/main/java/io/spine/base/Field.java +++ b/base/src/main/java/io/spine/base/Field.java @@ -34,6 +34,7 @@ import com.google.protobuf.Descriptors.EnumValueDescriptor; import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.protobuf.Message; +import com.google.protobuf.ProtocolMessageEnum; import io.spine.annotation.VisibleForTesting; import io.spine.code.proto.ScalarType; import io.spine.type.TypeName; @@ -273,6 +274,7 @@ public static Optional findIdField(Class idClass, Descri .stream() .filter(idType::matchField) .filter(f -> idType != IdType.MESSAGE || sameMessageType(idClass, f)) + .filter(f -> idType != IdType.ENUM || sameEnumType(idClass, f)) .findFirst(); return found; } @@ -288,6 +290,23 @@ private static boolean sameMessageType(Class idClass, FieldDescriptor f) return fieldType.equals(messageType); } + /** + * Verifies if the class of identifiers and the type of the field represent the same enum type. + * + *

The {@code matchField} check of {@code IdType.ENUM} accepts any enum field because it + * does not know the requested enum class. This check, performed once the class is known, + * ensures that an enum ID field of one type is not mistaken for a field of another enum + * type declared in the same message. + */ + private static boolean sameEnumType(Class idClass, FieldDescriptor f) { + var idEnum = (ProtocolMessageEnum) Identifier.defaultValue(idClass); + var idEnumType = idEnum.getDescriptorForType() + .getFullName(); + var fieldEnumType = f.getEnumType() + .getFullName(); + return fieldEnumType.equals(idEnumType); + } + /** * Checks if the field is a nested field. */ diff --git a/base/src/main/java/io/spine/base/IdType.java b/base/src/main/java/io/spine/base/IdType.java index b283570228..aa7127f7c1 100644 --- a/base/src/main/java/io/spine/base/IdType.java +++ b/base/src/main/java/io/spine/base/IdType.java @@ -28,14 +28,18 @@ import com.google.protobuf.Any; import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.EnumValue; import com.google.protobuf.Int32Value; import com.google.protobuf.Int64Value; import com.google.protobuf.Message; +import com.google.protobuf.ProtocolMessageEnum; import com.google.protobuf.StringValue; import io.spine.protobuf.AnyPacker; import io.spine.protobuf.Messages; import io.spine.protobuf.TypeConverter; +import static io.spine.util.Exceptions.newIllegalStateException; + /** * Supported types of identifiers. */ @@ -142,6 +146,73 @@ boolean matchField(FieldDescriptor field) { } }, + /** + * A Protobuf enum used as an identifier. + * + *

The Java class generated for a Protobuf enum implements {@link ProtocolMessageEnum}. + * The constant with the number zero is reserved by convention for the "undefined" value + * and is treated as an {@linkplain Identifier#isEmpty(Object) empty} identifier. + */ + ENUM { + @Override + boolean matchValue(I id) { + // Require an actual Java enum constant, not merely a `ProtocolMessageEnum` + // implementor, consistent with `matchClass()`. Later paths (such as + // `Identifier.toString()`) cast the value to `Enum`. + return id instanceof Enum && id instanceof ProtocolMessageEnum; + } + + /** + * Always returns {@code false}. + * + *

A Protobuf enum is packed into {@link Any} as an {@link EnumValue}, which carries + * only the {@linkplain EnumValue#getName() name} and {@linkplain EnumValue#getNumber() + * number} of the value, but not the enum type. Restoring the original Java enum constant + * therefore requires the target class, which is unavailable in this method. Enum + * identifiers are restored only via {@link Identifier#unpack(Any, Class)}. + * + *

Returning {@code false} keeps the raw {@code EnumValue} handled by {@link #MESSAGE} + * in the class-less {@link Identifier#unpack(Any)}, preserving its behavior. + */ + @Override + boolean matchMessage(Message message) { + return false; + } + + @Override + boolean matchClass(Class idClass) { + // Require an actual Java `enum`, not merely a `ProtocolMessageEnum` implementor: + // the `ProtocolMessageEnum` interface itself (and any non-enum implementation) has + // no enum constants, so `defaultValue()` would fail on `getEnumConstants()`. + return idClass.isEnum() && ProtocolMessageEnum.class.isAssignableFrom(idClass); + } + + /** + * Always throws {@link IllegalStateException}. + * + *

This method is never called because {@link #matchMessage(Message)} returns + * {@code false} for this type. Restoring an enum identifier requires the target class; + * use {@link Identifier#unpack(Any, Class)} instead. + */ + @Override + Object fromMessage(Message message) { + throw newIllegalStateException( + "An enum identifier must be restored with the target class" + + " via `Identifier.unpack(Any, Class)`."); + } + + @Override + I defaultValue(Class idClass) { + var undefined = zeroValue(idClass); + return (I) undefined; + } + + @Override + boolean matchField(FieldDescriptor field) { + return FieldDescriptor.JavaType.ENUM == field.getJavaType(); + } + }, + MESSAGE { @Override boolean matchValue(I id) { @@ -263,4 +334,30 @@ Any pack(I id) { var result = AnyPacker.pack(msg); return result; } + + /** + * Obtains the constant reserved by convention for the "undefined" identifier value — + * the one with the number zero — declared in the given Protobuf enum class. + * + *

The constant is located by its number through the enum descriptor, so it is correct + * regardless of the declaration order. If the enum declares no constant with the number + * zero — possible only for {@code proto2} — the first declared constant is returned. + */ + private static Object zeroValue(Class enumClass) { + var constants = enumClass.getEnumConstants(); + var enumDescriptor = ((ProtocolMessageEnum) constants[0]).getDescriptorForType(); + var zero = enumDescriptor.findValueByNumber(0); + if (zero == null) { + return constants[0]; + } + return enumConstant(enumClass, zero.getName()); + } + + /** + * Obtains the enum constant of the given class by its name. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) // `enumClass` is an `enum`, ensured by `matchClass()`. + private static Object enumConstant(Class enumClass, String name) { + return Enum.valueOf((Class) enumClass, name); + } } diff --git a/base/src/main/java/io/spine/base/Identifier.java b/base/src/main/java/io/spine/base/Identifier.java index 13ecd106f9..9e91b2f810 100644 --- a/base/src/main/java/io/spine/base/Identifier.java +++ b/base/src/main/java/io/spine/base/Identifier.java @@ -26,19 +26,39 @@ package io.spine.base; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; import com.google.protobuf.Any; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.EnumValue; import com.google.protobuf.Int32Value; import com.google.protobuf.Int64Value; import com.google.protobuf.Message; +import com.google.protobuf.ProtocolMessageEnum; import com.google.protobuf.StringValue; import io.spine.annotation.VisibleForTesting; import io.spine.protobuf.AnyPacker; +import io.spine.protobuf.TypeConverter; import io.spine.string.StringifierRegistry; import org.jspecify.annotations.Nullable; import java.util.UUID; import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_ENUM; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_FIXED32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_FIXED64; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_INT32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_INT64; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_MESSAGE; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_SFIXED32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_SFIXED64; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_SINT32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_SINT64; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_STRING; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_UINT32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_UINT64; import static io.spine.util.Exceptions.newIllegalArgumentException; import static io.spine.util.Exceptions.newIllegalStateException; @@ -52,9 +72,20 @@ *

  • {@code String} *
  • {@code Long} *
  • {@code Integer} + *
  • A Protobuf enum (a class implementing {@link com.google.protobuf.ProtocolMessageEnum + * ProtocolMessageEnum}). *
  • A class implementing {@link Message}. * * + *

    For a Protobuf enum identifier, the constant with the number zero is reserved by + * convention for the "undefined" value (a {@code null}-like value). Such a value is treated + * as an {@linkplain #isEmpty(Object) empty} identifier. + * + *

    To check whether a Protobuf message field may serve as an identifier, use + * {@link #isSupportedIdType(com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type) + * isSupportedIdType()}. It is the single source of truth for this rule; tools such as + * the Spine compiler delegate to it rather than maintaining their own list. + * *

    Consider using {@code Message}-based IDs if you want to have typed IDs in your code, * and/or if you need to have IDs with some structure inside. * @@ -109,6 +140,24 @@ public final class Identifier { /** An empty ID string representation. */ static final String EMPTY_ID = "EMPTY"; + /** + * Protobuf field types that may serve as an identifier. + * + *

    This is the programmatic counterpart of the + * Supported types of identifiers section: a {@code string}, + * every 32-bit and 64-bit integer encoding (Java {@code Integer} and {@code Long}), + * a Protobuf {@code enum}, and a {@code Message}. + * + * @see #isSupportedIdType(FieldDescriptorProto.Type) + */ + private static final ImmutableSet SUPPORTED_ID_TYPES = + Sets.immutableEnumSet( + TYPE_STRING, + TYPE_INT32, TYPE_UINT32, TYPE_SINT32, TYPE_FIXED32, TYPE_SFIXED32, + TYPE_INT64, TYPE_UINT64, TYPE_SINT64, TYPE_FIXED64, TYPE_SFIXED64, + TYPE_ENUM, + TYPE_MESSAGE); + private final IdType type; private final I value; @@ -136,6 +185,9 @@ private static Identifier fromMessage(Message value) { /** * Obtains a default value for an identifier of the passed class. + * + *

    For a Protobuf enum, the default value is the constant with the number zero, which is + * reserved by convention for the "undefined" value. */ public static I defaultValue(Class idClass) { checkNotNull(idClass); @@ -173,7 +225,10 @@ static IdType toType(Class idClass) { * *

    For string and message identifiers, the method verifies the values. * - *

    A string identifier is empty, if it contains an empty string. + *

    A string identifier is empty if it contains an empty string. + * + *

    An enum identifier is empty if it holds the constant with the number zero, which is + * reserved by convention for the "undefined" value. * * @param value * the value to check @@ -188,12 +243,29 @@ public static boolean isEmpty(I value) { if (id.type == IdType.INTEGER || id.type == IdType.LONG) { return false; } + if (id.type == IdType.ENUM) { + return isUndefinedEnum((ProtocolMessageEnum) value); + } var str = id.toString(); var result = EMPTY_ID.equals(str); return result; } + /** + * Tells if the passed Protobuf enum value is the "undefined" constant + * with the number zero. + * + *

    The {@code UNRECOGNIZED} constant generated by Protobuf is not considered empty + * because calling {@link ProtocolMessageEnum#getNumber()} on it throws an exception. + */ + private static boolean isUndefinedEnum(ProtocolMessageEnum value) { + // The value is always a Java enum constant, as ensured by `IdType.ENUM.matchValue()`. + // `UNRECOGNIZED` is excluded first because calling `getNumber()` on it throws. + return !"UNRECOGNIZED".equals(((Enum) value).name()) + && value.getNumber() == 0; + } + static IllegalArgumentException unsupported(I id) { return newIllegalArgumentException("ID of unsupported type encountered: `%s`.", id); } @@ -224,6 +296,49 @@ public static void checkSupported(Class idClass) { checkNotNull(type); } + /** + * Tells whether a Protobuf field of the given type may serve as an identifier. + * + *

    This method classifies the type of the field only. A {@code repeated} or + * a {@code map} field can never be an identifier regardless of its element type, so the + * caller must reject such fields separately — this method considers only the singular + * type. By the same token, {@code TYPE_GROUP} (a Protobuf 2 construct) is not a supported + * identifier type. + * + *

    This is the single source of truth for the + * supported identifier types. Tools that decide whether + * a message field can be used as an ID (such as the Spine compiler) should delegate here + * instead of maintaining their own list. + * + * @param type + * the type of the field + * @return {@code true} if a field of this type can be an identifier; + * {@code false} otherwise + * @see #isSupportedIdType(FieldDescriptor) + */ + public static boolean isSupportedIdType(FieldDescriptorProto.Type type) { + checkNotNull(type); + return SUPPORTED_ID_TYPES.contains(type); + } + + /** + * Tells whether the given field may serve as an identifier, considering its type only. + * + *

    Like {@link #isSupportedIdType(FieldDescriptorProto.Type)}, this method does not + * take the cardinality of the field into account — a {@code repeated} or a {@code map} + * field can never be an identifier even if its element type is supported. + * + * @param field + * the field to check + * @return {@code true} if a field of this type can be an identifier; + * {@code false} otherwise + * @see #isSupportedIdType(FieldDescriptorProto.Type) + */ + public static boolean isSupportedIdType(FieldDescriptor field) { + checkNotNull(field); + return isSupportedIdType(field.toProto().getType()); + } + /** * Wraps the passed ID value into an instance of {@link Any}. * @@ -235,6 +350,7 @@ public static void checkSupported(Class idClass) { *

  • For {@code String} — {@link StringValue} *
  • For {@code Long} — {@link Int64Value} *
  • For {@code Integer} — {@link Int32Value} + *
  • For a Protobuf enum — {@link EnumValue} * * * @param id @@ -263,6 +379,10 @@ public static Any pack(I id) { *
  • unwrapped {@code Message} instance if its type is none of the above * * + *

    A Protobuf enum identifier packed as {@link EnumValue} + * is returned as the raw {@code EnumValue} message, because reconstructing the original enum + * constant requires its class. Use {@link #unpack(Any, Class)} to obtain the enum constant. + * * @param any * the ID value wrapped into {@code Any} * @return unwrapped ID @@ -295,6 +415,11 @@ public static Object unpack(Any any) { * Does the same as {@link #unpack(com.google.protobuf.Any)} and * additionally casts the ID to the specified class. * + *

    If {@code idClass} is a Protobuf enum, the value packed as + * {@link EnumValue} is converted back to the corresponding + * enum constant. This is the only way to restore an enum identifier, because the packed + * {@code EnumValue} does not preserve the enum type. + * * @param any * the ID value wrapped into {@code Any} * @param idClass @@ -304,7 +429,14 @@ public static Object unpack(Any any) { * @return unwrapped ID */ public static I unpack(Any any, Class idClass) { + checkNotNull(any); checkNotNull(idClass); + // Restrict to an actual Java `enum` (not merely a `ProtocolMessageEnum` implementor), + // mirroring `IdType.ENUM.matchClass()`. The `ProtocolMessageEnum` interface itself is + // assignable but is not an enum and cannot be converted by `TypeConverter`. + if (idClass.isEnum() && ProtocolMessageEnum.class.isAssignableFrom(idClass)) { + return TypeConverter.toObject(any, idClass); + } var identifier = unpack(any); return idClass.cast(identifier); } @@ -332,6 +464,7 @@ public static String newUuid() { *

  • for classes implementing {@link Message} — a JSON form; *
  • for {@code String}, {@code Long}, {@code Integer} — * the result of {@link Object#toString()}; + *
  • for a Protobuf enum — the {@linkplain Enum#name() name} of the constant; *
  • for {@code null} ID — the {@link #NULL_ID}; *
  • if the result is empty or a blank string — the {@link #EMPTY_ID}. * @@ -369,6 +502,7 @@ public String toString() { case INTEGER, LONG, STRING -> value.toString(); + case ENUM -> ((Enum) value).name(); case MESSAGE -> MessageIdToString.convert((Message) value); default -> throw newIllegalStateException( "`toString()` is not supported for type: `%s`.", type diff --git a/base/src/test/java/io/spine/base/IdentifierTest.java b/base/src/test/java/io/spine/base/IdentifierTest.java index 286f2908de..a02fdda1d8 100644 --- a/base/src/test/java/io/spine/base/IdentifierTest.java +++ b/base/src/test/java/io/spine/base/IdentifierTest.java @@ -1,11 +1,11 @@ /* - * Copyright 2022, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed 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 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -26,29 +26,57 @@ package io.spine.base; +import com.google.common.collect.ImmutableList; import com.google.common.testing.NullPointerTester; import com.google.common.truth.BooleanSubject; import com.google.common.truth.OptionalSubject; import com.google.protobuf.Any; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto; import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.EnumDescriptor; +import com.google.protobuf.Descriptors.EnumValueDescriptor; import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.protobuf.Empty; import com.google.protobuf.Int32Value; import com.google.protobuf.Int64Value; import com.google.protobuf.StringValue; +import com.google.protobuf.ProtocolMessageEnum; import com.google.protobuf.Struct; import com.google.protobuf.Timestamp; import io.spine.protobuf.AnyPacker; +import io.spine.test.identifiers.EnumFieldId; import io.spine.test.identifiers.IdWithPrimitiveFields; import io.spine.test.identifiers.NestedMessageId; import io.spine.test.identifiers.SeveralFieldsId; +import io.spine.test.identifiers.TaskStatus; import io.spine.test.identifiers.TimestampFieldId; +import io.spine.test.identifiers.TwoEnumFieldsId; import io.spine.testing.TestValues; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_BOOL; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_BYTES; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_DOUBLE; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_ENUM; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_FIXED32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_FIXED64; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_FLOAT; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_GROUP; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_INT32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_INT64; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_MESSAGE; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_SFIXED32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_SFIXED64; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_SINT32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_SINT64; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_STRING; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_UINT32; +import static com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_UINT64; +import static io.spine.base.IdType.ENUM; import static io.spine.base.IdType.INTEGER; import static io.spine.base.IdType.LONG; import static io.spine.base.IdType.MESSAGE; @@ -60,10 +88,12 @@ import static io.spine.protobuf.TypeConverter.toMessage; import static io.spine.testing.Assertions.assertIllegalArgument; import static io.spine.testing.DisplayNames.NOT_ACCEPT_NULLS; +import static java.util.Arrays.stream; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @DisplayName("`Identifier` should") @@ -117,6 +147,12 @@ void ofMessage() { assertTypeOf(toMessage(300), MESSAGE); } + @Test + @DisplayName("a Protobuf enum") + void ofEnum() { + assertTypeOf(TaskStatus.TASK_OPEN, ENUM); + } + private static void assertTypeOf(Object id, IdType expectedType) { Identifier identifier = Identifier.from(id); assertThat(identifier.type()).isEqualTo(expectedType); @@ -188,6 +224,13 @@ void ofMessage() { assertThat(Identifier.defaultValue(Timestamp.class)) .isEqualTo(Timestamp.getDefaultInstance()); } + + @Test + @DisplayName("a Protobuf enum") + void ofEnum() { + assertThat(Identifier.defaultValue(TaskStatus.class)) + .isEqualTo(TaskStatus.TASK_STATUS_UNDEFINED); + } } @Nested @@ -255,6 +298,17 @@ void messageId() { assertNotEmpty(Time.currentTime()); } + @Test + @DisplayName("treating the zero-numbered enum constant as empty") + void enumId() { + assertEmpty(TaskStatus.TASK_STATUS_UNDEFINED); + assertNotEmpty(TaskStatus.TASK_OPEN); + assertNotEmpty(TaskStatus.TASK_CLOSED); + // `UNRECOGNIZED` is not the zero value and must not be treated as empty + // (calling `getNumber()` on it would otherwise throw). + assertNotEmpty(TaskStatus.UNRECOGNIZED); + } + void assertNotEmpty(I value) { assertThatEmpty(value).isFalse(); } @@ -338,6 +392,12 @@ void ofMessage() { assertEquals(TEST_ID, result); } + @Test + @DisplayName("a Protobuf enum") + void ofEnum() { + assertEquals("TASK_OPEN", Identifier.toString(TaskStatus.TASK_OPEN)); + } + @Test @DisplayName("`Message` with nested `Message`") void ofNestedMessage() { @@ -447,6 +507,12 @@ void longValue() { void messageValue() { assertDoesNotThrow(() -> Identifier.checkSupported(StringValue.class)); } + + @Test + @DisplayName("a Protobuf enum class") + void enumValue() { + assertDoesNotThrow(() -> Identifier.checkSupported(TaskStatus.class)); + } } @Test @@ -455,6 +521,132 @@ void checkNotSupported() { assertIllegalArgument(() -> checkSupported(Boolean.class)); } + @Nested + @DisplayName("tell if a Protobuf field type is supported for an ID") + class SupportedIdType { + + private final ImmutableList supported = ImmutableList.of( + TYPE_STRING, + TYPE_INT32, TYPE_UINT32, TYPE_SINT32, TYPE_FIXED32, TYPE_SFIXED32, + TYPE_INT64, TYPE_UINT64, TYPE_SINT64, TYPE_FIXED64, TYPE_SFIXED64, + TYPE_ENUM, + TYPE_MESSAGE); + + private final ImmutableList unsupported = ImmutableList.of( + TYPE_BOOL, TYPE_FLOAT, TYPE_DOUBLE, TYPE_BYTES, TYPE_GROUP); + + @Test + @DisplayName("accepting `string`, integer, `enum`, and `Message` types") + void accepting() { + for (var type : supported) { + assertWithMessage("`%s` should be supported", type) + .that(Identifier.isSupportedIdType(type)) + .isTrue(); + } + } + + @Test + @DisplayName("rejecting `bool`, `float`, `double`, `bytes`, and `group` types") + void rejecting() { + for (var type : unsupported) { + assertWithMessage("`%s` should not be supported", type) + .that(Identifier.isSupportedIdType(type)) + .isFalse(); + } + } + + @Test + @DisplayName("classifying every declared field type") + void everyType() { + // `FieldDescriptorProto.Type` comes from the proto2 `descriptor.proto`, + // so it has no `UNRECOGNIZED` sentinel — every declared value must be classified. + for (var type : FieldDescriptorProto.Type.values()) { + var classified = supported.contains(type) || unsupported.contains(type); + assertWithMessage("`%s` must be classified as supported or not", type) + .that(classified) + .isTrue(); + } + } + + @Test + @DisplayName("not classifying any type as both supported and unsupported") + void noOverlap() { + for (var type : supported) { + assertThat(unsupported).doesNotContain(type); + } + } + + @Test + @DisplayName("accepting a supported field descriptor") + void supportedField() { + assertSupported(stringField(), true); + assertSupported(intField(), true); + assertSupported(longField(), true); + assertSupported(messageField(), true); + assertSupported(enumField(), true); + } + + @Test + @DisplayName("rejecting an unsupported field descriptor") + void unsupportedField() { + assertSupported(boolField(), false); + } + + @Test + @DisplayName("agreeing with `IdType.matchField` for each field") + void consistentWithMatchField() { + var fields = ImmutableList.of( + stringField(), intField(), longField(), + messageField(), enumField(), boolField()); + for (var field : fields) { + var anyIdTypeMatches = stream(IdType.values()).anyMatch(t -> t.matchField(field)); + assertWithMessage("Disagreement for field `%s`", field.getFullName()) + .that(Identifier.isSupportedIdType(field)) + .isEqualTo(anyIdTypeMatches); + } + } + + private void assertSupported(FieldDescriptor field, boolean expected) { + assertWithMessage("`%s`", field.getFullName()) + .that(Identifier.isSupportedIdType(field)) + .isEqualTo(expected); + } + + private FieldDescriptor severalFieldsField(int index) { + return SeveralFieldsId.getDescriptor() + .getFields() + .get(index); + } + + private FieldDescriptor stringField() { + return severalFieldsField(0); + } + + private FieldDescriptor intField() { + return severalFieldsField(1); + } + + private FieldDescriptor messageField() { + return severalFieldsField(2); + } + + private FieldDescriptor longField() { + return severalFieldsField(3); + } + + private FieldDescriptor enumField() { + return EnumFieldId.getDescriptor() + .getFields() + .get(0); + } + + private FieldDescriptor boolField() { + return IdWithPrimitiveFields.getDescriptor() + .getFields() + .get(2); + } + } + @Nested @DisplayName("reject unsupported") class RejectUnsupported { @@ -472,6 +664,81 @@ void value() { void clazz() { assertIllegalArgument(() -> Identifier.toType(Float.class)); } + + @Test + @DisplayName("the `ProtocolMessageEnum` interface, which is not a Java enum") + void protocolMessageEnumInterface() { + // The interface is assignable from itself but has no enum constants, so it must + // not be treated as an enum ID class (which would fail on `getEnumConstants()`). + assertIllegalArgument(() -> checkSupported(ProtocolMessageEnum.class)); + assertIllegalArgument(() -> Identifier.defaultValue(ProtocolMessageEnum.class)); + } + + @Test + @DisplayName("a `ProtocolMessageEnum` value that is not a Java enum constant") + void protocolMessageEnumValue() { + // A non-enum `ProtocolMessageEnum` instance must not be treated as an enum ID, + // which would later fail casting the value to `Enum` in `toString()`. + assertIllegalArgument(() -> Identifier.toString(new NotAnEnum())); + } + + @Test + @DisplayName("a plain Java enum, which is not a Protobuf enum") + void plainJavaEnum() { + // A Java enum that does not implement `ProtocolMessageEnum` is not a supported ID. + assertIllegalArgument(() -> Identifier.toString(PlainEnum.A)); + assertIllegalArgument(() -> checkSupported(PlainEnum.class)); + } + + @Test + @DisplayName("a plain Java enum class when unpacking") + void plainJavaEnumUnpacking() { + var any = AnyPacker.pack(StringValue.of(TEST_ID)); + // `PlainEnum` is a Java enum but not a Protobuf enum, so the enum branch is + // skipped and the `String` value cannot be cast to it. + assertThrows(ClassCastException.class, + () -> Identifier.unpack(any, PlainEnum.class)); + } + } + + /** + * A {@link ProtocolMessageEnum} implementation that is not a Java {@code enum}, + * used to verify that such values are not recognized as enum identifiers. + */ + private static final class NotAnEnum implements ProtocolMessageEnum { + + @Override + public int getNumber() { + return 1; + } + + @Override + public EnumValueDescriptor getValueDescriptor() { + throw new UnsupportedOperationException(); + } + + @Override + public EnumDescriptor getDescriptorForType() { + throw new UnsupportedOperationException(); + } + } + + /** + * A plain Java enum that does not implement {@link ProtocolMessageEnum}, + * used to verify that such types are not recognized as enum identifiers. + */ + private enum PlainEnum { + A, + B + } + + @Test + @DisplayName("not support restoring an enum from a `Message` without the target class") + void enumFromMessageUnsupported() { + // `IdType.ENUM.fromMessage` is never reached in normal flow (an enum cannot be + // restored without the target class); a direct call documents that contract. + assertThrows(IllegalStateException.class, + () -> ENUM.fromMessage(StringValue.of(TEST_ID))); } @Nested @@ -492,6 +759,14 @@ void anyWithStringValue() { void rejectEmptyAny() { assertIllegalArgument(() -> Identifier.unpack(Any.getDefaultInstance())); } + + @Test + @DisplayName("a Protobuf enum packed into `Any` using the target class") + void enumRoundTrip() { + var any = Identifier.pack(TaskStatus.TASK_OPEN); + var unpacked = Identifier.unpack(any, TaskStatus.class); + assertEquals(TaskStatus.TASK_OPEN, unpacked); + } } @Test @@ -537,6 +812,15 @@ void messgeField() { assertTrue(MESSAGE.matchField(field(2))); } + @Test + @DisplayName("a Protobuf enum") + void enumField() { + var field = EnumFieldId.getDescriptor() + .getFields() + .get(0); + assertTrue(ENUM.matchField(field)); + } + FieldDescriptor field(int index) { var field = SeveralFieldsId.getDescriptor() @@ -578,6 +862,24 @@ void messageType() { assertNotFound(StringValue.class, IdWithPrimitiveFields.getDescriptor()); } + @Test + @DisplayName("a Protobuf enum") + void enumType() { + assertFound(TaskStatus.class, EnumFieldId.getDescriptor()); + assertNotFound(TaskStatus.class, IdWithPrimitiveFields.getDescriptor()); + } + + @Test + @DisplayName("a Protobuf enum, by its specific enum type") + void enumTypeDisambiguated() { + // `TwoEnumFieldsId` declares `Priority priority = 1` before `TaskStatus status = 2`. + // Searching for a `TaskStatus` ID must return the `status` field, not the first + // enum field of an unrelated enum type. + var found = Field.findIdField(TaskStatus.class, TwoEnumFieldsId.getDescriptor()); + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("status"); + } + void assertFound(Class idClass, Descriptor message) { assertField(idClass, message).isPresent(); } diff --git a/base/src/test/kotlin/io/spine/base/IdTypeTest.kt b/base/src/test/kotlin/io/spine/base/IdTypeTest.kt index 1f320c9647..2b8b1c91ad 100644 --- a/base/src/test/kotlin/io/spine/base/IdTypeTest.kt +++ b/base/src/test/kotlin/io/spine/base/IdTypeTest.kt @@ -1,11 +1,11 @@ /* - * Copyright 2022, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed 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 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -27,8 +27,11 @@ package io.spine.base import com.google.common.truth.Truth.assertThat +import com.google.protobuf.EnumValue +import io.spine.base.IdType.ENUM import io.spine.base.IdType.MESSAGE import io.spine.base.IdType.STRING +import io.spine.test.identifiers.TaskStatus import org.junit.jupiter.api.Test class `'IdType' should` { @@ -42,4 +45,14 @@ class `'IdType' should` { assertThat(MESSAGE.fromMessage(wrapped)) .isSameInstanceAs(wrapped) } + + @Test + fun `convert a Protobuf enum to 'EnumValue'`() { + val message = ENUM.toMessage(TaskStatus.TASK_OPEN) + + assertThat(message).isInstanceOf(EnumValue::class.java) + message as EnumValue + assertThat(message.name).isEqualTo("TASK_OPEN") + assertThat(message.number).isEqualTo(TaskStatus.TASK_OPEN.number) + } } diff --git a/base/src/test/proto/spine/test/identifiers_test.proto b/base/src/test/proto/spine/test/identifiers_test.proto index 8633464920..f8416cd618 100644 --- a/base/src/test/proto/spine/test/identifiers_test.proto +++ b/base/src/test/proto/spine/test/identifiers_test.proto @@ -1,11 +1,11 @@ /* - * Copyright 2022, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed 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 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -23,6 +23,7 @@ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ + syntax = "proto3"; package spine.test.identifiers; @@ -65,3 +66,32 @@ message IdWithPrimitiveFields { message UuidMessage { string uuid = 1; } + +// An enum used for testing enum-typed identifiers. +// +// The zero-numbered constant is reserved by convention for the "undefined" value. +enum TaskStatus { + TASK_STATUS_UNDEFINED = 0; + TASK_OPEN = 1; + TASK_CLOSED = 2; +} + +// A message with an enum field declared first, for testing enum-typed identifiers. +message EnumFieldId { + TaskStatus status = 1; +} + +// A second enum, of a different type than `TaskStatus`, used to verify that +// an enum ID field is matched by its specific enum type. +enum Priority { + PRIORITY_UNDEFINED = 0; + LOW = 1; + HIGH = 2; +} + +// A message with two enum fields of different types, declaring the non-matching +// one first, for testing that an enum ID field is matched by its enum type. +message TwoEnumFieldsId { + Priority priority = 1; + TaskStatus status = 2; +} diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt index 63a9078ab9..0a95432524 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt @@ -33,8 +33,8 @@ package io.spine.dependency.local */ @Suppress("ConstPropertyName", "unused") object Base { - const val version = "2.0.0-SNAPSHOT.411" - const val versionForBuildScript = "2.0.0-SNAPSHOT.411" + const val version = "2.0.0-SNAPSHOT.413" + const val versionForBuildScript = "2.0.0-SNAPSHOT.413" const val group = Spine.group private const val prefix = "spine" const val libModule = "$prefix-base" diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt index 0584eb23c6..d118d7c627 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt @@ -72,7 +72,7 @@ object Compiler : Dependency() { * The version of the Compiler dependencies. */ override val version: String - private const val fallbackVersion = "2.0.0-SNAPSHOT.052" + private const val fallbackVersion = "2.0.0-SNAPSHOT.053" /** * The distinct version of the Compiler used by other build tools. @@ -81,7 +81,7 @@ object Compiler : Dependency() { * transitive dependencies, this is the version used to build the project itself. */ val dogfoodingVersion: String - private const val fallbackDfVersion = "2.0.0-SNAPSHOT.052" + private const val fallbackDfVersion = "2.0.0-SNAPSHOT.053" /** * The artifact for the Compiler Gradle plugin. diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt index 8937b0ef02..805fdb0c84 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt @@ -39,7 +39,7 @@ typealias CoreJava = CoreJvm @Suppress("ConstPropertyName", "unused") object CoreJvm { const val group = Spine.group - const val version = "2.0.0-SNAPSHOT.375" + const val version = "2.0.0-SNAPSHOT.376" const val coreArtifact = "spine-core" const val clientArtifact = "spine-client" diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt index 5a0ab49b14..1f91cf2490 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt @@ -46,12 +46,12 @@ object CoreJvmCompiler { /** * The version used in the build classpath. */ - const val dogfoodingVersion = "2.0.0-SNAPSHOT.073" + const val dogfoodingVersion = "2.0.0-SNAPSHOT.077" /** * The version to be used for integration tests. */ - const val version = "2.0.0-SNAPSHOT.073" + const val version = "2.0.0-SNAPSHOT.077" /** * The ID of the Gradle plugin. diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt index 0110a32f75..d9d23e772e 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt @@ -38,7 +38,7 @@ package io.spine.dependency.local ) object ProtoTap { const val group = Spine.toolsGroup - const val version = "0.15.0" + const val version = "0.16.0" const val gradlePluginId = "io.spine.prototap" const val api = "$group:prototap-api:$version" const val gradlePlugin = "$group:prototap-gradle-plugin:$version" diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/Cli.kt b/buildSrc/src/main/kotlin/io/spine/gradle/Cli.kt index 7e0c22748a..9424ea1ff1 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/Cli.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/Cli.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -63,11 +63,14 @@ class Cli(private val workingFolder: File) { redirectError(PIPE) }.start() - val exitCode = process.run { - inputStream!!.pourTo(outWriter) - errorStream!!.pourTo(errWriter) - waitFor() - } + val outReader = process.inputStream!!.pourTo(outWriter) + val errReader = process.errorStream!!.pourTo(errWriter) + val exitCode = process.waitFor() + // `waitFor()` returns on process exit but does not wait for the reader + // threads to finish draining the pipes; join them so the buffers hold + // the complete output before it is read below. + outReader.join() + errReader.join() if (exitCode == 0) { return outWriter.toString() @@ -83,14 +86,14 @@ class Cli(private val workingFolder: File) { } /** - * Asynchronously reads all lines from this [InputStream] and appends them - * to the passed [StringWriter]. + * Starts a background thread that reads all lines from this [InputStream] and + * appends them to [dest], returning the thread so the caller can [join][Thread.join] + * it once the process has exited, ensuring the buffer holds the complete output. */ -private fun InputStream.pourTo(dest: StringWriter) { +private fun InputStream.pourTo(dest: StringWriter): Thread = Thread { val sc = Scanner(this) while (sc.hasNextLine()) { dest.append(sc.nextLine()) } - }.start() -} + }.also { it.start() } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/git/Repository.kt b/buildSrc/src/main/kotlin/io/spine/gradle/git/Repository.kt index e0ce8275f9..8ce4d72a5e 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/git/Repository.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/git/Repository.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -53,6 +53,7 @@ import org.gradle.api.Project * This configuration determines what ends up in the `author` and `committer` fields of a commit. * @property currentBranch The currently checked-out branch. */ +@Suppress("TooManyFunctions") // A cohesive wrapper over many small `git` commands. class Repository private constructor( private val project: Project, private val sshUrl: String, @@ -86,6 +87,7 @@ class Repository private constructor( * Checks out the branch by its name. * * IMPORTANT. The branch must exist in the upstream repository. + * Use [checkoutOrCreate] to check out a branch that may not exist yet. */ fun checkout(branch: String) { repoExecute("git", "checkout", branch) @@ -94,6 +96,112 @@ class Repository private constructor( currentBranch = branch } + /** + * Checks out the [branch], creating it in the remote repository if it does + * not exist yet. + * + * If the branch is already present on the remote, it is [checked out][checkout] + * as usual. Otherwise, it is created as an orphan branch seeded with + * [initialFiles] and pushed to the remote, so that subsequent commits with the + * documentation have a branch to append to. + * + * Creating the branch on the fly makes the very first documentation publication + * of a repository self-sufficient: the [documentation branch][Branch.documentation] + * no longer needs to be created manually beforehand. + * + * @param branch the name of the branch to check out or create. + * @param initialFiles the files — paths relative to the repository root mapped + * to their content — to add to the initial commit when the branch is created. + * Ignored when the branch already exists. + */ + fun checkoutOrCreate(branch: String, initialFiles: Map = emptyMap()) { + if (remoteHasBranch(branch)) { + // `remoteHasBranch` queries the remote directly via `git ls-remote`, + // which does not populate `refs/remotes/origin/*`. In a parallel + // build another module may have created the branch after this clone, + // so fetch first to make the `origin/$branch` ref available; + // otherwise `git checkout` cannot guess it and fails with a + // pathspec error. + repoExecute("git", "fetch", "origin") + checkout(branch) + } else { + createOrphanBranch(branch, initialFiles) + } + } + + /** + * Tells whether the remote repository has a branch with the given [name]. + * + * Queries the fully-qualified ref `refs/heads/$name` rather than the bare + * [name]: `git ls-remote` treats a bare name as a tail glob and would also + * match a namespaced branch such as `feature/$name`. Relies on `git ls-remote` + * returning an empty output with a zero exit code when the branch is absent, + * so the check does not raise an exception. + */ + private fun remoteHasBranch(name: String): Boolean { + val output = repoExecute("git", "ls-remote", "--heads", "origin", "refs/heads/$name") + return output.isNotBlank() + } + + /** + * Creates the [branch] as an orphan branch seeded with [initialFiles] and + * pushes it to the remote. + * + * `git switch --orphan` starts a new history with an empty working tree, so + * the source code of the default branch does not leak into the created branch. + * The [initialFiles] are written into this clean tree and staged before the + * initial commit, which stays `--allow-empty` to support seeding no files. + */ + private fun createOrphanBranch(branch: String, initialFiles: Map) { + repoExecute("git", "switch", "--orphan", branch) + initialFiles.forEach { (path, content) -> + location.toFile().resolve(path).writeText(content) + repoExecute("git", "add", path) + } + repoExecute( + "git", + "commit", + "--allow-empty", + "--message=Initialize the `$branch` branch." + ) + currentBranch = branch + pushNewBranch(branch) + } + + /** + * Pushes the just-created [branch] to the remote, setting up the upstream tracking. + * + * If the push is rejected because a concurrently running publication created + * the branch first (e.g., another module publishing documentation in the same + * parallel build), the remote branch is [adopted][adoptRemoteBranch] instead. + * Otherwise, the failure is genuine, and the original exception is rethrown. + */ + private fun pushNewBranch(branch: String) { + try { + repoExecute("git", "push", "--set-upstream", "origin", branch) + } catch (e: IllegalStateException) { + // `Cli.execute` surfaces every non-zero `git` exit as an + // `IllegalStateException`, so this branch handles a rejected push. + // If the branch now exists on the remote, another module won the + // creation race and we adopt its branch; otherwise the failure is + // genuine and is rethrown. + repoExecute("git", "fetch", "origin") + if (!remoteHasBranch(branch)) { + throw e + } + adoptRemoteBranch(branch) + } + } + + /** + * Discards the local orphan branch in favour of the same-named branch that + * already exists on the remote, keeping the local branch in sync with it. + */ + private fun adoptRemoteBranch(branch: String) { + repoExecute("git", "reset", "--hard", "origin/$branch") + repoExecute("git", "branch", "--set-upstream-to=origin/$branch", branch) + } + /** * Configures the username and the email of the user. * @@ -154,7 +262,8 @@ class Repository private constructor( * See [configureUser] documentation for more information. * * Performs checkout of the branch in case it was passed. - * By default, [master][Branch.master] is checked out. + * By default, [master][Branch.master] is checked out. A non-default branch + * that does not exist yet is created and seeded with [initialFiles]. * * @throws IllegalArgumentException if SSH URL is an empty string. */ @@ -163,6 +272,7 @@ class Repository private constructor( sshUrl: String, user: UserInfo, branch: String = Branch.master, + initialFiles: Map = emptyMap(), ): Repository { require(sshUrl.isNotBlank()) { "SSH URL cannot be an empty string." } @@ -171,7 +281,7 @@ class Repository private constructor( repo.configureUser(user) if (branch != Branch.master) { - repo.checkout(branch) + repo.checkoutOrCreate(branch, initialFiles) } return repo diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/RepositoryExtensions.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/RepositoryExtensions.kt index de75295482..682d6478c6 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/RepositoryExtensions.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/RepositoryExtensions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,8 +38,10 @@ import org.gradle.api.Project * * The repository's GitHub SSH URL is derived from the `REPO_SLUG` environment * variable. The [branch][Branch.documentation] dedicated to publishing documentation - * is automatically checked out in this repository. Also, the username and the email - * of the git user are automatically configured. + * is automatically checked out in this repository, and created if it does not exist + * yet. A freshly created branch is seeded with a `CNAME` file so that GitHub Pages + * serves the documentation under the `spine.io` custom domain. Also, the username + * and the email of the git user are automatically configured. * * The username is set to `"UpdateGitHubPages Plugin"`, and the email is derived from * the `FORMAL_GIT_HUB_PAGES_AUTHOR` environment variable. @@ -56,5 +58,10 @@ internal fun Repository.Factory.forPublishingDocumentation(project: Project): Re val branch = Branch.documentation - return clone(project, host, user, branch) + // When the `gh-pages` branch is created from scratch, seed it with a `CNAME` + // file so that GitHub Pages serves the documentation under the `spine.io` + // custom domain. + val initialFiles = mapOf("CNAME" to "spine.io\n") + + return clone(project, host, user, branch, initialFiles) } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPages.kt b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPages.kt index 521d75101d..0382e83641 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPages.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/github/pages/UpdateGitHubPages.kt @@ -53,7 +53,7 @@ import org.gradle.api.tasks.TaskProvider * repository root. It is recommended to encrypt it in the repository and then decrypt * it on CI upon publication. Also, the script uses the `FORMAL_GIT_HUB_PAGES_AUTHOR` * environment variable to set the author email for the commits. The `gh-pages` - * branch itself should exist before the plugin is run. + * branch is created automatically if it does not exist yet. * * NOTE: when changing the value of "FORMAL_GIT_HUB_PAGES_AUTHOR", one also must change * the SSH private (encrypted `deploy_key_rsa`) and the public diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/repo/Repositories.kt b/buildSrc/src/main/kotlin/io/spine/gradle/repo/Repositories.kt index e6c3f677d9..800f2a38f8 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/repo/Repositories.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/repo/Repositories.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ package io.spine.gradle.repo import io.spine.gradle.publish.PublishingRepos import java.net.URI import org.gradle.api.artifacts.dsl.RepositoryHandler +import org.gradle.api.artifacts.repositories.ArtifactRepository import org.gradle.api.artifacts.repositories.MavenArtifactRepository import org.gradle.kotlin.dsl.maven @@ -94,24 +95,39 @@ fun RepositoryHandler.spineArtifacts(): MavenArtifactRepository = maven { } val RepositoryHandler.intellijReleases: MavenArtifactRepository - get() = maven("https://www.jetbrains.com/intellij-repository/releases") + get() = maven("https://www.jetbrains.com/intellij-repository/releases") { + includeIntelliJPlatformOnly() + } val RepositoryHandler.jetBrainsCacheRedirector: MavenArtifactRepository - get() = maven("https://cache-redirector.jetbrains.com/intellij-dependencies") + get() = maven("https://cache-redirector.jetbrains.com/intellij-dependencies") { + includeIntelliJPlatformOnly() + } val RepositoryHandler.intellijDependencies: MavenArtifactRepository get() = maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies") { - content { - includeGroupByRegex("com\\.jetbrains.*") - includeGroupByRegex("org\\.jetbrains.*") - includeGroupByRegex("com\\.intellij.*") - } + includeIntelliJPlatformOnly() } /** * Applies repositories commonly used by Spine Event Engine projects. */ fun RepositoryHandler.standardToSpineSdk() { + // + // General-purpose, highly available repositories come first. Gradle stops at + // the first repository that can serve an artifact, so keeping these ahead of + // the special-purpose ones means coordinates shared with them (such as + // `org.jetbrains:annotations`) resolve here and never reach a less reliable + // mirror like `cache-redirector.jetbrains.com`. + // + // `io.spine.*` modules are served only by the Spine repositories below, so + // they are excluded here. Otherwise Gradle would query Central / the Plugin + // Portal for every Spine module first, adding pointless lookups and making + // Spine resolution depend on the health of repositories that never host it. + // + mavenCentral { excludeSpine() } + gradlePluginPortal { excludeSpine() } + spineArtifacts() @Suppress("DEPRECATION") // Still use `CloudRepo` for earlier versions. @@ -131,16 +147,20 @@ fun RepositoryHandler.standardToSpineSdk() { } } + // IntelliJ Platform repositories. Each is restricted to the IntelliJ + // coordinates it serves (see `includeIntelliJPlatformOnly`), so a transient + // 5xx from one of them cannot break the resolution of unrelated artifacts. intellijReleases jetBrainsCacheRedirector intellijDependencies maven { url = URI(Repos.sonatypeSnapshots) + // This repository only ever serves snapshots; restrict it so it is not + // queried (and cannot fail the build) for release artifacts. + mavenContent { snapshotsOnly() } } - mavenCentral() - gradlePluginPortal() mavenLocal().includeSpineOnly() } @@ -180,3 +200,36 @@ private fun MavenArtifactRepository.includeSpineOnly() { includeGroupByRegex("io\\.spine.*") } } + +/** + * Excludes Spine artifact groups from this repository. + * + * `io.spine.*` modules are published only to the Spine repositories (each scoped + * via [includeSpineOnly]). Excluding them from a general-purpose repository keeps + * Gradle from querying it — and depending on its health — for coordinates it + * never hosts. + */ +private fun ArtifactRepository.excludeSpine() { + content { + excludeGroupByRegex("io\\.spine.*") + } +} + +/** + * Restricts a JetBrains/IntelliJ Platform repository to the coordinates it + * actually serves. + * + * These hosts — `cache-redirector.jetbrains.com` in particular — periodically + * answer with HTTP 5xx. Once Gradle sees such an error, it disables the + * repository for the rest of the build and fails the resolution instead of + * falling back to another repository. Without this filter the redirector is + * queried for every artifact, so a single 502 on an unrelated POM (such as + * `com.fasterxml.jackson:jackson-parent`) breaks the whole build. + */ +private fun MavenArtifactRepository.includeIntelliJPlatformOnly() { + content { + includeGroupByRegex("com\\.jetbrains.*") + includeGroupByRegex("org\\.jetbrains.*") + includeGroupByRegex("com\\.intellij.*") + } +} diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/license/LicenseReporter.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/license/LicenseReporter.kt index ed839ee91f..aa7e65f44c 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/license/LicenseReporter.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/license/LicenseReporter.kt @@ -96,6 +96,20 @@ object LicenseReporter { renderers = arrayOf(MarkdownReportRenderer(Paths.outputFilename)) } + + // The rendered report embeds the project's Maven coordinates — including its + // version — in the report header (see `Template.writeHeader`). The + // `generateLicenseReport` task is a `@CacheableTask` that keys its up-to-date check + // and build-cache entry on the resolved dependencies only, not on the project version. + // Without the version as an explicit input, a version-only change leaves the task + // `UP-TO-DATE` (or restorable from the build cache), so the report keeps the previous + // version while `pom.xml`, produced by an always-running task, is updated. Declaring + // the version as an input invalidates the cached output when it changes, so the report + // is regenerated. The value is read lazily so it reflects the version resolved at + // execution time, regardless of when `project.version` is assigned during configuration. + project.tasks.generateLicenseReport.configure { + inputs.property("projectVersion", project.provider { project.version.toString() }) + } } /** diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt index 3a9da7294b..0c4b23355f 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt @@ -225,7 +225,7 @@ internal class DependencyWriterSpec { @Test fun `preferring a known scope over that of an unknown configuration`() { - subproject("a-tools").declare("protoData", SPINE_BASE) + subproject("a-tools").declare("spineCompiler", SPINE_BASE) subproject("b-tests").declare("testImplementation", SPINE_BASE) val dependency = rootProject.dependencies().single() @@ -236,7 +236,7 @@ internal class DependencyWriterSpec { @Test fun `preferring the 'provided' scope over that of an unknown configuration`() { - subproject("a-tools").declare("protoData", SPINE_BASE) + subproject("a-tools").declare("spineCompiler", SPINE_BASE) subproject("b-lib").declare("compileOnly", SPINE_BASE) val dependency = rootProject.dependencies().single() @@ -248,7 +248,7 @@ internal class DependencyWriterSpec { @Test fun `omit the scope of a dependency coming only from an unknown configuration`() { - subproject("lib").declare("protoData", SPINE_BASE) + subproject("lib").declare("spineCompiler", SPINE_BASE) val dependency = rootProject.dependencies().single() diff --git a/config b/config index aea90ae9cf..d93220ab6d 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit aea90ae9cf4c263d0cf5b1d6817c905923cb025e +Subproject commit d93220ab6d1e3bc97c333894596bac8b9bc9e898 diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index 86ed5ce085..bc26d6d8ab 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine:spine-annotations:2.0.0-SNAPSHOT.413` +# Dependencies of `io.spine:spine-annotations:2.0.0-SNAPSHOT.420` ## Runtime 1. **Group** : org.jetbrains. **Name** : annotations. **Version** : 26.1.0. @@ -760,14 +760,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Tue Jun 16 17:11:42 WEST 2026** using +This report was generated on **Thu Jun 18 20:57:17 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:spine-base:2.0.0-SNAPSHOT.413` +# Dependencies of `io.spine:spine-base:2.0.0-SNAPSHOT.420` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -1604,14 +1604,14 @@ This report was generated on **Tue Jun 16 17:11:42 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Tue Jun 16 17:11:42 WEST 2026** using +This report was generated on **Thu Jun 18 20:57:17 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:spine-environment:2.0.0-SNAPSHOT.413` +# Dependencies of `io.spine:spine-environment:2.0.0-SNAPSHOT.420` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -2430,14 +2430,14 @@ This report was generated on **Tue Jun 16 17:11:42 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Tue Jun 16 17:11:42 WEST 2026** using +This report was generated on **Thu Jun 18 20:57:17 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:spine-format:2.0.0-SNAPSHOT.413` +# Dependencies of `io.spine:spine-format:2.0.0-SNAPSHOT.420` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -3336,6 +3336,6 @@ This report was generated on **Tue Jun 16 17:11:42 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Tue Jun 16 17:11:42 WEST 2026** using +This report was generated on **Thu Jun 18 20:57:17 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file diff --git a/docs/dependencies/pom.xml b/docs/dependencies/pom.xml index cbff2813ac..684cb71e6c 100644 --- a/docs/dependencies/pom.xml +++ b/docs/dependencies/pom.xml @@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject. --> io.spine base-libraries -2.0.0-SNAPSHOT.413 +2.0.0-SNAPSHOT.420 2015 diff --git a/gradle.properties b/gradle.properties index 559cd0d15f..7c2bb5f202 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,8 +12,24 @@ org.gradle.parallel=true # so cold builds skip work whose inputs are unchanged. org.gradle.caching=true -# Dokka plugin eats more memory than usual. Therefore, all builds should have enough. -org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+UseParallelGC -Dfile.encoding=UTF-8 +# Extra JVM args for the Gradle daemon, for two unrelated reasons: +# +# 1. The Dokka plugin eats more memory than usual, so all builds get a generous heap. +# 2. The `--add-exports` / `--add-opens` flags expose the `jdk.compiler` internals that +# Error Prone needs on JDK 16+ (JEP 396). Passing them to the daemon here lets the +# `net.ltgt.errorprone` plugin run Error Prone in-process instead of forking a separate +# compiler JVM per task. See https://github.com/SpineEventEngine/config/issues/543 +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+UseParallelGC -Dfile.encoding=UTF-8 \ + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED # suppress inspection "UnusedProperty" # The below property enables generation of XML reports for tests. diff --git a/init-submodules b/init-submodules new file mode 100755 index 0000000000..d2914ac6b9 --- /dev/null +++ b/init-submodules @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +################################################################################ +# +# Materialize the submodules a fresh working tree is missing, so agent assets +# resolve. +# +# `git worktree add` — and some shallow CI / cloud checkouts — populate only the +# superproject's own tracked files; registered submodules are left UNinitialized. +# In a Spine repo that means the `config` and `.agents/shared` submodules are +# empty, the `.agents/skills` -> `.agents/shared/skills` symlink dangles, and no +# agent skills, scripts, or guidelines can be found. +# +# This script is the bootstrap that has to run BEFORE `./config/pull`: `pull` +# lives inside the `config` submodule, so on a fresh worktree it does not yet +# exist. `init-submodules`, by contrast, is a plain tracked file at the repo root +# (distributed by `config`), so `git worktree add` always checks it out — it can +# therefore bring `config` itself into existence. +# +# It initializes ONLY submodules that are not yet checked out (those +# `git submodule status` marks with a leading `-`), at the commit the branch +# pins. Submodules already present are left exactly as they are, so a tree that +# floated `config` / `.agents/shared` to a branch tip via `./config/pull` is +# never silently rewound to the pin. That makes the script idempotent and safe to +# run on every session start. +# +# It does NOT float submodules to their branch tips — run `./config/pull` +# afterwards for that. Unlike `pull`, it depends on no pre-existing `config` +# submodule, so it can bootstrap a bare worktree where `./config/pull` does not +# yet exist. +# +################################################################################ + +set -u + +root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 +cd "$root" || exit 0 + +# Nothing to do in a repo without submodules. +[ -f .gitmodules ] || exit 0 + +# `git submodule status` prefixes each uninitialized submodule with `-`; an +# initialized one starts with a space (at the pinned commit) or `+` (ahead of +# it). Act only on the `-` lines, taking the path from the second field. +git submodule status 2>/dev/null | awk '$1 ~ /^-/ { print $2 }' | while read -r path; do + [ -n "$path" ] || continue + echo "init-submodules: initializing '$path'" + git submodule update --init --recursive -- "$path" \ + || echo "init-submodules: WARNING — could not initialize '$path' (offline?)." >&2 +done + +exit 0 diff --git a/version.gradle.kts b/version.gradle.kts index edbaca1e74..118dfb538f 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -24,4 +24,4 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -val versionToPublish: String by extra("2.0.0-SNAPSHOT.413") +val versionToPublish: String by extra("2.0.0-SNAPSHOT.420")