From 392cecf3221cb26dba170c733830528638b75354 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Thu, 23 Apr 2026 21:17:44 +0100 Subject: [PATCH 01/40] Consolidate `(when)` option handling --- .../spine/tools/time/validation/WhenOption.kt | 191 ++++++++++++++++++ .../time/validation/java/WhenGenerator.kt | 186 +++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 java/src/main/kotlin/io/spine/tools/time/validation/WhenOption.kt create mode 100644 java/src/main/kotlin/io/spine/tools/time/validation/java/WhenGenerator.kt diff --git a/java/src/main/kotlin/io/spine/tools/time/validation/WhenOption.kt b/java/src/main/kotlin/io/spine/tools/time/validation/WhenOption.kt new file mode 100644 index 00000000..99230a9d --- /dev/null +++ b/java/src/main/kotlin/io/spine/tools/time/validation/WhenOption.kt @@ -0,0 +1,191 @@ +/* + * 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.tools.time.validation + +import com.google.auto.service.AutoService +import com.google.protobuf.Timestamp +import io.spine.core.External +import io.spine.core.Subscribe +import io.spine.core.Where +import io.spine.protobuf.unpack +import io.spine.server.entity.alter +import io.spine.server.event.NoReaction +import io.spine.server.event.React +import io.spine.server.event.asA +import io.spine.server.tuple.EitherOf2 +import io.spine.time.Temporal +import io.spine.time.validation.Time +import io.spine.time.validation.TimeOption +import io.spine.time.validation.TimeFieldType +import io.spine.time.validation.TimeFieldType.TFT_TEMPORAL +import io.spine.time.validation.TimeFieldType.TFT_TIMESTAMP +import io.spine.time.validation.TimeFieldType.TFT_UNKNOWN +import io.spine.time.validation.WhenField +import io.spine.time.validation.event.WhenFieldDiscovered +import io.spine.time.validation.event.whenFieldDiscovered +import io.spine.tools.compiler.Compilation +import io.spine.tools.compiler.ast.Field +import io.spine.tools.compiler.ast.FieldRef +import io.spine.tools.compiler.ast.FieldType +import io.spine.tools.compiler.ast.File +import io.spine.tools.compiler.ast.event.FieldOptionDiscovered +import io.spine.tools.compiler.ast.extractMessageType +import io.spine.tools.compiler.ast.isRepeatedMessage +import io.spine.tools.compiler.ast.name +import io.spine.tools.compiler.ast.qualifiedName +import io.spine.tools.compiler.ast.ref +import io.spine.tools.compiler.check +import io.spine.tools.compiler.jvm.findJavaClassName +import io.spine.tools.compiler.jvm.javaClass +import io.spine.tools.compiler.plugin.Reaction +import io.spine.tools.compiler.plugin.View +import io.spine.tools.compiler.type.TypeSystem +import io.spine.tools.validation.ErrorPlaceholder.FIELD_PATH +import io.spine.tools.validation.ErrorPlaceholder.FIELD_TYPE +import io.spine.tools.validation.ErrorPlaceholder.FIELD_VALUE +import io.spine.tools.validation.ErrorPlaceholder.PARENT_TYPE +import io.spine.tools.validation.ErrorPlaceholder.WHEN_IN +import io.spine.tools.validation.OPTION_NAME +import io.spine.tools.validation.checkPlaceholders +import io.spine.tools.validation.defaultMessage +import io.spine.tools.validation.java.ValidationOption +import io.spine.tools.validation.java.generate.OptionGenerator +import io.spine.tools.validation.option.WHEN +import io.spine.tools.time.validation.java.WhenGenerator + +/** + * Extends the Java validation with code generation for the `(when)` option. + */ +@AutoService(ValidationOption::class) +public class WhenOption : ValidationOption { + + override val reactions: Set> = setOf(WhenReaction()) + + override val view: Set>> = setOf(WhenFieldView::class.java) + + override val generator: OptionGenerator = WhenGenerator() +} + +/** + * Controls whether a field should be validated with the `(when)` option. + * + * Whenever a field marked with the `(when)` options is discovered, emits + * [WhenFieldDiscovered] event if the following conditions are met: + * + * 1) The field type is supported by the option. + * 2) The error message does not contain unsupported placeholders. + * 3) The option value is other than [Time.TIME_UNDEFINED]. + * + * If (1) or (2) is violated, the reaction reports a compilation error. + * + * Violation of (3) means that the `(when)` option is applied correctly, + * but effectively disabled. [WhenFieldDiscovered] is not emitted for + * disabled options. In this case, the reaction emits [NoReaction] meaning + * that the option is ignored. + */ +internal class WhenReaction : Reaction() { + + @React + override fun whenever( + @External @Where(field = OPTION_NAME, equals = WHEN) + event: FieldOptionDiscovered + ): EitherOf2 { + val field = event.subject + val file = event.file + val timeType = checkFieldType(field, typeSystem, file) + + val option = event.option.value.unpack() + val timeBound = option.`in` + if (timeBound == Time.TIME_UNDEFINED) { + return ignore() + } + + val message = option.errorMsg.ifEmpty { option.descriptorForType.defaultMessage } + message.checkPlaceholders(SUPPORTED_PLACEHOLDERS, field, file, WHEN) + + return whenFieldDiscovered { + id = field.ref + subject = field + errorMessage = message + bound = timeBound + type = timeType + }.asA() + } +} + +private fun checkFieldType(field: Field, typeSystem: TypeSystem, file: File): TimeFieldType { + val timeType = typeSystem.determineTimeType(field.type) + Compilation.check(timeType != TFT_UNKNOWN, file, field.span) { + "The field type `${field.type.name}` of the `${field.qualifiedName}` field" + + " is not supported by the `(${WHEN})` option. Supported field types:" + + " `google.protobuf.Timestamp` and types introduced in the `spine.time` package" + + " that describe time-related concepts." + } + return timeType +} + +/** + * Analysis the given [fieldType], determining whether it represents + * the Protobuf [Timestamp] or Spine [Temporal]. + * + * For other field types, the method returns [TimeFieldType.TFT_UNKNOWN]. + */ +private fun TypeSystem.determineTimeType(fieldType: FieldType): TimeFieldType { + if (!fieldType.isMessage && !fieldType.isRepeatedMessage) { + return TFT_UNKNOWN + } + val messageType = fieldType.extractMessageType(typeSystem = this)?.name + val javaClass = messageType?.findJavaClassName(typeSystem = this)?.javaClass() + return when { + javaClass == null -> TFT_UNKNOWN + javaClass == Timestamp::class.java -> TFT_TIMESTAMP + Temporal::class.java.isAssignableFrom(javaClass) -> TFT_TEMPORAL + else -> TFT_UNKNOWN + } +} + +/** + * A view of a field that is marked with the `(when)` option. + */ +internal class WhenFieldView : View() { + + @Subscribe + fun on(e: WhenFieldDiscovered) = alter { + subject = e.subject + errorMessage = e.errorMessage + bound = e.bound + type = e.type + } +} + +private val SUPPORTED_PLACEHOLDERS = setOf( + FIELD_PATH, + FIELD_TYPE, + FIELD_VALUE, + PARENT_TYPE, + WHEN_IN, +) diff --git a/java/src/main/kotlin/io/spine/tools/time/validation/java/WhenGenerator.kt b/java/src/main/kotlin/io/spine/tools/time/validation/java/WhenGenerator.kt new file mode 100644 index 00000000..e16a98db --- /dev/null +++ b/java/src/main/kotlin/io/spine/tools/time/validation/java/WhenGenerator.kt @@ -0,0 +1,186 @@ +/* + * 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.tools.time.validation.java + +import io.spine.base.FieldPath +import io.spine.server.query.select +import io.spine.time.validation.Time.FUTURE +import io.spine.time.validation.TimeFieldType.TFT_TEMPORAL +import io.spine.time.validation.TimeFieldType.TFT_TIMESTAMP +import io.spine.time.validation.WhenField +import io.spine.tools.compiler.ast.TypeName +import io.spine.tools.compiler.ast.isRepeatedMessage +import io.spine.tools.compiler.ast.name +import io.spine.tools.compiler.jvm.CodeBlock +import io.spine.tools.compiler.jvm.Expression +import io.spine.tools.compiler.jvm.JavaValueConverter +import io.spine.tools.compiler.jvm.ReadVar +import io.spine.tools.compiler.jvm.StringLiteral +import io.spine.tools.compiler.jvm.call +import io.spine.tools.compiler.jvm.field +import io.spine.tools.validation.ErrorPlaceholder +import io.spine.tools.validation.ErrorPlaceholder.FIELD_PATH +import io.spine.tools.validation.ErrorPlaceholder.FIELD_TYPE +import io.spine.tools.validation.ErrorPlaceholder.FIELD_VALUE +import io.spine.tools.validation.ErrorPlaceholder.PARENT_TYPE +import io.spine.tools.validation.ErrorPlaceholder.WHEN_IN +import io.spine.tools.validation.java.expression.EmptyFieldCheck +import io.spine.tools.validation.java.expression.JsonExtensionsClass +import io.spine.tools.validation.java.expression.SpineTime +import io.spine.tools.validation.java.expression.TimestampsClass +import io.spine.tools.validation.java.expression.constraintViolation +import io.spine.tools.validation.java.expression.joinToString +import io.spine.tools.validation.java.expression.orElse +import io.spine.tools.validation.java.expression.resolve +import io.spine.tools.validation.java.expression.stringify +import io.spine.tools.validation.java.expression.templateString +import io.spine.tools.validation.java.generate.MessageScope.message +import io.spine.tools.validation.java.generate.OptionGeneratorWithConverter +import io.spine.tools.validation.java.generate.SingleOptionCode +import io.spine.tools.validation.java.generate.ValidateScope.parentName +import io.spine.tools.validation.java.generate.ValidateScope.parentPath +import io.spine.tools.validation.java.generate.ValidateScope.violations +import io.spine.tools.validation.option.WHEN +import io.spine.validation.ConstraintViolation + +/** + * The generator for the `(when)` option. + */ +internal class WhenGenerator : OptionGeneratorWithConverter() { + + /** + * All `(when)` fields in the current compilation process. + */ + private val allWhenFields by lazy { + querying.select() + .all() + } + + override fun codeFor(type: TypeName): List = + allWhenFields + .filter { it.id.type == type } + .map { GenerateWhen(it, converter).code() } +} + +/** + * Generates code for a single application of the `(when)` option + * represented by the [view]. + */ +private class GenerateWhen( + private val view: WhenField, + override val converter: JavaValueConverter +) : EmptyFieldCheck { + + private val field = view.subject + private val fieldType = field.type + private val declaringType = field.declaringType + private val fieldValue = message.field(field).getter() + + /** + * Returns the generated code. + */ + fun code(): SingleOptionCode = when { + fieldType.isMessage -> validateTime(fieldValue) + fieldType.isRepeatedMessage -> + CodeBlock( + """ + for (var element : $fieldValue) { + ${validateTime(ReadVar("element"))} + } + """.trimIndent() + ) + + else -> unsupportedFieldType() + }.run { SingleOptionCode(this) } + + /** + * Yields an expression to check if the provided [fieldValue] matches + * the time [restriction][WhenField.getBound]. + * + * The reported violations are appended to [violations] list, if any. + * + * Depending on the field type, the method uses either Protobuf's + * [Timestamps.compare()][com.google.protobuf.util.Timestamps.compare] + * or Spine's [Temporal.isInPast()][io.spine.time.Temporal.isInPast] and + * [Temporal.isInFuture()][io.spine.time.Temporal.isInFuture] methods. + */ + private fun validateTime(fieldValue: Expression): CodeBlock { + val isTimeOutOfBound = when (view.type) { + TFT_TIMESTAMP -> { + val operator = if (view.bound == FUTURE) "<" else ">" + "$TimestampsClass.compare($fieldValue, $SpineTime.currentTime()) $operator 0" + } + + TFT_TEMPORAL -> { + val checkBound = if (view.bound == FUTURE) "isInPast" else "isInFuture" + "$fieldValue.$checkBound()" + } + + else -> unsupportedFieldType() + } + return CodeBlock( + """ + if (!${field.hasDefaultValue()} && $isTimeOutOfBound) { + var fieldPath = ${parentPath.resolve(field.name)}; + var typeName = ${parentName.orElse(declaringType)}; + var violation = ${violation(ReadVar("fieldPath"), ReadVar("typeName"), fieldValue)}; + $violations.add(violation); + } + """.trimIndent() + ) + } + + private fun violation( + fieldPath: Expression, + typeName: Expression, + fieldValue: Expression<*>, + ): Expression { + val typeNameStr = typeName.stringify() + val placeholders = supportedPlaceholders(fieldPath, typeNameStr, fieldValue) + val errorMessage = templateString(view.errorMessage, placeholders, WHEN) + return constraintViolation(errorMessage, typeNameStr, fieldPath, fieldValue) + } + + private fun supportedPlaceholders( + fieldPath: Expression, + typeName: Expression, + fieldValue: Expression<*>, + ): Map> = mapOf( + FIELD_PATH to fieldPath.joinToString(), + FIELD_VALUE to JsonExtensionsClass.call("toCompactJson", fieldValue), + FIELD_TYPE to StringLiteral(fieldType.name), + PARENT_TYPE to typeName, + WHEN_IN to StringLiteral("${view.bound}".lowercase()) + ) + + private fun unsupportedFieldType(): Nothing = + error( + "The field type `${field.type.name}` is not supported by `${this::class.simpleName}`." + + " Please ensure that the supported field types in this generator match those" + + " used by the reaction, which verified `${view::class.simpleName}`." + ) +} From fc1174beead35639719d8786162fc76a6a1f59ce Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Thu, 23 Apr 2026 21:46:49 +0100 Subject: [PATCH 02/40] Introduce `time-validation-tests` module --- time-validation-tests/Module.md | 5 + time-validation-tests/build.gradle.kts | 57 ++++++++++++ .../time/validation/CompilationErrorTest.kt | 41 ++++++++ .../tools/time/validation/WhenReactionSpec.kt | 93 +++++++++++++++++++ .../spine/validation/when_option_spec.proto | 63 +++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 time-validation-tests/Module.md create mode 100644 time-validation-tests/build.gradle.kts create mode 100644 time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/CompilationErrorTest.kt create mode 100644 time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/WhenReactionSpec.kt create mode 100644 time-validation-tests/src/testFixtures/proto/spine/validation/when_option_spec.proto diff --git a/time-validation-tests/Module.md b/time-validation-tests/Module.md new file mode 100644 index 00000000..1290906d --- /dev/null +++ b/time-validation-tests/Module.md @@ -0,0 +1,5 @@ +# Module `time-validation-tests` + +This is a test-only module that verifies compilation of Protobuf files using +the `(when)` time-related validation option. The module is based on Prototap and +verifies the handling of errors in using the `(when)` option on non-temporal fields. diff --git a/time-validation-tests/build.gradle.kts b/time-validation-tests/build.gradle.kts new file mode 100644 index 00000000..d5dfc75b --- /dev/null +++ b/time-validation-tests/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * Copyright 2025, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import io.spine.dependency.artifact +import io.spine.dependency.lib.Protobuf +import io.spine.dependency.local.Compiler +import io.spine.dependency.local.Logging +import io.spine.dependency.test.JUnit.Jupiter +import io.spine.gradle.report.license.LicenseReporter + +plugins { + kotlin("jvm") + id("module-testing") + protobuf + `java-test-fixtures` + prototap +} +LicenseReporter.generateReportIn(project) + +dependencies { + implementation(project(":java")) + implementation(project(":jvm-runtime")) + + testImplementation(Logging.testLib)?.because("We need `tapConsole`.") + testImplementation(Compiler.testlib) + + testFixturesImplementation(Compiler.api) + testFixturesImplementation(Compiler.testlib) + testFixturesImplementation(Jupiter.artifact { params }) +} + +protobuf { + protoc { artifact = Protobuf.compiler } +} diff --git a/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/CompilationErrorTest.kt b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/CompilationErrorTest.kt new file mode 100644 index 00000000..715368cb --- /dev/null +++ b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/CompilationErrorTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.tools.time.validation + +import io.spine.testing.compiler.AbstractCompilationErrorTest +import io.spine.tools.compiler.plugin.Plugin +import io.spine.tools.validation.java.JavaValidationPlugin + +/** + * An abstract base for compilation error tests of [JavaValidationPlugin]. + */ +internal abstract class CompilationErrorTest : AbstractCompilationErrorTest() { + + override fun plugins(): List = listOf( + object : JavaValidationPlugin() {} + ) +} diff --git a/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/WhenReactionSpec.kt b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/WhenReactionSpec.kt new file mode 100644 index 00000000..b5d6fec4 --- /dev/null +++ b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/WhenReactionSpec.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2025, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.tools.time.validation + +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldInclude +import io.spine.tools.compiler.ast.name +import io.spine.tools.compiler.ast.qualifiedName +import io.spine.tools.compiler.protobuf.field +import io.spine.tools.validation.given.WhenBoolField +import io.spine.tools.validation.given.WhenInt32Field +import io.spine.tools.validation.given.WhenStringField +import io.spine.tools.validation.given.WhenWithInvalidPlaceholders +import io.spine.tools.validation.option.WHEN +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`WhenReaction` should reject") +internal class WhenReactionSpec : CompilationErrorTest() { + + @Test + fun `option on a boolean field`() { + val message = WhenBoolField.getDescriptor() + val error = assertCompilationFails(message) + val field = message.field("value") + error.message.run { + shouldContain(field.type.name) + shouldContain(field.qualifiedName) + shouldContain("is not supported") + } + } + + @Test + fun `option on an integer field`() { + val message = WhenInt32Field.getDescriptor() + val error = assertCompilationFails(message) + val field = message.field("value") + error.message.run { + shouldContain(field.type.name) + shouldContain(field.qualifiedName) + shouldContain("is not supported") + } + } + + @Test + fun `option on a string field`() { + val message = WhenStringField.getDescriptor() + val error = assertCompilationFails(message) + val field = message.field("value") + error.message.run { + shouldContain(field.type.name) + shouldContain(field.qualifiedName) + shouldContain("is not supported") + } + } + + @Test + fun `the error message with unsupported placeholders`() { + val message = WhenWithInvalidPlaceholders.getDescriptor() + val error = assertCompilationFails(message) + val field = message.field("value") + error.message.run { + shouldContain(field.qualifiedName) + shouldContain(WHEN) + shouldContain("unsupported placeholders") + shouldInclude("[when]") + } + } +} diff --git a/time-validation-tests/src/testFixtures/proto/spine/validation/when_option_spec.proto b/time-validation-tests/src/testFixtures/proto/spine/validation/when_option_spec.proto new file mode 100644 index 00000000..0bc85ce8 --- /dev/null +++ b/time-validation-tests/src/testFixtures/proto/spine/validation/when_option_spec.proto @@ -0,0 +1,63 @@ +/* + * Copyright 2025, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (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.validation.stubs; + +import "spine/options.proto"; +import "spine/time_options.proto"; + +import "google/protobuf/timestamp.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.tools.validation.given"; +option java_outer_classname = "WhenOptionSpecProto"; +option java_multiple_files = true; + +// Provides a boolean field with the inapplicable `(when)` option. +message WhenBoolField { + bool value = 1 [(when).in = FUTURE]; +} + +// Provides an int32 field with the inapplicable `(when)` option. +message WhenInt32Field { + int32 value = 1 [(when).in = FUTURE]; +} + +// Provides a string field with the inapplicable `(when)` option. +message WhenStringField { + string value = 1 [(when).in = PAST]; +} + +// Provides a `(when)` field that specifies a custom error message using +// the placeholders not supported by the option. +message WhenWithInvalidPlaceholders { + google.protobuf.Timestamp value = 1 [(when) = { + in: PAST, + error_msg: "The field value `${field.value}` must be in `${when}`." + }]; +} From 1bf0d8298340bc1cfcce65e1548afa45ddc27239 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 24 Apr 2026 16:42:50 +0100 Subject: [PATCH 03/40] Extract `time-validating` module Also: * Eliminate duplicating test dependencies. --- tests/time-validating/build.gradle.kts | 58 +++++ .../options/when/ProtoTimestampWhenSpec.kt | 240 +++++++++++++++++ .../options/when/SpineTemporalWhenSpec.kt | 241 ++++++++++++++++++ .../spine/test/tools/validate/when.proto | 69 +++++ .../test/tools/validate/when_repeated.proto | 69 +++++ 5 files changed, 677 insertions(+) create mode 100644 tests/time-validating/build.gradle.kts create mode 100644 tests/time-validating/src/test/kotlin/io/spine/test/options/when/ProtoTimestampWhenSpec.kt create mode 100644 tests/time-validating/src/test/kotlin/io/spine/test/options/when/SpineTemporalWhenSpec.kt create mode 100644 tests/time-validating/src/testFixtures/proto/spine/test/tools/validate/when.proto create mode 100644 tests/time-validating/src/testFixtures/proto/spine/test/tools/validate/when_repeated.proto diff --git a/tests/time-validating/build.gradle.kts b/tests/time-validating/build.gradle.kts new file mode 100644 index 00000000..99b96720 --- /dev/null +++ b/tests/time-validating/build.gradle.kts @@ -0,0 +1,58 @@ +/* + * 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import io.spine.dependency.local.Base +import io.spine.dependency.local.Logging +import io.spine.dependency.local.Time +import io.spine.dependency.local.Validation +import io.spine.gradle.report.license.LicenseReporter + +plugins { + `java-test-fixtures` + kotlin("jvm") + id("module-testing") +} +LicenseReporter.generateReportIn(project) + +dependencies { + testFixturesImplementation(Base.lib) + testFixturesImplementation(Time.lib) + testFixturesImplementation(Logging.lib) + testFixturesImplementation(Validation.runtime) + + testImplementation(testFixtures(project(":tests:validating"))) + testImplementation(Time.lib) +} + +afterEvaluate { + tasks.named("kspTestFixturesKotlin") { + mustRunAfter("launchTestFixturesSpineCompiler") + } +} + +val testFixturesJar by tasks.getting(Jar::class) { + duplicatesStrategy = DuplicatesStrategy.INCLUDE +} diff --git a/tests/time-validating/src/test/kotlin/io/spine/test/options/when/ProtoTimestampWhenSpec.kt b/tests/time-validating/src/test/kotlin/io/spine/test/options/when/ProtoTimestampWhenSpec.kt new file mode 100644 index 00000000..3a7b5a38 --- /dev/null +++ b/tests/time-validating/src/test/kotlin/io/spine/test/options/when/ProtoTimestampWhenSpec.kt @@ -0,0 +1,240 @@ +/* + * Copyright 2024, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.test.options.`when` + +import com.google.protobuf.Duration +import com.google.protobuf.Timestamp +import com.google.protobuf.util.Durations.fromMillis +import com.google.protobuf.util.Timestamps +import io.spine.test.tools.validate.anyProtoTimestamp +import io.spine.test.tools.validate.anyProtoTimestamps +import io.spine.test.tools.validate.futureProtoTimestamp +import io.spine.test.tools.validate.futureProtoTimestamps +import io.spine.test.tools.validate.pastProtoTimestamp +import io.spine.test.tools.validate.pastProtoTimestamps +import io.spine.tools.validation.assertions.assertValidationFails +import io.spine.tools.validation.assertions.assertValidationPasses +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("If used with Protobuf `Timestamp`, `(when)` constrain should") +internal class ProtoTimestampWhenSpec { + + @Nested inner class + `when given a timestamp denoting` { + + @Nested inner class + `the past` { + + @Test + fun `throw, if restricted to be in future`() = assertValidationFails { + futureProtoTimestamp { + value = pastTime() + } + } + + @Test + fun `pass, if restricted to be in past`() = assertValidationPasses { + pastProtoTimestamp { + value = pastTime() + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anyProtoTimestamp { + value = pastTime() + } + } + } + + @Nested inner class + `the future` { + + @Test + fun `throw, if restricted to be in past`() = assertValidationFails { + pastProtoTimestamp { + value = futureTime() + } + } + + @Test + fun `pass, if restricted to be in future`() = assertValidationPasses { + futureProtoTimestamp { + value = futureTime() + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anyProtoTimestamp { + value = futureTime() + } + } + } + } + + @Nested inner class + `when given several timestamps` { + + @Nested inner class + `denoting only the past` { + + private val severalPastTimes = listOf(pastTime(), pastTime(), pastTime()) + + @Test + fun `throw, if restricted to be in future`() = assertValidationFails { + futureProtoTimestamps { + value.addAll(severalPastTimes) + } + } + + @Test + fun `pass, if restricted to be in past`() = assertValidationPasses { + pastProtoTimestamps { + value.addAll(severalPastTimes) + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anyProtoTimestamps { + value.addAll(severalPastTimes) + } + } + } + + @Nested inner class + `denoting only the future` { + + private val severalFutureTimes = listOf(futureTime(), futureTime(), futureTime()) + + @Test + fun `throw, if restricted to be in past`() = assertValidationFails { + pastProtoTimestamps { + value.addAll(severalFutureTimes) + } + } + + @Test + fun `pass, if restricted to be in future`() = assertValidationPasses { + futureProtoTimestamps { + value.addAll(severalFutureTimes) + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anyProtoTimestamps { + value.addAll(severalFutureTimes) + } + } + } + + @Nested inner class + `with a single past stamp within the future stamps` { + + private val severalFutureAndPast = listOf(futureTime(), pastTime(), futureTime()) + + @Test + fun `throw, if restricted to be in future`() = assertValidationFails { + futureProtoTimestamps { + value.addAll(severalFutureAndPast) + } + } + + @Test + fun `throw, if restricted to be in past`() = assertValidationFails { + pastProtoTimestamps { + value.addAll(severalFutureAndPast) + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anyProtoTimestamps { + value.addAll(severalFutureAndPast) + } + } + } + + @Nested inner class + `with a single future stamp within the past stamps` { + + private val severalPastAndFuture = listOf(pastTime(), futureTime(), pastTime()) + + @Test + fun `throw, if restricted to be in future`() = assertValidationFails { + futureProtoTimestamps { + value.addAll(severalPastAndFuture) + } + } + + @Test + fun `throw, if restricted to be in past`() = assertValidationFails { + pastProtoTimestamps { + value.addAll(severalPastAndFuture) + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anyProtoTimestamps { + value.addAll(severalPastAndFuture) + } + } + } + } +} + +private fun pastTime(): Timestamp { + val current = Timestamps.now() + val past = Timestamps.subtract(current, HALF_OF_SECONDS) + return past +} + +private fun futureTime(): Timestamp { + val current = Timestamps.now() + val future = Timestamps.add(current, HALF_OF_SECONDS) + return future +} + +/** + * Protobuf [Duration] of five hundred milliseconds. + * + * To shift the time into the past or future, we add or subtract a difference of this amount. + * + * There are two reasons for choosing 500 milliseconds: + * + * 1. The generated code uses `io.spine.base.Time.currentTime()` to get the current timestamp + * for comparison. In turn, this method relies on `io.spine.base.Time.SystemTimeProvider` + * by default, which has millisecond precision. + * 2. Adding too small amount of time to make the stamp denote "future" might be unreliable. + * As it could catch up `now` by the time `Time.currentTime()` is invoked. + */ +private val HALF_OF_SECONDS: Duration = fromMillis(500) diff --git a/tests/time-validating/src/test/kotlin/io/spine/test/options/when/SpineTemporalWhenSpec.kt b/tests/time-validating/src/test/kotlin/io/spine/test/options/when/SpineTemporalWhenSpec.kt new file mode 100644 index 00000000..0e4a3f0f --- /dev/null +++ b/tests/time-validating/src/test/kotlin/io/spine/test/options/when/SpineTemporalWhenSpec.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2024, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.test.options.`when` + +import io.spine.test.tools.validate.anySpineTemporal +import io.spine.test.tools.validate.anySpineTemporals +import io.spine.test.tools.validate.futureSpineTemporal +import io.spine.test.tools.validate.futureSpineTemporals +import io.spine.test.tools.validate.pastSpineTemporal +import io.spine.test.tools.validate.pastSpineTemporals +import io.spine.time.LocalDateTimes +import io.spine.tools.validation.assertions.assertValidationFails +import io.spine.tools.validation.assertions.assertValidationPasses +import java.time.Instant +import java.time.LocalDateTime.ofInstant +import java.time.ZoneOffset.UTC +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import io.spine.time.LocalDateTime as SpineTimeLocalDateTime + +@DisplayName("If used with Spine `Temporal`, `(when)` constrain should") +internal class SpineTemporalWhenSpec { + + @Nested inner class + `when given a temporal denoting` { + + @Nested inner class + `the past` { + + @Test + fun `throw, if restricted to be in future`() = assertValidationFails { + futureSpineTemporal { + value = pastTime() + } + } + + @Test + fun `pass, if restricted to be in past`() = assertValidationPasses { + pastSpineTemporal { + value = pastTime() + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anySpineTemporal { + value = pastTime() + } + } + } + + @Nested inner class + `the future` { + + @Test + fun `throw, if restricted to be in past`() = assertValidationFails { + pastSpineTemporal { + value = futureTime() + } + } + + @Test + fun `pass, if restricted to be in future`() = assertValidationPasses { + futureSpineTemporal { + value = futureTime() + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anySpineTemporal { + value = futureTime() + } + } + } + } + + @Nested inner class + `when given several times` { + + @Nested inner class + `denoting only the past` { + + private val severalPastTimes = listOf(pastTime(), pastTime(), pastTime()) + + @Test + fun `throw, if restricted to be in future`() = assertValidationFails { + futureSpineTemporals { + value.addAll(severalPastTimes) + } + } + + @Test + fun `pass, if restricted to be in past`() = assertValidationPasses { + pastSpineTemporals { + value.addAll(severalPastTimes) + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anySpineTemporals { + value.addAll(severalPastTimes) + } + } + } + + @Nested inner class + `denoting only the future` { + + private val severalFutureTimes = listOf(futureTime(), futureTime(), futureTime()) + + @Test + fun `throw, if restricted to be in past`() = assertValidationFails { + pastSpineTemporals { + value.addAll(severalFutureTimes) + } + } + + @Test + fun `pass, if restricted to be in future`() = assertValidationPasses { + futureSpineTemporals { + value.addAll(severalFutureTimes) + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anySpineTemporals { + value.addAll(severalFutureTimes) + } + } + } + + @Nested inner class + `with a single past time within the future times` { + + private val severalFutureAndPast = listOf(futureTime(), pastTime(), futureTime()) + + @Test + fun `throw, if restricted to be in future`() = assertValidationFails { + futureSpineTemporals { + value.addAll(severalFutureAndPast) + } + } + + @Test + fun `throw, if restricted to be in past`() = assertValidationFails { + pastSpineTemporals { + value.addAll(severalFutureAndPast) + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anySpineTemporals { + value.addAll(severalFutureAndPast) + } + } + } + + @Nested inner class + `with a single future time within the past times` { + + private val severalPastAndFuture = listOf(pastTime(), futureTime(), pastTime()) + + @Test + fun `throw, if restricted to be in future`() = assertValidationFails { + futureSpineTemporals { + value.addAll(severalPastAndFuture) + } + } + + @Test + fun `throw, if restricted to be in past`() = assertValidationFails { + pastSpineTemporals { + value.addAll(severalPastAndFuture) + } + } + + @Test + fun `pass, if not restricted at all`() = assertValidationPasses { + anySpineTemporals { + value.addAll(severalPastAndFuture) + } + } + } + } +} + +private fun pastTime(): SpineTimeLocalDateTime { + val current = Instant.now() // It is a UTC stamp. + val past = current.minusMillis(HALF_OF_SECOND) + return LocalDateTimes.of(ofInstant(past, UTC)) +} + +private fun futureTime(): SpineTimeLocalDateTime { + val current = Instant.now() // It is a UTC stamp. + val past = current.plusMillis(HALF_OF_SECOND) + return LocalDateTimes.of(ofInstant(past, UTC)) +} + +/** + * Five hundred milliseconds. + * + * To shift the time into the past or future, we add or subtract a difference of this amount. + * + * There are two reasons for choosing 500 milliseconds: + * + * 1. The generated code uses `io.spine.base.Time.currentTime()` to get the current timestamp + * for comparison. In turn, this method relies on `io.spine.base.Time.SystemTimeProvider` + * by default, which has millisecond precision. + * 2. Adding too small amount of time to make the stamp denote "future" might be unreliable. + * As it could catch up `now` by the time `Time.currentTime()` is invoked. + */ +private const val HALF_OF_SECOND: Long = 500 diff --git a/tests/time-validating/src/testFixtures/proto/spine/test/tools/validate/when.proto b/tests/time-validating/src/testFixtures/proto/spine/test/tools/validate/when.proto new file mode 100644 index 00000000..b25a952a --- /dev/null +++ b/tests/time-validating/src/testFixtures/proto/spine/test/tools/validate/when.proto @@ -0,0 +1,69 @@ +/* + * Copyright 2024, 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 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (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.tools.validate; + +import "spine/options.proto"; +import "spine/time_options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.test.tools.validate"; +option java_outer_classname = "WhenProto"; +option java_multiple_files = true; + +import "google/protobuf/timestamp.proto"; +import "spine/time/time.proto"; + +// Tests `PAST` restriction with a Protobuf timestamp. +message PastProtoTimestamp { + google.protobuf.Timestamp value = 1 [(when).in = PAST]; +} + +// Tests `PAST` restriction with a Spine temporal. +message PastSpineTemporal { + spine.time.LocalDateTime value = 1 [(when).in = PAST]; +} + +// Tests `FUTURE` restriction with a Protobuf timestamp. +message FutureProtoTimestamp { + google.protobuf.Timestamp value = 1 [(when).in = FUTURE]; +} + +// Tests `FUTURE` restriction with a Spine temporal. +message FutureSpineTemporal { + spine.time.LocalDateTime value = 1 [(when).in = FUTURE]; +} + +// Tests that a Protobuf timestamp is not restricted when there's no option. +message AnyProtoTimestamp { + google.protobuf.Timestamp value = 1; +} + +// Tests that a Spine temporal is not restricted when there's no option. +message AnySpineTemporal { + spine.time.LocalDateTime value = 1; +} diff --git a/tests/time-validating/src/testFixtures/proto/spine/test/tools/validate/when_repeated.proto b/tests/time-validating/src/testFixtures/proto/spine/test/tools/validate/when_repeated.proto new file mode 100644 index 00000000..a96cd6c6 --- /dev/null +++ b/tests/time-validating/src/testFixtures/proto/spine/test/tools/validate/when_repeated.proto @@ -0,0 +1,69 @@ +/* + * Copyright 2024, 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 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (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.tools.validate; + +import "spine/options.proto"; +import "spine/time_options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.test.tools.validate"; +option java_outer_classname = "WhenRepeatedProto"; +option java_multiple_files = true; + +import "google/protobuf/timestamp.proto"; +import "spine/time/time.proto"; + +// Tests `PAST` restriction with a repeated Protobuf timestamp. +message PastProtoTimestamps { + repeated google.protobuf.Timestamp value = 1 [(when).in = PAST]; +} + +// Tests `PAST` restriction with a repeated Spine temporal. +message PastSpineTemporals { + repeated spine.time.LocalDateTime value = 1 [(when).in = PAST]; +} + +// Tests `FUTURE` restriction with a repeated Protobuf timestamp. +message FutureProtoTimestamps { + repeated google.protobuf.Timestamp value = 1 [(when).in = FUTURE]; +} + +// Tests `FUTURE` restriction with a repeated Spine temporal. +message FutureSpineTemporals { + repeated spine.time.LocalDateTime value = 1 [(when).in = FUTURE]; +} + +// Tests that a repeated Protobuf timestamp is not restricted when there's no option. +message AnyProtoTimestamps { + repeated google.protobuf.Timestamp value = 1; +} + +// Tests that a repeated Spine temporal is not restricted when there's no option. +message AnySpineTemporals { + repeated spine.time.LocalDateTime value = 1; +} From 15ac63c0a9d1d02c22f8142329cf2881db27f48e Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 24 Apr 2026 17:03:31 +0100 Subject: [PATCH 04/40] Introduce `time-consumer` module --- tests/time-consumer/build.gradle.kts | 54 +++++++++++++++++ .../src/main/proto/test/football.proto | 44 ++++++++++++++ .../io/spine/validation/test/Assertions.kt | 58 +++++++++++++++++++ .../io/spine/validation/test/WhenRuleITest.kt | 51 ++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 tests/time-consumer/build.gradle.kts create mode 100644 tests/time-consumer/src/main/proto/test/football.proto create mode 100644 tests/time-consumer/src/test/kotlin/io/spine/validation/test/Assertions.kt create mode 100644 tests/time-consumer/src/test/kotlin/io/spine/validation/test/WhenRuleITest.kt diff --git a/tests/time-consumer/build.gradle.kts b/tests/time-consumer/build.gradle.kts new file mode 100644 index 00000000..28fefdbd --- /dev/null +++ b/tests/time-consumer/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright 2025, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import io.spine.dependency.boms.BomsPlugin +import io.spine.dependency.local.Time +import io.spine.gradle.report.license.LicenseReporter +import io.spine.tools.compiler.gradle.plugin.LaunchSpineCompiler + +plugins { + kotlin("jvm") + id("module-testing") +} + +apply() +LicenseReporter.generateReportIn(project) + +spine { + compiler { + plugins( + "io.spine.tools.compiler.jvm.annotation.SuppressWarningsAnnotation\$Plugin", + "io.spine.validation.java.JavaValidationPlugin", + ) + } +} + +dependencies { + spineCompiler(project(":java")) + implementation(Time.lib) +} + +spineCompilerRemoteDebug(enabled = false) diff --git a/tests/time-consumer/src/main/proto/test/football.proto b/tests/time-consumer/src/main/proto/test/football.proto new file mode 100644 index 00000000..95bd06f3 --- /dev/null +++ b/tests/time-consumer/src/main/proto/test/football.proto @@ -0,0 +1,44 @@ +/* + * Copyright 2025, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (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.validation.test; + +import "spine/options.proto"; +import "spine/time_options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.validation.test"; +option java_outer_classname = "FootballProto"; +option java_multiple_files = true; + +import "google/protobuf/timestamp.proto"; + +message Player { + + google.protobuf.Timestamp started_career_in = 1 [(when).in = PAST]; +} diff --git a/tests/time-consumer/src/test/kotlin/io/spine/validation/test/Assertions.kt b/tests/time-consumer/src/test/kotlin/io/spine/validation/test/Assertions.kt new file mode 100644 index 00000000..6d39a7fd --- /dev/null +++ b/tests/time-consumer/src/test/kotlin/io/spine/validation/test/Assertions.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.validation.test + +import com.google.errorprone.annotations.CanIgnoreReturnValue +import com.google.protobuf.Message +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldNotBe +import io.spine.validation.ConstraintViolation +import io.spine.validation.ValidationException +import org.junit.jupiter.api.Assertions.fail +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows + +@CanIgnoreReturnValue +internal fun assertValidationException(builder: Message.Builder): ConstraintViolation { + val exception = assertThrows { + builder.build() + } + val error = exception.asMessage() + error.constraintViolationList shouldHaveSize 1 + return error.constraintViolationList[0] +} + +internal fun assertNoException(builder: Message.Builder) { + try { + assertDoesNotThrow { + val result = builder.build() + result shouldNotBe null + } + } catch (e: ValidationException) { + fail("Unexpected constraint violation: " + e.constraintViolations, e) + } +} diff --git a/tests/time-consumer/src/test/kotlin/io/spine/validation/test/WhenRuleITest.kt b/tests/time-consumer/src/test/kotlin/io/spine/validation/test/WhenRuleITest.kt new file mode 100644 index 00000000..e1ec9acc --- /dev/null +++ b/tests/time-consumer/src/test/kotlin/io/spine/validation/test/WhenRuleITest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024, 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.validation.test + +import com.google.protobuf.util.Timestamps +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`(when)` rule should") +internal class WhenRuleITest { + + @Test + fun `prohibit invalid timestamp`() { + val startWhen = Timestamps.fromSeconds(4792687200L) // 15 Nov 2121 + val player = Player.newBuilder() + .setStartedCareerIn(startWhen) + assertValidationException(player) + } + + @Test + fun `allow valid timestamp`() { + val timestamp = Timestamps.fromSeconds(59086800L) // 15 Nov 1971 + val player = Player.newBuilder() + .setStartedCareerIn(timestamp) + assertNoException(player) + } +} From c7b95ea93038bddeab78cb610a8b28e878f3c45e Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 24 Apr 2026 18:15:13 +0100 Subject: [PATCH 05/40] Fix Java package for Time Validation tools Also: * Move `WHEN` constant under `WhenOption`. --- .../time/validation/java/WhenGenerator.kt | 9 ++- .../time/validation/{ => java}/WhenOption.kt | 32 ++++++---- .../time/validation/time_field_type.proto | 49 +++++++++++++++ .../spine/tools/time/validation/views.proto | 59 +++++++++++++++++++ .../tools/time/validation/WhenReactionSpec.kt | 4 +- .../time/validation/java/WhenOptionSpec.kt | 41 +++++++++++++ 6 files changed, 174 insertions(+), 20 deletions(-) rename java/src/main/kotlin/io/spine/tools/time/validation/{ => java}/WhenOption.kt (88%) create mode 100644 java/src/main/proto/spine/tools/time/validation/time_field_type.proto create mode 100644 java/src/main/proto/spine/tools/time/validation/views.proto create mode 100644 time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/java/WhenOptionSpec.kt diff --git a/java/src/main/kotlin/io/spine/tools/time/validation/java/WhenGenerator.kt b/java/src/main/kotlin/io/spine/tools/time/validation/java/WhenGenerator.kt index e16a98db..34ef9f9d 100644 --- a/java/src/main/kotlin/io/spine/tools/time/validation/java/WhenGenerator.kt +++ b/java/src/main/kotlin/io/spine/tools/time/validation/java/WhenGenerator.kt @@ -29,9 +29,6 @@ package io.spine.tools.time.validation.java import io.spine.base.FieldPath import io.spine.server.query.select import io.spine.time.validation.Time.FUTURE -import io.spine.time.validation.TimeFieldType.TFT_TEMPORAL -import io.spine.time.validation.TimeFieldType.TFT_TIMESTAMP -import io.spine.time.validation.WhenField import io.spine.tools.compiler.ast.TypeName import io.spine.tools.compiler.ast.isRepeatedMessage import io.spine.tools.compiler.ast.name @@ -42,6 +39,9 @@ import io.spine.tools.compiler.jvm.ReadVar import io.spine.tools.compiler.jvm.StringLiteral import io.spine.tools.compiler.jvm.call import io.spine.tools.compiler.jvm.field +import io.spine.tools.time.validation.TimeFieldType.TFT_TEMPORAL +import io.spine.tools.time.validation.TimeFieldType.TFT_TIMESTAMP +import io.spine.tools.time.validation.WhenField import io.spine.tools.validation.ErrorPlaceholder import io.spine.tools.validation.ErrorPlaceholder.FIELD_PATH import io.spine.tools.validation.ErrorPlaceholder.FIELD_TYPE @@ -64,7 +64,6 @@ import io.spine.tools.validation.java.generate.SingleOptionCode import io.spine.tools.validation.java.generate.ValidateScope.parentName import io.spine.tools.validation.java.generate.ValidateScope.parentPath import io.spine.tools.validation.java.generate.ValidateScope.violations -import io.spine.tools.validation.option.WHEN import io.spine.validation.ConstraintViolation /** @@ -161,7 +160,7 @@ private class GenerateWhen( ): Expression { val typeNameStr = typeName.stringify() val placeholders = supportedPlaceholders(fieldPath, typeNameStr, fieldValue) - val errorMessage = templateString(view.errorMessage, placeholders, WHEN) + val errorMessage = templateString(view.errorMessage, placeholders, WhenOption.NAME) return constraintViolation(errorMessage, typeNameStr, fieldPath, fieldValue) } diff --git a/java/src/main/kotlin/io/spine/tools/time/validation/WhenOption.kt b/java/src/main/kotlin/io/spine/tools/time/validation/java/WhenOption.kt similarity index 88% rename from java/src/main/kotlin/io/spine/tools/time/validation/WhenOption.kt rename to java/src/main/kotlin/io/spine/tools/time/validation/java/WhenOption.kt index 99230a9d..4b267736 100644 --- a/java/src/main/kotlin/io/spine/tools/time/validation/WhenOption.kt +++ b/java/src/main/kotlin/io/spine/tools/time/validation/java/WhenOption.kt @@ -24,7 +24,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package io.spine.tools.time.validation +package io.spine.tools.time.validation.java import com.google.auto.service.AutoService import com.google.protobuf.Timestamp @@ -40,13 +40,6 @@ import io.spine.server.tuple.EitherOf2 import io.spine.time.Temporal import io.spine.time.validation.Time import io.spine.time.validation.TimeOption -import io.spine.time.validation.TimeFieldType -import io.spine.time.validation.TimeFieldType.TFT_TEMPORAL -import io.spine.time.validation.TimeFieldType.TFT_TIMESTAMP -import io.spine.time.validation.TimeFieldType.TFT_UNKNOWN -import io.spine.time.validation.WhenField -import io.spine.time.validation.event.WhenFieldDiscovered -import io.spine.time.validation.event.whenFieldDiscovered import io.spine.tools.compiler.Compilation import io.spine.tools.compiler.ast.Field import io.spine.tools.compiler.ast.FieldRef @@ -64,6 +57,13 @@ import io.spine.tools.compiler.jvm.javaClass import io.spine.tools.compiler.plugin.Reaction import io.spine.tools.compiler.plugin.View import io.spine.tools.compiler.type.TypeSystem +import io.spine.tools.time.validation.TimeFieldType +import io.spine.tools.time.validation.TimeFieldType.TFT_TEMPORAL +import io.spine.tools.time.validation.TimeFieldType.TFT_TIMESTAMP +import io.spine.tools.time.validation.TimeFieldType.TFT_UNKNOWN +import io.spine.tools.time.validation.WhenField +import io.spine.tools.time.validation.event.WhenFieldDiscovered +import io.spine.tools.time.validation.event.whenFieldDiscovered import io.spine.tools.validation.ErrorPlaceholder.FIELD_PATH import io.spine.tools.validation.ErrorPlaceholder.FIELD_TYPE import io.spine.tools.validation.ErrorPlaceholder.FIELD_VALUE @@ -74,8 +74,6 @@ import io.spine.tools.validation.checkPlaceholders import io.spine.tools.validation.defaultMessage import io.spine.tools.validation.java.ValidationOption import io.spine.tools.validation.java.generate.OptionGenerator -import io.spine.tools.validation.option.WHEN -import io.spine.tools.time.validation.java.WhenGenerator /** * Extends the Java validation with code generation for the `(when)` option. @@ -83,6 +81,14 @@ import io.spine.tools.time.validation.java.WhenGenerator @AutoService(ValidationOption::class) public class WhenOption : ValidationOption { + public companion object { + + /** + * The name of the option as it appears in the Protobuf definition. + */ + public const val NAME: String = "when" + } + override val reactions: Set> = setOf(WhenReaction()) override val view: Set>> = setOf(WhenFieldView::class.java) @@ -111,7 +117,7 @@ internal class WhenReaction : Reaction() { @React override fun whenever( - @External @Where(field = OPTION_NAME, equals = WHEN) + @External @Where(field = OPTION_NAME, equals = WhenOption.NAME) event: FieldOptionDiscovered ): EitherOf2 { val field = event.subject @@ -125,7 +131,7 @@ internal class WhenReaction : Reaction() { } val message = option.errorMsg.ifEmpty { option.descriptorForType.defaultMessage } - message.checkPlaceholders(SUPPORTED_PLACEHOLDERS, field, file, WHEN) + message.checkPlaceholders(SUPPORTED_PLACEHOLDERS, field, file, WhenOption.NAME) return whenFieldDiscovered { id = field.ref @@ -141,7 +147,7 @@ private fun checkFieldType(field: Field, typeSystem: TypeSystem, file: File): Ti val timeType = typeSystem.determineTimeType(field.type) Compilation.check(timeType != TFT_UNKNOWN, file, field.span) { "The field type `${field.type.name}` of the `${field.qualifiedName}` field" + - " is not supported by the `(${WHEN})` option. Supported field types:" + + " is not supported by the `(${WhenOption.NAME})` option. Supported field types:" + " `google.protobuf.Timestamp` and types introduced in the `spine.time` package" + " that describe time-related concepts." } diff --git a/java/src/main/proto/spine/tools/time/validation/time_field_type.proto b/java/src/main/proto/spine/tools/time/validation/time_field_type.proto new file mode 100644 index 00000000..be8f9211 --- /dev/null +++ b/java/src/main/proto/spine/tools/time/validation/time_field_type.proto @@ -0,0 +1,49 @@ +/* + * Copyright 2025, 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 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (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.tools.time.validation; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.tools.time.validation"; +option java_outer_classname = "TimeFieldTypeProto"; +option java_multiple_files = true; + +// Time-related field type supported by the `(when)` option. +enum TimeFieldType { + + TFT_UNKNOWN = 0; + + // Denotes `com.google.protobuf.Timestamp`. + TFT_TIMESTAMP = 1; + + // Denotes an interface `io.spine.time.Temporal`, which the field type should + // implement to be handled by the option. + TFT_TEMPORAL = 2; +} diff --git a/java/src/main/proto/spine/tools/time/validation/views.proto b/java/src/main/proto/spine/tools/time/validation/views.proto new file mode 100644 index 00000000..e18d6e17 --- /dev/null +++ b/java/src/main/proto/spine/tools/time/validation/views.proto @@ -0,0 +1,59 @@ +/* + * 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (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.tools.time.validation; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.tools.time.validation"; +option java_outer_classname = "ViewsProto"; +option java_multiple_files = true; + +import "spine/compiler/ast.proto"; +import "spine/time_options.proto"; +import "spine/tools/time/validation/time_field_type.proto"; + +// A view of a field that is marked with `(when)` option. +message WhenField { + option (entity).kind = PROJECTION; + + compiler.FieldRef id = 1; + + // The field in which the option was discovered. + compiler.Field subject = 2; + + // The error message template. + string error_message = 3; + + // Defines a restriction for the timestamp. + Time bound = 4; + + // The type of the field. + spine.tools.time.validation.TimeFieldType type = 5; +} diff --git a/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/WhenReactionSpec.kt b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/WhenReactionSpec.kt index b5d6fec4..d42d32b1 100644 --- a/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/WhenReactionSpec.kt +++ b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/WhenReactionSpec.kt @@ -31,11 +31,11 @@ import io.kotest.matchers.string.shouldInclude import io.spine.tools.compiler.ast.name import io.spine.tools.compiler.ast.qualifiedName import io.spine.tools.compiler.protobuf.field +import io.spine.tools.time.validation.java.WhenOption import io.spine.tools.validation.given.WhenBoolField import io.spine.tools.validation.given.WhenInt32Field import io.spine.tools.validation.given.WhenStringField import io.spine.tools.validation.given.WhenWithInvalidPlaceholders -import io.spine.tools.validation.option.WHEN import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -85,7 +85,7 @@ internal class WhenReactionSpec : CompilationErrorTest() { val field = message.field("value") error.message.run { shouldContain(field.qualifiedName) - shouldContain(WHEN) + shouldContain(WhenOption.NAME) shouldContain("unsupported placeholders") shouldInclude("[when]") } diff --git a/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/java/WhenOptionSpec.kt b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/java/WhenOptionSpec.kt new file mode 100644 index 00000000..694d4e11 --- /dev/null +++ b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/java/WhenOptionSpec.kt @@ -0,0 +1,41 @@ +/* + * 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.tools.time.validation.java + +import io.kotest.matchers.shouldBe +import io.spine.time.validation.TimeOptionsProto +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`WhenOption` should") +internal class WhenOptionSpec { + + @Test + fun `have the name matching the descriptor name`() { + WhenOption.NAME shouldBe TimeOptionsProto.`when`.descriptor.name + } +} From 62a14f6ec7e6d09b451352370dc7f7c5b52831f1 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 24 Apr 2026 18:29:58 +0100 Subject: [PATCH 06/40] Fix the proto package for time validation events --- .../spine/tools/time/validation/events.proto | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 java/src/main/proto/spine/tools/time/validation/events.proto diff --git a/java/src/main/proto/spine/tools/time/validation/events.proto b/java/src/main/proto/spine/tools/time/validation/events.proto new file mode 100644 index 00000000..fda7ff16 --- /dev/null +++ b/java/src/main/proto/spine/tools/time/validation/events.proto @@ -0,0 +1,59 @@ +/* + * 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 + * + * 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 + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (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.tools.time.validation; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.tools.time.validation.event"; +option java_outer_classname = "EventsProto"; +option java_multiple_files = true; + +import "spine/compiler/ast.proto"; +import "spine/time_options.proto"; +import "spine/tools/time/validation/time_field_type.proto"; + +// The event emitted whenever a field with `(when)` option is discovered +// and has passed the necessary checks to confirm the option is applied correctly. +message WhenFieldDiscovered { + + compiler.FieldRef id = 1; + + // The field in which the option was discovered. + compiler.Field subject = 2; + + // The error message template. + string error_message = 3; + + // Defines a restriction for the timestamp. + Time bound = 4; + + // The type of the field. + spine.tools.time.validation.TimeFieldType type = 5; +} From b5023d46223a99a88319305c3653bb5fe9c2f68c Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 24 Apr 2026 18:39:24 +0100 Subject: [PATCH 07/40] Remove unused code --- tests/time-validating/build.gradle.kts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/time-validating/build.gradle.kts b/tests/time-validating/build.gradle.kts index 99b96720..1ccc0838 100644 --- a/tests/time-validating/build.gradle.kts +++ b/tests/time-validating/build.gradle.kts @@ -52,7 +52,3 @@ afterEvaluate { mustRunAfter("launchTestFixturesSpineCompiler") } } - -val testFixturesJar by tasks.getting(Jar::class) { - duplicatesStrategy = DuplicatesStrategy.INCLUDE -} From a5d057de62ad9a4bf96d122c6fa34ae041b77684 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 24 Apr 2026 18:53:35 +0100 Subject: [PATCH 08/40] Update (c) year --- tests/time-consumer/build.gradle.kts | 2 +- .../src/test/kotlin/io/spine/validation/test/Assertions.kt | 2 +- .../src/test/kotlin/io/spine/validation/test/WhenRuleITest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/time-consumer/build.gradle.kts b/tests/time-consumer/build.gradle.kts index 28fefdbd..7f572b41 100644 --- a/tests/time-consumer/build.gradle.kts +++ b/tests/time-consumer/build.gradle.kts @@ -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. diff --git a/tests/time-consumer/src/test/kotlin/io/spine/validation/test/Assertions.kt b/tests/time-consumer/src/test/kotlin/io/spine/validation/test/Assertions.kt index 6d39a7fd..01c65edc 100644 --- a/tests/time-consumer/src/test/kotlin/io/spine/validation/test/Assertions.kt +++ b/tests/time-consumer/src/test/kotlin/io/spine/validation/test/Assertions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, 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. diff --git a/tests/time-consumer/src/test/kotlin/io/spine/validation/test/WhenRuleITest.kt b/tests/time-consumer/src/test/kotlin/io/spine/validation/test/WhenRuleITest.kt index e1ec9acc..3f870a3b 100644 --- a/tests/time-consumer/src/test/kotlin/io/spine/validation/test/WhenRuleITest.kt +++ b/tests/time-consumer/src/test/kotlin/io/spine/validation/test/WhenRuleITest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, 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. From 024c04e31b4b1fb3a149f52d42f9ac94534817e6 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 24 Apr 2026 18:54:23 +0100 Subject: [PATCH 09/40] Optimise imports --- tests/time-consumer/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/time-consumer/build.gradle.kts b/tests/time-consumer/build.gradle.kts index 7f572b41..67163e14 100644 --- a/tests/time-consumer/build.gradle.kts +++ b/tests/time-consumer/build.gradle.kts @@ -27,7 +27,6 @@ import io.spine.dependency.boms.BomsPlugin import io.spine.dependency.local.Time import io.spine.gradle.report.license.LicenseReporter -import io.spine.tools.compiler.gradle.plugin.LaunchSpineCompiler plugins { kotlin("jvm") From 5b881e2415c253a2c208fd62899adb4665b5c8ac Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 24 Apr 2026 18:54:43 +0100 Subject: [PATCH 10/40] Update (c) year --- .../io/spine/tools/time/validation/CompilationErrorTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/CompilationErrorTest.kt b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/CompilationErrorTest.kt index 715368cb..12cf445f 100644 --- a/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/CompilationErrorTest.kt +++ b/time-validation-tests/src/test/kotlin/io/spine/tools/time/validation/CompilationErrorTest.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. From af9d644f33c87cd94a4da4427407dab97a7de9c8 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 24 Apr 2026 18:55:01 +0100 Subject: [PATCH 11/40] Update (c) year --- time-validation-tests/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time-validation-tests/build.gradle.kts b/time-validation-tests/build.gradle.kts index d5dfc75b..a994a62e 100644 --- a/time-validation-tests/build.gradle.kts +++ b/time-validation-tests/build.gradle.kts @@ -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. From f956aa01c668fbad3c27ededee6787c9f7384bbd Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Mon, 27 Apr 2026 15:26:06 +0100 Subject: [PATCH 12/40] Bump version -> `2.0.0-SNAPSHOT.236` --- version.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.gradle.kts b/version.gradle.kts index 984db38e..1df9dd0b 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of this library for publishing. */ -val versionToPublish by extra("2.0.0-SNAPSHOT.235") +val versionToPublish by extra("2.0.0-SNAPSHOT.236") From 22b322fe2bc9eaef62746c846e0cd653d5b21f82 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Mon, 27 Apr 2026 15:32:22 +0100 Subject: [PATCH 13/40] Update `config` --- .agents/skills/move-files/SKILL.md | 49 +++ .agents/skills/move-files/agents/openai.yaml | 4 + .agents/skills/update-copyright/SKILL.md | 16 + .../update-copyright/agents/openai.yaml | 4 + .../scripts/update_copyright.py | 389 ++++++++++++++++++ .../tests/test_update_copyright.py | 130 ++++++ .claude/skills | 1 + .gitignore | 4 + .idea/inspectionProfiles/Project_Default.xml | 12 + buildSrc/build.gradle.kts | 14 +- .../kotlin/io/spine/dependency/build/Dokka.kt | 7 +- .../JetBrainsAnnotations.kt} | 28 +- .../kotlin/io/spine/dependency/local/Base.kt | 4 +- .../io/spine/dependency/local/Compiler.kt | 6 +- .../io/spine/dependency/local/CoreJvm.kt | 12 +- .../spine/dependency/local/CoreJvmCompiler.kt | 29 +- .../io/spine/dependency/local/Logging.kt | 6 +- .../io/spine/dependency/local/ProtoTap.kt | 2 +- .../io/spine/dependency/local/TestLib.kt | 4 +- .../kotlin/io/spine/dependency/local/Time.kt | 3 +- .../io/spine/dependency/local/ToolBase.kt | 4 +- .../io/spine/dependency/local/Validation.kt | 4 +- .../kotlin/io/spine/dependency/test/Kotest.kt | 30 +- .../io/spine/gradle/ProjectExtensions.kt | 11 +- .../spine/gradle/docs/UpdatePluginVersion.kt | 5 +- .../gradle/github/pages/UpdateGitHubPages.kt | 8 - .../gradle/publish/PublicationHandler.kt | 50 ++- .../io/spine/gradle/publish/ShadowJarExts.kt | 87 ++-- .../spine/gradle/publish/SpinePublishing.kt | 31 +- .../gradle/report/license/LicenseReporter.kt | 5 +- .../src/main/kotlin/jvm-module.gradle.kts | 8 + .../src/main/kotlin/kmp-module.gradle.kts | 8 +- .../gradle/publish/SpinePublishingTest.kt | 7 +- config | 2 +- 34 files changed, 815 insertions(+), 169 deletions(-) create mode 100644 .agents/skills/move-files/SKILL.md create mode 100644 .agents/skills/move-files/agents/openai.yaml create mode 100644 .agents/skills/update-copyright/SKILL.md create mode 100644 .agents/skills/update-copyright/agents/openai.yaml create mode 100755 .agents/skills/update-copyright/scripts/update_copyright.py create mode 100644 .agents/skills/update-copyright/tests/test_update_copyright.py create mode 120000 .claude/skills rename buildSrc/src/main/kotlin/io/spine/dependency/{build/LicenseReport.kt => lib/JetBrainsAnnotations.kt} (71%) diff --git a/.agents/skills/move-files/SKILL.md b/.agents/skills/move-files/SKILL.md new file mode 100644 index 00000000..2885f482 --- /dev/null +++ b/.agents/skills/move-files/SKILL.md @@ -0,0 +1,49 @@ +--- +name: move-files +description: > + Move or rename any files/directories in a repo: preserve history, update all + references and build metadata, verify no stale paths remain. +--- + +# Move Files + +## Workflow + +1. Preflight. + - Run `git status --short`. + - Map each `source -> destination`. + - Classify scope: simple same-module moves stay targeted; package, module, or + cross-module moves need broader inspection. + - Ask before ambiguous mappings, destination conflicts, or unclear semantic + package/module changes. + +2. Search before moving. + - Search all old identifiers: paths, names, resource refs, doc links. + - For Gradle/module/source-set moves, check `settings.gradle.kts`, + `build.gradle.kts`, and `buildSrc`. + - For Kotlin/Java, update package declarations only when package intent + changes. + +3. Move safely. + - Prefer `git mv` for tracked files in the repo. + - Use filesystem moves only for untracked/generated/out-of-git files. + - Create parent directories first. + - For case-only renames, move through a temporary name. + +4. Repair references. + - Update all references: imports, build metadata, docs, resources, and scripts. + - Start search scope narrow: affected directory, then module, then repo-wide. + - Prefer precise edits; avoid broad replacements on generic names. + +5. Verify. + - Re-run targeted searches for old tokens. + - Run `git status --short` and confirm the delta matches the move. + - Run focused validation for moved files, or state what could not run. + +## Repo Notes + +Follow `.agents/project-structure-expectations.md` for module/source-set/test moves. + +## Report + +Return: `Moved[]`, `UpdatedRefs[]`, `Verification[]`, `Risks[]`. diff --git a/.agents/skills/move-files/agents/openai.yaml b/.agents/skills/move-files/agents/openai.yaml new file mode 100644 index 00000000..ba90a9f8 --- /dev/null +++ b/.agents/skills/move-files/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Move Files" + short_description: "Move files safely across a repo" + default_prompt: "Use $move-files to relocate files or directories in this repository while preserving history, updating references, and verifying the result." diff --git a/.agents/skills/update-copyright/SKILL.md b/.agents/skills/update-copyright/SKILL.md new file mode 100644 index 00000000..6afc4c7c --- /dev/null +++ b/.agents/skills/update-copyright/SKILL.md @@ -0,0 +1,16 @@ +--- +name: update-copyright +description: > + Update source file copyright headers from the IntelliJ IDEA copyright profile, + replacing `today.year` with the current year. + Automatically apply when source files are modified in a change set. +--- + +# Copyright Update + +**Command:** `python3 .agents/skills/update-copyright/scripts/update_copyright.py` + +1. Scope: explicit files/dirs from the user, or all tracked source files if none given. +2. No explicit paths → run with `--dry-run` first, then without. +3. Relay stdout (notice source, file count, changed paths) to the user. +4. Never add a copyright header to a file that does not already have one. diff --git a/.agents/skills/update-copyright/agents/openai.yaml b/.agents/skills/update-copyright/agents/openai.yaml new file mode 100644 index 00000000..246dd647 --- /dev/null +++ b/.agents/skills/update-copyright/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Copyright Update" + short_description: "Refresh source copyright headers" + default_prompt: "Use $update-copyright to refresh source file copyright headers from the IntelliJ IDEA copyright profile in this repository." diff --git a/.agents/skills/update-copyright/scripts/update_copyright.py b/.agents/skills/update-copyright/scripts/update_copyright.py new file mode 100755 index 00000000..2dbf8bbc --- /dev/null +++ b/.agents/skills/update-copyright/scripts/update_copyright.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Update source copyright headers from IntelliJ IDEA copyright profiles.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import html +import re +import subprocess +import sys +from pathlib import Path +from xml.etree import ElementTree as ET + + +BLOCK_EXTENSIONS = { + ".c", + ".cc", + ".cpp", + ".cs", + ".css", + ".cxx", + ".dart", + ".go", + ".gradle", + ".groovy", + ".h", + ".hh", + ".hpp", + ".java", + ".js", + ".jsx", + ".kt", + ".kts", + ".less", + ".m", + ".mm", + ".proto", + ".rs", + ".scala", + ".scss", + ".swift", + ".ts", + ".tsx", +} +HASH_EXTENSIONS = { + ".bash", + ".bzl", + ".properties", + ".pl", + ".py", + ".rb", + ".sh", + ".toml", + ".yaml", + ".yml", + ".zsh", +} +XML_EXTENSIONS = { + ".fxml", + ".pom", + ".wsdl", + ".xml", + ".xsd", + ".xsl", + ".xslt", +} +EXCLUDED_DIRS = { + ".agents", + ".git", + ".gradle", + ".idea", + ".kotlin", + "build", + "generated", + "out", + "tmp", +} +EXCLUDED_FILES = { + "gradlew", + "gradlew.bat", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Update source copyright headers from " + ".idea/copyright/profiles_settings.xml." + ) + ) + parser.add_argument( + "paths", + nargs="*", + help="Files or directories to update. Defaults to tracked source files.", + ) + parser.add_argument( + "--root", + type=Path, + default=Path.cwd(), + help="Repository root. Defaults to the current working directory.", + ) + parser.add_argument( + "--year", + default=str(dt.date.today().year), + help="Year to substitute for today.year. Defaults to the current year.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Report files that would change without writing them.", + ) + parser.add_argument( + "--check", + action="store_true", + help="Exit with status 1 if any file would change; do not write files.", + ) + return parser.parse_args() + + +def profile_filename(profile_name: str) -> str: + stem = re.sub(r"[^A-Za-z0-9]+", "_", profile_name).strip("_") + if not stem: + raise ValueError("The default copyright profile name is empty.") + return f"{stem}.xml" + + +def load_notice(root: Path, year: str) -> tuple[str, Path]: + settings_path = root / ".idea" / "copyright" / "profiles_settings.xml" + if not settings_path.is_file(): + raise FileNotFoundError(f"Missing {settings_path}") + + settings_root = ET.parse(settings_path).getroot() + settings = settings_root.find(".//settings") + if settings is None: + raise ValueError(f"{settings_path} does not contain a settings tag.") + + default_profile = settings.get("default") + if not default_profile: + raise ValueError(f"{settings_path} settings tag has no default attribute.") + + profile_path = settings_path.parent / profile_filename(default_profile) + if not profile_path.is_file(): + raise FileNotFoundError( + f"Default profile {default_profile!r} resolves to missing {profile_path}" + ) + + profile_root = ET.parse(profile_path).getroot() + notice = None + for option in profile_root.findall(".//option"): + if option.get("name") == "notice": + notice = option.get("value") + break + if notice is None: + raise ValueError(f"{profile_path} has no option named 'notice'.") + + decoded = html.unescape(notice) + decoded = decoded.replace("${today.year}", year) + decoded = decoded.replace("$today.year", year) + decoded = decoded.replace("today.year", year) + return decoded.rstrip(), profile_path + + +def style_for(path: Path) -> str | None: + name = path.name + suffix = path.suffix.lower() + if name.endswith((".sh.template", ".bash.template", ".zsh.template")): + return "hash" + if suffix in BLOCK_EXTENSIONS: + return "block" + if suffix in HASH_EXTENSIONS: + return "hash" + if suffix in XML_EXTENSIONS: + return "xml" + return None + + +def is_excluded(path: Path) -> bool: + if path.name in EXCLUDED_FILES: + return True + parts = path.parts + if len(parts) >= 2 and parts[0] == "gradle" and parts[1] == "wrapper": + return True + return any(part in EXCLUDED_DIRS for part in parts) + + +def tracked_files(root: Path) -> list[Path]: + try: + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return [ + path.relative_to(root) + for path in root.rglob("*") + if path.is_file() and not is_excluded(path.relative_to(root)) + ] + + paths = [] + for item in result.stdout.decode("utf-8").split("\0"): + if not item: + continue + path = Path(item) + if (root / path).is_file(): + paths.append(path) + return paths + + +def expand_requested_paths(root: Path, requested: list[str]) -> list[Path]: + if not requested: + paths = tracked_files(root) + else: + paths = [] + for item in requested: + path = (root / item).resolve() + if not path.exists(): + raise FileNotFoundError(f"Path does not exist: {item}") + if not path.is_relative_to(root): + raise ValueError( + f"Path is outside the repository root: {item!r} " + f"(resolved to {path}, root is {root})" + ) + if path.is_dir(): + for child in path.rglob("*"): + if child.is_file(): + paths.append(child.relative_to(root)) + else: + paths.append(path.relative_to(root)) + + unique = sorted(set(paths), key=lambda p: p.as_posix()) + return [ + path + for path in unique + if style_for(path) is not None and not is_excluded(path) + ] + + +def newline_for(text: str) -> str: + return "\r\n" if "\r\n" in text else "\n" + + +def build_header(notice: str, style: str, newline: str) -> str: + lines = notice.splitlines() + if style == "block": + body = newline.join(f" * {line}" if line else " *" for line in lines) + return f"/*{newline}{body}{newline} */{newline}{newline}" + if style == "hash": + body = newline.join(f"# {line}" if line else "#" for line in lines) + return f"{body}{newline}{newline}" + if style == "xml": + body = newline.join(f" ~ {line}" if line else " ~" for line in lines) + return f"{newline}{newline}" + raise ValueError(f"Unsupported comment style: {style}") + + +def split_leading_directive(text: str, style: str, newline: str) -> tuple[str, str]: + if style == "hash" and text.startswith("#!"): + line_end = text.find("\n") + if line_end == -1: + return text + newline + newline, "" + prefix = text[: line_end + 1] + newline + return prefix, strip_leading_blank_lines(text[line_end + 1 :]) + + if style == "xml" and text.startswith("") + if close != -1: + line_end = text.find("\n", close) + if line_end == -1: + return text + newline + newline, "" + prefix = text[: line_end + 1] + newline + return prefix, strip_leading_blank_lines(text[line_end + 1 :]) + + return "", strip_leading_blank_lines(text) + + +def strip_leading_blank_lines(text: str) -> str: + return re.sub(r"^(?:[ \t]*\r?\n)+", "", text) + + +def strip_existing_header(text: str, style: str) -> tuple[str, bool]: + if style == "block" and text.startswith("/*"): + close = text.find("*/") + if close != -1: + candidate = text[: close + 2] + if is_copyright_header(candidate): + return strip_leading_blank_lines(text[close + 2 :]), True + + if style == "xml" and text.startswith("") + if close != -1: + candidate = text[: close + 3] + if is_copyright_header(candidate): + return strip_leading_blank_lines(text[close + 3 :]), True + + if style == "hash": + lines = text.splitlines(keepends=True) + end = 0 + for line in lines: + stripped = line.strip() + if stripped == "" or stripped.startswith("#"): + end += len(line) + continue + break + candidate = text[:end] + if candidate and is_copyright_header(candidate): + return strip_leading_blank_lines(text[end:]), True + + return text, False + + +def is_copyright_header(text: str) -> bool: + limited = text[:5000] + return "Copyright" in limited and ( + "Licensed under" in limited or "All rights reserved" in limited + ) + + +def updated_text(text: str, notice: str, style: str) -> str: + original = text + bom = "\ufeff" if text.startswith("\ufeff") else "" + if bom: + text = text[1:] + newline = newline_for(text) + prefix, body = split_leading_directive(text, style, newline) + body, had_header = strip_existing_header(body, style) + if not had_header: + return original + return bom + prefix + build_header(notice, style, newline) + body + + +def update_file(root: Path, path: Path, notice: str, dry_run: bool) -> bool: + absolute = root / path + style = style_for(path) + if style is None: + return False + + try: + text = absolute.read_text(encoding="utf-8") + except FileNotFoundError: + print(f"Skipping missing file: {path}", file=sys.stderr) + return False + except UnicodeDecodeError: + print(f"Skipping non-UTF-8 file: {path}", file=sys.stderr) + return False + + next_text = updated_text(text, notice, style) + if next_text == text: + return False + + if not dry_run: + with absolute.open("w", encoding="utf-8", newline="") as file: + file.write(next_text) + return True + + +def main() -> int: + args = parse_args() + root = args.root.resolve() + notice, profile_path = load_notice(root, args.year) + try: + paths = expand_requested_paths(root, args.paths) + except (FileNotFoundError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + dry_run = args.dry_run or args.check + + changed = [ + path + for path in paths + if update_file(root, path, notice, dry_run=dry_run) + ] + + rel_profile = profile_path.relative_to(root) + action = "Would update" if dry_run else "Updated" + print(f"Notice source: {rel_profile}") + print(f"{action} {len(changed)} file(s).") + for path in changed: + print(path.as_posix()) + + if args.check and changed: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/update-copyright/tests/test_update_copyright.py b/.agents/skills/update-copyright/tests/test_update_copyright.py new file mode 100644 index 00000000..8770b327 --- /dev/null +++ b/.agents/skills/update-copyright/tests/test_update_copyright.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "update_copyright.py" + + +class UpdateCopyrightTest(unittest.TestCase): + def test_default_run_leaves_plain_source_without_header_unchanged(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_profile(root) + source = root / "Foo.java" + original = "class Foo {}\n" + source.write_text(original, encoding="utf-8") + + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "Foo.java"], cwd=root, check=True) + + result = self.run_script(root) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Updated 0 file(s).", result.stdout) + self.assertEqual(result.stderr, "") + self.assertEqual(source.read_text(encoding="utf-8"), original) + + def test_existing_header_is_updated(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_profile(root) + source = root / "Foo.java" + source.write_text( + "/*\n" + " * Copyright 2024 ACME\n" + " * All rights reserved\n" + " */\n" + "\n" + "class Foo {}\n", + encoding="utf-8", + ) + + result = self.run_script(root, "--year", "2026", "Foo.java") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Updated 1 file(s).", result.stdout) + self.assertIn("Foo.java", result.stdout) + self.assertEqual(result.stderr, "") + self.assertEqual( + source.read_text(encoding="utf-8"), + "/*\n" + " * Copyright 2026 ACME\n" + " * All rights reserved\n" + " */\n" + "\n" + "class Foo {}\n", + ) + + def test_default_run_skips_tracked_files_deleted_from_working_tree(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_profile(root) + source = root / "Foo.java" + source.write_text("class Foo {}\n", encoding="utf-8") + + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "Foo.java"], cwd=root, check=True) + source.unlink() + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--root", + str(root), + "--dry-run", + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Would update 0 file(s).", result.stdout) + self.assertEqual(result.stderr, "") + + @staticmethod + def run_script(root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--root", + str(root), + *args, + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + @staticmethod + def write_profile(root: Path) -> None: + copyright_dir = root / ".idea" / "copyright" + copyright_dir.mkdir(parents=True) + (copyright_dir / "profiles_settings.xml").write_text( + '' + '' + "\n", + encoding="utf-8", + ) + (copyright_dir / "Default.xml").write_text( + '' + "" + '" + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000..2b7a412b --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.gitignore b/.gitignore index 48de9f27..a3e0ec13 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,7 @@ pubspec.lock /tmp .gradle-test-kit/ + +# Python cache +__pycache__/ +*.pyc diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 0bd1d9dc..7be402da 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -255,6 +255,18 @@