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:
@@ -15,4 +15,10 @@ data class JsonSchemaProperty(
|
|||||||
val type: String,
|
val type: String,
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
val default: String? = null,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
+23
-2
@@ -1,6 +1,7 @@
|
|||||||
package com.correx.core.validation.artifact
|
package com.correx.core.validation.artifact
|
||||||
|
|
||||||
import com.correx.core.artifacts.kind.JsonSchema
|
import com.correx.core.artifacts.kind.JsonSchema
|
||||||
|
import com.correx.core.artifacts.kind.JsonSchemaProperty
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -12,7 +13,8 @@ import kotlinx.serialization.json.longOrNull
|
|||||||
/**
|
/**
|
||||||
* Minimal, deterministic validator of a JSON value against a [JsonSchema]
|
* Minimal, deterministic validator of a JSON value against a [JsonSchema]
|
||||||
* (the subset correx models: object type, typed properties, required keys,
|
* (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,
|
* 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.
|
* so an LLM-emitted custom kind is checked against its shape rather than trusted.
|
||||||
@@ -35,12 +37,31 @@ object JsonSchemaValidator {
|
|||||||
if (property == null) {
|
if (property == null) {
|
||||||
if (!schema.additionalProperties) errors += "unknown property '$key'"
|
if (!schema.additionalProperties) errors += "unknown property '$key'"
|
||||||
} else {
|
} else {
|
||||||
checkType(property.type, element)?.let { errors += "property '$key': $it" }
|
errors += checkProperty(key, property, element)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errors
|
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? {
|
private fun checkType(type: String, value: JsonElement): String? {
|
||||||
val ok = when (type) {
|
val ok = when (type) {
|
||||||
"string" -> value is JsonPrimitive && value.isString
|
"string" -> value is JsonPrimitive && value.isString
|
||||||
|
|||||||
+32
@@ -53,4 +53,36 @@ class JsonSchemaValidatorTest {
|
|||||||
fun `non-object payload against object schema is flagged`() {
|
fun `non-object payload against object schema is flagged`() {
|
||||||
assertEquals(1, errors("""["a","b"]""").size)
|
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") })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
},
|
},
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"type": "array",
|
"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": {
|
"affected_areas": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"type": "array",
|
"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": {
|
"risks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"steps": {
|
"steps": {
|
||||||
"type": "array",
|
"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": {
|
"verification": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
Reference in New Issue
Block a user