diff --git a/CHANGELOG.md b/CHANGELOG.md index f53a151..2492c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `pctx_codegen` now resolves in-document JSON pointer `$ref`s (e.g. `#/properties/filter/anyOf/0`) during type generation. Previously only `$defs`-named refs resolved, so a tool whose input schema used a pointer ref — such as a recursive query filter whose `and`/`or` groups reference the filter itself — failed type generation, and consumers fell back to typing the entire tool input as `any`. Pointer targets are now hoisted into `definitions` under a generated name and all refs to them repointed, so self-referential schemas generate proper recursive TypeScript types. Schemas already using `$defs`/`definitions` refs are unaffected. + ## [v0.7.2] - 2026-07-16 ### Fixed diff --git a/crates/pctx_codegen/src/lib.rs b/crates/pctx_codegen/src/lib.rs index 6c7f7a9..c0257be 100644 --- a/crates/pctx_codegen/src/lib.rs +++ b/crates/pctx_codegen/src/lib.rs @@ -1,5 +1,6 @@ pub mod case; pub mod format; +pub mod normalize; pub mod schema_type; pub mod tools; pub mod typegen; diff --git a/crates/pctx_codegen/src/normalize.rs b/crates/pctx_codegen/src/normalize.rs new file mode 100644 index 0000000..3e85548 --- /dev/null +++ b/crates/pctx_codegen/src/normalize.rs @@ -0,0 +1,172 @@ +//! Normalize hand-authored JSON Schemas so the type generator can resolve their +//! recursion. +//! +//! [`schema_type`](crate::schema_type) resolves a `$ref` by its trailing path +//! segment, looked up in the schema's `definitions` map — so `#/$defs/Foo` and +//! `#/definitions/Foo` work. Hand-written schemas (e.g. a recursive query filter +//! whose `and`/`or` groups reference the filter itself) instead express recursion +//! with an in-document JSON **pointer**, e.g. `#/properties/filter/anyOf/0`. Its +//! trailing segment is `0`, which is not a definition, so `follow` fails with +//! `#/$defs/0 does not exist` and the whole tool falls back to `any`. +//! +//! [`normalize_in_document_refs`] rewrites those into the named form codegen +//! already handles: each distinct pointer target is hoisted into `definitions` +//! under a generated name, and every ref to it — including refs *inside* the +//! hoisted target, so a self-referential filter becomes a proper named recursive +//! type — is repointed at `#/definitions/`. Schemas that already use +//! `#/$defs`/`#/definitions` refs pass through untouched. + +use std::collections::BTreeSet; + +use serde_json::{Map, Value, json}; + +/// Prefix for generated definition names, kept distinctive so a hoisted pointer +/// target can't collide with a real `definitions` entry. +const HOISTED_PREFIX: &str = "InlineRef"; + +/// Rewrite in-document pointer `$ref`s into named `definitions` refs. Idempotent +/// and a no-op for schemas without such refs. +pub fn normalize_in_document_refs(mut schema: Value) -> Value { + let mut pointers = BTreeSet::new(); + collect_pointer_refs(&schema, &mut pointers); + if pointers.is_empty() { + return schema; + } + + // Resolve pointers against an unmodified snapshot (adding definitions must not + // shift the targets), and only rewrite the refs we actually hoisted — an + // unresolvable pointer is left as-is rather than repointed at a missing def. + let snapshot = schema.clone(); + let Some(root) = schema.as_object_mut() else { + return schema; + }; + let defs = root + .entry("definitions") + .or_insert_with(|| Value::Object(Map::new())); + let Some(defs) = defs.as_object_mut() else { + return schema; + }; + let mut hoisted = BTreeSet::new(); + for ptr in &pointers { + // A JSON pointer is the ref without its leading '#'. + if let Some(target) = snapshot.pointer(&ptr[1..]) { + defs.entry(def_name(ptr)).or_insert_with(|| target.clone()); + hoisted.insert(ptr.clone()); + } + } + rewrite_pointer_refs(&mut schema, &hoisted); + schema +} + +/// Collect distinct `$ref`s that point into the document body (`#/...`) rather +/// than at a `$defs`/`definitions` entry. +fn collect_pointer_refs(v: &Value, out: &mut BTreeSet) { + match v { + Value::Object(map) => { + if let Some(Value::String(r)) = map.get("$ref") + && is_in_document_pointer(r) + { + out.insert(r.clone()); + } + for child in map.values() { + collect_pointer_refs(child, out); + } + } + Value::Array(arr) => arr.iter().for_each(|c| collect_pointer_refs(c, out)), + _ => {} + } +} + +/// Repoint every `$ref` in `pointers` at its hoisted `#/definitions/`. +fn rewrite_pointer_refs(v: &mut Value, pointers: &BTreeSet) { + match v { + Value::Object(map) => { + if let Some(Value::String(r)) = map.get("$ref") + && pointers.contains(r) + { + let name = def_name(r); + map.insert("$ref".to_string(), json!(format!("#/definitions/{name}"))); + } + for child in map.values_mut() { + rewrite_pointer_refs(child, pointers); + } + } + Value::Array(arr) => arr + .iter_mut() + .for_each(|c| rewrite_pointer_refs(c, pointers)), + _ => {} + } +} + +/// A ref that points into the document body (e.g. `#/properties/filter/anyOf/0`) +/// and not at an already-named definition. +fn is_in_document_pointer(r: &str) -> bool { + r.starts_with("#/") && !r.starts_with("#/$defs/") && !r.starts_with("#/definitions/") +} + +/// A stable, collision-resistant definition name for a pointer, e.g. +/// `#/properties/filter/anyOf/0` → `InlineRef_properties_filter_anyOf_0`. +fn def_name(pointer: &str) -> String { + let path = pointer + .trim_start_matches("#/") + .split('/') + .collect::>() + .join("_"); + format!("{HOISTED_PREFIX}_{path}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hoists_recursive_filter_self_ref_into_definitions() { + // The shape that broke codegen: a filter whose `or`/`and` branches ref + // `#/properties/filter/anyOf/0` (a pointer into the body, self-recursive). + let schema = json!({ + "type": "object", + "properties": { + "filter": { + "anyOf": [ + {"anyOf": [ + {"type": "object", "properties": {"attribute": {"type": "string"}}}, + {"type": "object", "properties": { + "or": {"type": "array", "items": {"$ref": "#/properties/filter/anyOf/0"}} + }} + ]}, + {"type": "null"} + ] + } + } + }); + let out = normalize_in_document_refs(schema); + + let name = "InlineRef_properties_filter_anyOf_0"; + // The target is hoisted into definitions... + assert!(out["definitions"][name].is_object(), "hoisted def present"); + // ...the original ref is repointed at the named def... + assert_eq!( + out["properties"]["filter"]["anyOf"][0]["anyOf"][1]["properties"]["or"]["items"]["$ref"], + format!("#/definitions/{name}") + ); + // ...and the self-ref INSIDE the hoisted def is repointed too, so it's a + // proper named recursive type rather than a dangling body pointer. + assert_eq!( + out["definitions"][name]["anyOf"][1]["properties"]["or"]["items"]["$ref"], + format!("#/definitions/{name}") + ); + } + + #[test] + fn leaves_named_refs_and_plain_schemas_untouched() { + let named = json!({ + "type": "object", + "properties": {"child": {"$ref": "#/$defs/Node"}}, + "$defs": {"Node": {"type": "object"}} + }); + assert_eq!(normalize_in_document_refs(named.clone()), named); + + let plain = json!({"type": "object", "properties": {"n": {"type": "number"}}}); + assert_eq!(normalize_in_document_refs(plain.clone()), plain); + } +} diff --git a/crates/pctx_codegen/src/typegen/mod.rs b/crates/pctx_codegen/src/typegen/mod.rs index 911e3aa..d22c73a 100644 --- a/crates/pctx_codegen/src/typegen/mod.rs +++ b/crates/pctx_codegen/src/typegen/mod.rs @@ -28,6 +28,11 @@ pub struct TypegenResult { } pub fn generate_types(root_schema: RootSchema, type_name: &str) -> CodegenResult { + // Normalize in-document pointer refs (e.g. a recursive filter's + // `#/properties/filter/anyOf/0`) into named `definitions` refs, which the + // resolver below handles. A no-op for schemas that already use `$defs`. + let root_schema = normalize_root_schema(root_schema)?; + // ensure all objects have type names let mut defs: SchemaDefinitions = IndexMap::new(); for (ref_key, s) in root_schema.definitions { @@ -59,6 +64,20 @@ pub fn generate_types(root_schema: RootSchema, type_name: &str) -> CodegenResult }) } +/// Apply [`normalize_in_document_refs`](crate::normalize::normalize_in_document_refs) +/// to a `RootSchema`. Round-trips through JSON so pointer resolution runs against +/// the standard schema document (where `#/properties/…` is meaningful), then +/// re-parses; the schemas involved are tiny (one tool's input), so the cost is +/// negligible and paid once at registration. +fn normalize_root_schema(root_schema: RootSchema) -> CodegenResult { + let value = serde_json::to_value(&root_schema).map_err(|e| { + crate::CodegenError::TypeGen(format!("serialize schema for normalize: {e}")) + })?; + let normalized = crate::normalize::normalize_in_document_refs(value); + serde_json::from_value(normalized) + .map_err(|e| crate::CodegenError::TypeGen(format!("re-parse normalized schema: {e}"))) +} + fn is_all_optional(schema: &Schema, defs: &SchemaDefinitions) -> CodegenResult { // follow top schema until no longer ref let mut schema_type = SchemaType::from(schema); diff --git a/crates/pctx_codegen/tests/in_document_recursive_refs.rs b/crates/pctx_codegen/tests/in_document_recursive_refs.rs new file mode 100644 index 0000000..38faf77 --- /dev/null +++ b/crates/pctx_codegen/tests/in_document_recursive_refs.rs @@ -0,0 +1,63 @@ +//! Regression: a hand-authored schema can express recursion as an in-document +//! JSON pointer (`#/properties/filter/anyOf/0`) rather than a `#/$defs/` named +//! ref — e.g. a query filter whose `and`/`or` groups reference the filter itself. +//! Before the normalize pass, codegen failed with `#/$defs/0 does not exist` and +//! the tool fell back to `any`. It must now generate a real recursive type. +use pctx_codegen::RootSchema; + +/// A recursive query filter: `and`/`or` groups reference the top filter via an +/// in-document pointer (`#/properties/filter/anyOf/0`). +fn recursive_filter_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "object": {"type": "string"}, + "filter": { + "anyOf": [ + {"anyOf": [ + {"type": "object", + "properties": {"attribute": {"type": "string"}, "op": {"type": "string"}}, + "required": ["attribute", "op"]}, + {"type": "object", + "properties": {"or": {"type": "array", + "items": {"$ref": "#/properties/filter/anyOf/0"}}}, + "required": ["or"]}, + {"type": "object", + "properties": {"and": {"type": "array", + "items": {"$ref": "#/properties/filter/anyOf/0"}}}, + "required": ["and"]} + ]}, + {"type": "null"} + ] + } + }, + "required": ["object"] + }) +} + +#[test] +fn in_document_recursive_ref_generates_real_types() { + let schema: RootSchema = + serde_json::from_value(recursive_filter_schema()).expect("schema deserializes"); + let res = pctx_codegen::typegen::generate_types(schema, "ListRecordsInput") + .expect("recursive in-document filter ref must resolve, not error"); + + // `object` keeps its real type; the whole input is not collapsed. + assert!( + res.types.contains("object: string"), + "types:\n{}", + res.types + ); + // The filter is a real recursive union (its `or`/`and` branches reference the + // hoisted filter type), not degraded to `any`. + assert!( + res.types.contains("InlineRef"), + "expected a hoisted recursive filter type; got:\n{}", + res.types + ); + assert!( + !res.types.contains("any"), + "filter must not fall back to `any`; got:\n{}", + res.types + ); +}