diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/JsonSchema.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/JsonSchema.kt index e55bbb58..f6e877bb 100644 --- a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/JsonSchema.kt +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/JsonSchema.kt @@ -15,4 +15,10 @@ data class JsonSchemaProperty( val type: String, val description: String = "", val default: String? = null, + // For `type == "array"`: minimum element count (0 = unconstrained) and the element + // schema. Lets a schema declare a required-non-empty list — the deterministic guard + // behind the role pipeline's "define done" contract (an empty `requirements`/`steps` + // array is a misread brief, not a valid artifact). + val minItems: Int = 0, + val items: JsonSchemaProperty? = null, ) diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt index 8f52c9ca..65cc0643 100644 --- a/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt +++ b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt @@ -1,6 +1,7 @@ package com.correx.core.validation.artifact import com.correx.core.artifacts.kind.JsonSchema +import com.correx.core.artifacts.kind.JsonSchemaProperty import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -12,7 +13,8 @@ import kotlinx.serialization.json.longOrNull /** * Minimal, deterministic validator of a JSON value against a [JsonSchema] * (the subset correx models: object type, typed properties, required keys, - * additionalProperties). Returns human-readable error strings; empty = valid. + * additionalProperties, and array minItems/items). Returns human-readable error + * strings; empty = valid. * * Used to validate config-declared artifact kinds against their declared schema, * so an LLM-emitted custom kind is checked against its shape rather than trusted. @@ -35,12 +37,31 @@ object JsonSchemaValidator { if (property == null) { if (!schema.additionalProperties) errors += "unknown property '$key'" } else { - checkType(property.type, element)?.let { errors += "property '$key': $it" } + errors += checkProperty(key, property, element) } } return errors } + private fun checkProperty(key: String, property: JsonSchemaProperty, element: JsonElement): List { + val typeError = checkType(property.type, element) + val array = element as? JsonArray + return when { + typeError != null -> listOf("property '$key': $typeError") + property.type != "array" || array == null -> emptyList() + else -> buildList { + if (array.size < property.minItems) { + add("property '$key': expected at least ${property.minItems} item(s), got ${array.size}") + } + property.items?.let { itemSchema -> + array.forEachIndexed { i, el -> + checkType(itemSchema.type, el)?.let { add("property '$key'[$i]: $it") } + } + } + } + } + } + private fun checkType(type: String, value: JsonElement): String? { val ok = when (type) { "string" -> value is JsonPrimitive && value.isString diff --git a/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt b/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt index 56cddbe2..1b8d490c 100644 --- a/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt +++ b/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt @@ -53,4 +53,36 @@ class JsonSchemaValidatorTest { fun `non-object payload against object schema is flagged`() { assertEquals(1, errors("""["a","b"]""").size) } + + private val arraySchema = JsonSchema( + type = "object", + properties = mapOf( + "tags" to JsonSchemaProperty(type = "array", minItems = 1, items = JsonSchemaProperty(type = "string")), + ), + required = listOf("tags"), + additionalProperties = true, + ) + + private fun arrayErrors(json: String) = + JsonSchemaValidator.validate(arraySchema, Json.parseToJsonElement(json)) + + @Test + fun `non-empty typed array passes`() { + assertTrue(arrayErrors("""{"tags":["a","b"]}""").isEmpty()) + } + + @Test + fun `empty array violates minItems`() { + assertTrue(arrayErrors("""{"tags":[]}""").any { it.contains("tags") && it.contains("at least 1") }) + } + + @Test + fun `wrong array element type is flagged with index`() { + assertTrue(arrayErrors("""{"tags":["ok",3]}""").any { it.contains("tags") && it.contains("[1]") }) + } + + @Test + fun `non-array value for array property is flagged`() { + assertTrue(arrayErrors("""{"tags":"nope"}""").any { it.contains("tags") && it.contains("array") }) + } } diff --git a/docs/schemas/analysis.json b/docs/schemas/analysis.json index 4c8e5fe9..9028ea2b 100644 --- a/docs/schemas/analysis.json +++ b/docs/schemas/analysis.json @@ -7,7 +7,9 @@ }, "requirements": { "type": "array", - "description": "concrete requirements / acceptance criteria, one per item" + "description": "concrete requirements / acceptance criteria, one per item", + "minItems": 1, + "items": { "type": "string" } }, "affected_areas": { "type": "array", diff --git a/docs/schemas/design.json b/docs/schemas/design.json index b6184532..13b92b5f 100644 --- a/docs/schemas/design.json +++ b/docs/schemas/design.json @@ -7,7 +7,9 @@ }, "components": { "type": "array", - "description": "components/files to add or change, one per item" + "description": "components/files to add or change, one per item", + "minItems": 1, + "items": { "type": "string" } }, "risks": { "type": "array", diff --git a/docs/schemas/impl_plan.json b/docs/schemas/impl_plan.json index d806bbe1..9db89377 100644 --- a/docs/schemas/impl_plan.json +++ b/docs/schemas/impl_plan.json @@ -3,7 +3,9 @@ "properties": { "steps": { "type": "array", - "description": "ordered, individually verifiable implementation steps, one per item" + "description": "ordered, individually verifiable implementation steps, one per item", + "minItems": 1, + "items": { "type": "string" } }, "verification": { "type": "array",