You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: make format warnings reach execution boundary and fix schema handling defects
This commit addresses multiple critical schema and validation issues:
1. Adds `warn_format_violations` and ensures it runs at input/output validation boundaries, so format warnings now fire for actual module invocations instead of only direct schema validation calls
2. Fixes format warning walk to reach into combinators (anyOf/oneOf/allOf/additionalProperties) and avoid duplicate reports
3. Reworks type array handling to properly create real unions instead of collapsing to first member
4. Makes `type` and combinator siblings (enum/const/anyOf/etc) both enforced instead of discarding one
5. Honours object form of `additionalProperties` instead of ignoring it
6. Enforces `not` keyword instead of aborting schema generation
7. Fixes option keyword leakage across types and restores missing description/title fields on generated models
8. Adds comprehensive test coverage for all these changes
Signed-off-by: tercel <tercel.yi@gmail.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+25Lines changed: 25 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
8
8
9
+
## [Unreleased]
10
+
11
+
> **Release note:** this section contains BREAKING changes. It must ship as a
12
+
> **minor** (or major) version bump, never a patch.
13
+
14
+
### Changed
15
+
16
+
- **BREAKING: a `type` array converts to a real union instead of collapsing to its first member.** `_schema_to_field_info` kept only the first non-`null` member of `{"type": [...]}` and dropped the rest, which failed in both directions. When the leading member was a scalar the annotation was **over-tightened**: `{"type": ["string", "boolean"]}` — what apexe emits for a value-optional flag — became `str`, so `color=true` was rejected while apcore-rust accepted it. When the leading member was `object` or `array` the annotation **widened to `Any`**, because `_TYPE_MAP` has no entry for either, so `{"type": ["object", "null"]}` accepted `42` and `"str"` alike. Each member now becomes its own union branch, and the type-specific option keywords are kept per branch, so `{"type": ["string", "integer"], "minLength": 3, "minimum": 10}` no longer applies the numeric bound to the string branch. **Impact:** a value that was accepted only because the union collapsed to `Any` now fails with `SCHEMA_VALIDATION_ERROR`; a value that was rejected only because a branch was discarded now succeeds. **Migration:** none for a well-formed call — this is the behaviour apcore-rust has always had.
17
+
18
+
-**BREAKING: a combinator sibling of a `type` keyword is enforced, and `type` is no longer discarded by one.** The dispatch chain returned on the first keyword it matched, so `type` and its siblings could never both hold. `const`/`enum`/`oneOf`/`anyOf`/`allOf` were checked *before* the type dispatch and won outright: `{"type": "string", "anyOf": [{"minLength": 3}]}` annotated the field `Any` and accepted `12345`, `{"a": 1}`, and `True`. `type` and a combinator keyword are independent assertions that must both hold (JSON Schema 2020-12 §10.2), so the type-derived annotation is now intersected with a jsonschema-backed check for every sibling it does not already cover — the same engine `hardening.validate_schema_dict` uses, which keeps the two validation paths in agreement. **Impact:** a value that passed only because one of the two assertions was dropped now fails with `SCHEMA_VALIDATION_ERROR`. **Migration:** none for a well-formed call.
19
+
20
+
-**BREAKING: the object form of `additionalProperties` is honoured.**`generate_model` only inspected `additionalProperties is False`; a sub-schema form (`{"type": "integer"}`) fell through to Pydantic's default `extra="ignore"`, so an undeclared key of any type was silently accepted and dropped. It now maps to `extra="allow"` plus a typed `__pydantic_extra__`, so undeclared keys are kept and their values validated. `additionalProperties: false` is unchanged. **Impact:** a call passing an undeclared key whose value does not match the declared sub-schema now fails with `SCHEMA_VALIDATION_ERROR`; undeclared keys that do match are now **retained** rather than dropped. **Migration:** none for a well-formed call — apcore-rust (jsonschema crate) has always rejected these.
21
+
22
+
-**BREAKING: `not` is enforced instead of aborting schema generation.**`'not' keyword not yet supported` was raised at *model-build* time, so a module whose contract carried `not` anywhere could not be registered at all. It is now applied as a sibling assertion like every other combinator. **Impact:** a contract that previously failed to load now loads and validates; a value the `not` excludes is now rejected at the validation boundary. `if`/`then`/`else` still raise — unchanged and out of scope here.
23
+
24
+
### Fixed
25
+
26
+
-**Option keywords no longer leak across types, and no longer vanish on a required field.** Two related defects in `_build_field`. First, every constraint was applied field-wide regardless of the declared type, so `{"type": ["string", "integer"], "minimum": 10}` attached `ge=10` to the string branch. Constraints for a `type` array now live on their own branch. Second, the `enum`/`const`/combinator branches returned a bare `Field(default=...)`, discarding every constraint — but only for **required** fields, because the optional path rebuilt the field through `_clone_field_with_default` and picked them back up. `{"type": "string", "minLength": 5, "enum": ["ab", "abcdef"]}` therefore accepted `"ab"` when required and rejected it when optional. Both paths now build the field the same way.
27
+
28
+
-**`description` and `title` reach the generated model.**`_build_field` never copied either keyword, so both were dropped for every property — a scalar `type` as much as a `type` array. Pydantic then re-derived a title from the field name, which made the loss easy to miss. LLM-facing exports (MCP / OpenAI / Anthropic tool definitions) and `content_hash` read the raw JSON Schema rather than the generated model, so they were unaffected; only direct consumers of `generate_model()` saw the omission.
29
+
30
+
-**The SHOULD-level format warning reaches the execution boundary.**`_check_formats_and_warn` was only ever called from `hardening.validate_schema_dict`, which `SchemaValidator.validate` invokes solely when the schema carries a **top-level**`oneOf`/`anyOf`. Module invocation validates through `input_schema.model_validate` (`builtin_steps.py`), which never reached it, so a format violation on a real call emitted nothing at all — the conformance fixture `schema_hardening_formats.json`'s `warn_logged: true` half was satisfied only by tests calling `validate_schema_dict` directly. The new `hardening.warn_format_violations(data, model)` is now called from both the input and output validation steps, reading the source JSON Schema that `SchemaLoader` attaches to every generated model. Whether a schema declares any `format` at all is computed once and cached on the model, so a schema without one costs a single attribute lookup. A natively declared Pydantic model (no source schema) is skipped.
31
+
32
+
-**The format warning walk reaches into combinators.** The walk descended only through `properties` and `items`, so a `format` inside an `anyOf` / `oneOf` / `allOf` branch or an `additionalProperties` sub-schema never warned. Each node is now checked against its own `format` before the walk descends, and the walk covers those four node kinds. A union branch is only descended into when the data actually satisfies it, so a sibling branch cannot report a format the value never carried, and an annotation reached through more than one branch is reported once. An unrecognised format still never warns and never fails, per JSON Schema 2020-12 §7.2.1.
0 commit comments