diff --git a/crates/jp_cli/src/schema.rs b/crates/jp_cli/src/schema.rs index 8f01f3e88..26b419091 100644 --- a/crates/jp_cli/src/schema.rs +++ b/crates/jp_cli/src/schema.rs @@ -134,7 +134,7 @@ impl SchemaType { Self::Number => json!({"type": "number"}), Self::Boolean => json!({"type": "boolean"}), Self::Any => json!({}), - Self::Literal(val) => json!({"const": val}), + Self::Literal(value) => literal_schema(value), Self::Array(items) => json!({ "type": "array", "items": items.to_json(), @@ -667,23 +667,63 @@ fn fields_to_json(fields: &[SchemaField]) -> serde_json::Value { serde_json::Value::Object(schema) } -/// Convert a union to JSON Schema, optimizing all-literal unions to `enum`. +/// Convert a union to JSON Schema, optimizing compatible literal unions to a +/// typed `enum`. fn union_to_json(types: &[SchemaType]) -> serde_json::Value { - if types.iter().all(SchemaType::is_literal) { - // All literals: use the more widely supported `enum` form. - let values: Vec<&serde_json::Value> = types - .iter() - .map(|t| match t { - SchemaType::Literal(v) => v, - _ => unreachable!(), - }) - .collect(); - json!({ "enum": values }) - } else { - // Mixed types and literals: use `anyOf`. - json!({ + if !types.iter().all(SchemaType::is_literal) { + return json!({ "anyOf": types.iter().map(SchemaType::to_json).collect::>(), + }); + } + + let values = types + .iter() + .map(|type_| match type_ { + SchemaType::Literal(value) => value, + _ => unreachable!(), }) + .collect::>(); + + match common_literal_type(&values) { + Some(type_) => json!({ "type": type_, "enum": values }), + None => json!({ + "anyOf": values.into_iter().map(literal_schema).collect::>(), + }), + } +} + +// The type is redundant next to `const` for validation: both forms accept the +// same documents. It is emitted anyway because a consumer that dispatches on +// `type` skips a node without one, and at least one provider rejects such a +// node outright. +fn literal_schema(value: &serde_json::Value) -> serde_json::Value { + json!({ + "type": literal_type(value), + "const": value, + }) +} + +fn common_literal_type(values: &[&serde_json::Value]) -> Option<&'static str> { + let first = values.first().map(|value| literal_type(value))?; + if values.iter().all(|value| literal_type(value) == first) { + return Some(first); + } + + values + .iter() + .all(|value| matches!(literal_type(value), "integer" | "number")) + .then_some("number") +} + +fn literal_type(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "boolean", + serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "integer", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", } } diff --git a/crates/jp_cli/src/schema_tests.rs b/crates/jp_cli/src/schema_tests.rs index 9d4ba1c78..77d24ffd4 100644 --- a/crates/jp_cli/src/schema_tests.rs +++ b/crates/jp_cli/src/schema_tests.rs @@ -612,32 +612,62 @@ fn string_literal_enum() { let schema = parse_schema_dsl(r#"status "active"|"inactive"|"archived""#).unwrap(); assert_eq!( schema["properties"]["status"], - json!({"enum": ["active", "inactive", "archived"]}) + json!({"type": "string", "enum": ["active", "inactive", "archived"]}) ); } #[test] fn single_string_literal() { let schema = parse_schema_dsl(r#"kind "fixed""#).unwrap(); - assert_eq!(schema["properties"]["kind"], json!({"const": "fixed"})); + assert_eq!( + schema["properties"]["kind"], + json!({"type": "string", "const": "fixed"}) + ); } #[test] fn number_literal() { let schema = parse_schema_dsl("version 1").unwrap(); - assert_eq!(schema["properties"]["version"], json!({"const": 1})); + assert_eq!( + schema["properties"]["version"], + json!({"type": "integer", "const": 1}) + ); } #[test] fn negative_number_literal() { let schema = parse_schema_dsl("offset -1").unwrap(); - assert_eq!(schema["properties"]["offset"], json!({"const": -1})); + assert_eq!( + schema["properties"]["offset"], + json!({"type": "integer", "const": -1}) + ); } #[test] fn float_literal() { let schema = parse_schema_dsl("ratio 0.5").unwrap(); - assert_eq!(schema["properties"]["ratio"], json!({"const": 0.5})); + assert_eq!( + schema["properties"]["ratio"], + json!({"type": "number", "const": 0.5}) + ); +} + +#[test] +fn integer_literal_enum_has_integer_type() { + let schema = parse_schema_dsl("value 1|2|3").unwrap(); + assert_eq!( + schema["properties"]["value"], + json!({"type": "integer", "enum": [1, 2, 3]}) + ); +} + +#[test] +fn numeric_literal_enum_has_number_type() { + let schema = parse_schema_dsl("value 1|2.5").unwrap(); + assert_eq!( + schema["properties"]["value"], + json!({"type": "number", "enum": [1, 2.5]}) + ); } #[test] @@ -645,7 +675,13 @@ fn mixed_literal_enum() { let schema = parse_schema_dsl(r#"value "foo"|"bar"|42"#).unwrap(); assert_eq!( schema["properties"]["value"], - json!({"enum": ["foo", "bar", 42]}) + json!({ + "anyOf": [ + {"type": "string", "const": "foo"}, + {"type": "string", "const": "bar"}, + {"type": "integer", "const": 42} + ] + }) ); } @@ -654,26 +690,35 @@ fn literal_mixed_with_type() { let schema = parse_schema_dsl(r#"value "special"|int"#).unwrap(); assert_eq!( schema["properties"]["value"], - json!({"anyOf": [{"const": "special"}, {"type": "integer"}]}) + json!({"anyOf": [{"type": "string", "const": "special"}, {"type": "integer"}]}) ); } #[test] fn boolean_literal_true() { let schema = parse_schema_dsl("answer true").unwrap(); - assert_eq!(schema["properties"]["answer"], json!({"const": true})); + assert_eq!( + schema["properties"]["answer"], + json!({"type": "boolean", "const": true}) + ); } #[test] fn boolean_literal_false() { let schema = parse_schema_dsl("answer false").unwrap(); - assert_eq!(schema["properties"]["answer"], json!({"const": false})); + assert_eq!( + schema["properties"]["answer"], + json!({"type": "boolean", "const": false}) + ); } #[test] fn null_literal() { let schema = parse_schema_dsl("cleared null").unwrap(); - assert_eq!(schema["properties"]["cleared"], json!({"const": null})); + assert_eq!( + schema["properties"]["cleared"], + json!({"type": "null", "const": null}) + ); } #[test] @@ -681,7 +726,7 @@ fn nullable_string() { let schema = parse_schema_dsl("value null|string").unwrap(); assert_eq!( schema["properties"]["value"], - json!({"anyOf": [{"const": null}, {"type": "string"}]}) + json!({"anyOf": [{"type": "null", "const": null}, {"type": "string"}]}) ); } @@ -690,7 +735,10 @@ fn enum_in_array() { let schema = parse_schema_dsl(r#"tags ["foo"|"bar"|"baz"]"#).unwrap(); assert_eq!( schema["properties"]["tags"], - json!({"type": "array", "items": {"enum": ["foo", "bar", "baz"]}}) + json!({ + "type": "array", + "items": {"type": "string", "enum": ["foo", "bar", "baz"]} + }) ); } @@ -700,7 +748,7 @@ fn enum_in_nested_object() { let config = &schema["properties"]["config"]; assert_eq!( config["properties"]["mode"], - json!({"enum": ["fast", "slow"]}) + json!({"type": "string", "enum": ["fast", "slow"]}) ); assert_eq!(config["properties"]["count"], json!({"type": "integer"})); } @@ -710,15 +758,21 @@ fn enum_with_description() { let schema = parse_schema_dsl(r#"status "active"|"inactive": current status"#).unwrap(); assert_eq!( schema["properties"]["status"], - json!({"enum": ["active", "inactive"], "description": "current status"}) + json!({ + "type": "string", + "enum": ["active", "inactive"], + "description": "current status" + }) ); } #[test] -fn true_false_enum_is_not_bool_type() { - // true|false produces enum, not {"type": "boolean"} +fn true_false_enum_has_boolean_type() { let schema = parse_schema_dsl("flag true|false").unwrap(); - assert_eq!(schema["properties"]["flag"], json!({"enum": [true, false]})); + assert_eq!( + schema["properties"]["flag"], + json!({"type": "boolean", "enum": [true, false]}) + ); } #[test] @@ -728,7 +782,7 @@ fn field_level_union_with_literal_and_array() { schema["properties"]["value"], json!({ "anyOf": [ - {"type": "array", "items": {"enum": ["a", "b"]}}, + {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}}, {"type": "integer"} ] }) diff --git a/docs/.vitepress/rfd-summaries.json b/docs/.vitepress/rfd-summaries.json index 9826be052..1ead11442 100644 --- a/docs/.vitepress/rfd-summaries.json +++ b/docs/.vitepress/rfd-summaries.json @@ -116,7 +116,7 @@ "summary": "Make JP scriptable with concise schema DSL and inferred clean JSON output when piping or with --schema flag." }, "030-schema-dsl.md": { - "hash": "d8f041473711fa0ec15010002e56e1ef8e8f65c1b55481601e6efdffc4d70f2e", + "hash": "97d9fe44330e60174d9045dfba09869ee1f005af585df97f79163a947cdd7ff6", "summary": "JP's concise DSL syntax for defining JSON Schema objects via command-line flags with types, descriptions, and nested structures." }, "031-durable-conversation-storage-with-workspace-projection.md": { diff --git a/docs/rfd/030-schema-dsl.md b/docs/rfd/030-schema-dsl.md index b5333639f..3b1d7a6de 100644 --- a/docs/rfd/030-schema-dsl.md +++ b/docs/rfd/030-schema-dsl.md @@ -192,13 +192,17 @@ Quoted strings, numbers, `true`, `false`, and `null` in the type position define literal (constant) values: ``` -kind "fixed" → {"const": "fixed"} -version 1 → {"const": 1} -ratio 0.5 → {"const": 0.5} -answer true → {"const": true} -cleared null → {"const": null} +kind "fixed" → {"type": "string", "const": "fixed"} +version 1 → {"type": "integer", "const": 1} +ratio 0.5 → {"type": "number", "const": 0.5} +answer true → {"type": "boolean", "const": true} +cleared null → {"type": "null", "const": null} ``` +A literal carries both its type and its value. +`{"type": "integer", "const": 1}` and `{"const": 1}` accept the same documents; +the first also says what kind of value it is. + Strings must be quoted in the type position to distinguish them from type keywords. `string` is the type; `"string"` is the literal value `"string"`. @@ -206,18 +210,18 @@ keywords. ### Unions The pipe `|` creates union types. -When all variants are literals, the output uses `enum` (widely supported by LLM -providers in strict mode). -When the union mixes literals and types, the output uses `anyOf`. +When every variant is a literal and the literals share a type, the output is a +typed `enum`; integer and float literals together widen to `number`. +Every other union is an `anyOf`. -**All literals — `enum`:** +**All literals of one type — typed `enum`:** ``` status "active"|"inactive"|"archived" ``` ```json -{"enum": ["active", "inactive", "archived"]} +{"type": "string", "enum": ["active", "inactive", "archived"]} ``` **Mixed types — `anyOf`:** @@ -227,17 +231,23 @@ value "special"|int ``` ```json -{"anyOf": [{"const": "special"}, {"type": "integer"}]} +{"anyOf": [{"type": "string", "const": "special"}, {"type": "integer"}]} ``` -**Mixed literals (different JSON types) — `enum`:** +**Mixed literal types — `anyOf`:** ``` value "foo"|"bar"|42 ``` ```json -{"enum": ["foo", "bar", 42]} +{ + "anyOf": [ + {"type": "string", "const": "foo"}, + {"type": "string", "const": "bar"}, + {"type": "integer", "const": 42} + ] +} ``` Inside arrays, `|` defines which item types the array accepts: @@ -257,7 +267,7 @@ tags ["foo"|"bar"|"baz"] ``` ```json -{"type": "array", "items": {"enum": ["foo", "bar", "baz"]}} +{"type": "array", "items": {"type": "string", "enum": ["foo", "bar", "baz"]}} ``` At the field level, `|` creates a union of the entire type: @@ -392,7 +402,7 @@ This produces: "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, - "role": {"enum": ["engineer", "manager", "designer"]}, + "role": {"type": "string", "enum": ["engineer", "manager", "designer"]}, "misc": {"type": "array", "items": {}, "description": "whatever you want"}, "nested": { "type": "object",