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 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,
|
||||
)
|
||||
|
||||
+23
-2
@@ -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
|
||||
|
||||
+32
@@ -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") })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user