Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/pctx_codegen/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod case;
pub mod format;
pub mod normalize;
pub mod schema_type;
pub mod tools;
pub mod typegen;
Expand Down
172 changes: 172 additions & 0 deletions crates/pctx_codegen/src/normalize.rs
Original file line number Diff line number Diff line change
@@ -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/<name>`. 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<String>) {
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/<name>`.
fn rewrite_pointer_refs(v: &mut Value, pointers: &BTreeSet<String>) {
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::<Vec<_>>()
.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);
}
}
19 changes: 19 additions & 0 deletions crates/pctx_codegen/src/typegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ pub struct TypegenResult {
}

pub fn generate_types(root_schema: RootSchema, type_name: &str) -> CodegenResult<TypegenResult> {
// 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 {
Expand Down Expand Up @@ -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<RootSchema> {
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<bool> {
// follow top schema until no longer ref
let mut schema_type = SchemaType::from(schema);
Expand Down
63 changes: 63 additions & 0 deletions crates/pctx_codegen/tests/in_document_recursive_refs.rs
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading