feat(validation): enforce array minItems/items in artifact schemas (role-reliability §3)

The JSON-schema artifact validator enforced `required` keys but not array
contents, so a model could emit `"requirements": []` (or `"steps": []`,
`"components": []`) and pass — a misread brief sailing through as a valid
artifact. That empty-list gap is the hole under role-reliability §3's
keystone ("force the analyst to define done; verifiability propagates
downstream").

- JsonSchemaProperty gains `minItems` and `items` (recursive), letting a
  schema declare a required-non-empty / element-typed array.
- JsonSchemaValidator checks array length >= minItems and each element's
  declared item type (with index in the message). Pure + deterministic;
  single-return to stay within the detekt ReturnCount budget.
- Role-pipeline schemas tightened: analysis.requirements, design.components,
  impl_plan.steps → minItems 1 + items string. Prompt-free — these arrays
  were already `required`; this only forbids them being empty.

§4 (force a non-empty `alternativesConsidered` on the architect) is
deliberately NOT taken: it contradicts the standing "don't raise
alternatives unnecessarily" preference the architect prompt already encodes.
This commit is contained in:
2026-06-13 15:47:54 +04:00
parent 65df3f4fad
commit 894969d62b
6 changed files with 70 additions and 5 deletions
@@ -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,
)
@@ -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<String> {
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
@@ -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") })
}
}