feat: a schema says what strict mode will refuse, and its local refs can be inlined #24
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/strict-schema-check"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The second half of L3.
JsonSchemaStrictness.Checklists what a strictjson_schemaprovider would refuse in a user-authored schema, andInlineLocalRefsfixes the refs that can be fixed without changing what the schema means. Tsumugi'sKagaku.Flow.OpenRouteruses the check to refuse anllmnode whose schema has problems while strict is on, naming each pointer (D-14). The$refpassTool<T>ran on its own schemas moves into core as the same normaliser, and a recursive tool parameter type now gets a schema instead of an error. The sibling, #23 (ParseJson and name validation), is merged, and this branch is rebased onto it. This one needs no owner decision.The check
Check(JsonElement schema) → IReadOnlyList<StrictSchemaProblem>, each problem a JSON Pointer (RFC 6901 escaping) with aStrictSchemaRule. It never throws and never rewrites. An optional property is reported, not made required, because making it required would change the schema's meaning. Every rule names the providers whose rule it is, so a caller that targets one provider can ignore the others'.RootNotObject: roottypenot the string"object", or a union root["object","null"]RootWithoutPropertiesMissingAdditionalProperties/OpenAdditionalProperties: every object needsadditionalProperties: falsetruePropertyNotRequired: to make one optional, require it and allownullRequiredNotAPropertyUnsupportedRef: anything but#/$defs/<name>and#UnresolvedRefRecursiveRefRefWithSiblingsUnsupportedKeyword:allOf,oneOf,not,if/then/else,patternProperties,prefixItems,nullable, … and an arrayitemsArrayWithoutItemsUntypedSchema:{},true,false, or anenum/constwithout atypeComplexEnumValueUnsupportedName: a$defsname with/, or a property named like a keywordThe rule enum is append-only; store a rule by name.
InlineLocalRefsandTool<T>#/properties/…,#/definitions/…, refs into a definition), follows aliases, and leaves#/$defs/…refs and recursion alone. A work budget and a depth bound keep a pathological schema from exploding. The walker has a chain guard: probe 51, which drops it, crashes the host with a stack overflow.ToolSchemaBytesTests(22 cases), green on the old pass inTool.cs. That was re-checked after the rebase: 85/85 Agents tests at48f73f6. The second commit moves the pass into core, and the same tests stay green.ToolBaseTestsis untouched.Tool<T>fail. It now keeps one$refto itself, and a type that recurses twice gets a small schema. These are two more facts in the second commit.Tests
c67663b, after #23)JsonSchemaStrictnessTests(new, 118): one fact per rule with its pointer; a strict-clean schema with nested objects, arrays and$defs, with no problems; the order of problems; "never throws" on a type list with a non-string entry and on a root definition that is not an object.InlineLocalRefsTests(new, 48): inlines#/properties/…, leaves#/$defs/…alone, follows aliases, and hits the budget and depth limits.ToolSchemaBytesTests(new, 24): 22 golden-bytes cases in the first commit, plus 2 recursion facts in the second.Coverage (per changed file, lines and branches):
JsonSchemaStrictness.cs: 277/277 lines, 226/226 branches.JsonSchemaRefs.cs: 60/60 lines, 60/60 branches.JsonPointer.cs: 2/2 lines.StrictSchemaProblem.cs: 8/8 lines.Tool.cs: 20/21 lines. The one uncovered line,InvokeAsyncwith a parsed null, predates this PR, and the cache-hit line is now covered.Mutation probes: the brief's probes 24–57, split into 90 sub-cases, each run on a copy and restored. All are caught at run time. Probe 51 is counted as caught because it crashes the host.
Rebased onto #23
Rebased onto
mainc67663b, which now includes #21, #22 and #23. Only the README conflicted, and both sides were kept:StructuredOutputand the JSON-schema ref pass.JsonSchemaSpecandResponseFormatmerged cleanly: the two PRs edit different members.Out of scope
Kagaku.Flow.OpenRouter.JsonSchemaFormat<T>and the exporter move: Phase 4.InlineLocalRefsis the one opt-in fix.🤖 Generated with Claude Code
Summary
Summary
Coverage
OpenRouter.Net - 97.6%
OpenRouter.Net.Agents - 80.2%
OpenRouter.Net.Imaging - 81.8%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A provider-compatibility oracle~ You went and read the strict-mode documentation of five providers, distilled it into fifteen named rules with RFC 6901 pointers, and then — fufu~ — you proved the ref-pass refactor preserves bytes by writing the golden tests against the OLD pass first and moving the pass in a second commit. That two-commit proof isn't a claim, it's arithmetic. I checked it myself~ ♡
Verdict: ✅ Looks good to me~
What I verified mechanically (clone at
c9edc62, everything restored pristine after):TreatWarningsAsErrors+AnalysisLevel latest-recommended. Full suite 1270/1270 (1176 + 87 + 7) — and I ran baseb901b13too: 1010 + 63 + 7 = 1080. Your table's baseline and head both reproduce exactly.5ffca18) — the 22 golden-byte cases run green against the old pass still insideTool.cs(85/85), then at head they're green against the moved pass (87/87 = 85 + the 2 recursion facts). The proof is real, not narrated.JsonSchemaStrictness100% line AND branch including the nestedChecker(173/173, 148/148) andSubschemas(21/21, 32/32);JsonSchemaRefs100% (Pass51/51 lines, 60/60 branches);JsonPointer2/2;StrictSchemaProblem8/8;Tool<TParams>20/21 with the single uncovered line being the pre-existing parsed-null arm your body already discloses. Every coverage number in the table is true.Escapeorder swapped~-first→/-first → 3 red; (2)DefinitionName's multi-segmentContains('/')guard dropped → 1 red (deep#/$defs/x/properties/yrefs now misclassify); (3) the_chainguard dropped →Stack overflow., exit 134, run dead at 231/1176 — the crash your body promises for probe 51, reproduced; (4)requiredNamesfilter dropped → 90 red; (5)Reachesdequeue-order swap (owner check after visited-dedup) → 5 red. Restored each time, rebuilt, re-verified green.What I liked~
InlineLocalRefsnever throwing on weird input — budget and depth bounds with preciseArgumentExceptions, refs that point nowhere left alone — whileCheckreports and never rewrites an optional property into a required one. The separation of oracle and fixer is exactly right.Expand(ref→ref→schema) with the enclosing-location guard escaped consistently on both sides — andInlineLocalRefsTestspins the~1/~0round-trip both ways.The_exporter_emits_what_the_ref_pass_leaves_alone_byte_for_byteis such a sneaky-good test: it asserts the premises the byte-identity relies on, so a future runtime upgrade that changes what the exporter emits fails loudly instead of silently invalidating the goldens.StrictSchemaProblem,StrictSchemaRule,JsonSchemaStrictness),Internal/for the shared pass matching the 29 siblings, append-only enum documented "store by name, not number", XML docs on every member.💡 Little ideas (non-blocking)~
JsonDocument.Parse(node.ToJsonString())uses the default 64-levelMaxDepthwhile the pass it now shares withInlineLocalRefsis bounded at 1000. A parameter type nested ~33 records deep would export past 64 levels and throw at this parse. Pre-existing line, unchanged by this diff, and no realistic tool shape gets there — just an asymmetry a future PR might iron out.InlineLocalRefs' string round-trip is O(n) extra work, but it's what buys the independence guarantee the doc promises; correct trade for schemas of this size.Run once and finished~ ♪
Automated review by Jibril · 2026-09-25
CI/CD: absent for head
c9edc62at review time (0 bot comments) · Local checks: build 0w/0e, 1270/1270, coverage reproduced, 5/5 mutation probes killed, byte-identity proof reproduced on both commitsc9edc6295caf6326e3eaThanks for the review. Since you reviewed
c9edc62, the branch has been rebased ontomainc67663b(#23 merged), and the head is nowaf6326e. Only the README conflicted, and both sides were kept. Checks after the rebase:48f73f6) still passes on the old pass (85/85 Agents tests), so the byte-identity proof still holds after the rebase.💡 1 (Tool.cs:47, the parse uses the default
MaxDepthof 64 but the pass allows 1000): not taking it in this PR. The line predates this PR, and no real tool shape nests 33 records deep. Changing it now would put an unreviewed edit on an approved diff. I've logged it as a library follow-up. When it's fixed,JsonDocumentOptions { MaxDepth }should match the pass's depth bound, and a test should pin a type nested deeper than 64 levels.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
A rebase round~ You slid both commits cleanly onto
mainc67663bnow that #23 is in, and I came back to make sure nothing hid in the shuffle. Oh? You even rewrote the body's test table for the new baseline — 1369, not the old 1270. Honest numbers, fufu~ ♡Verdict: ✅ Looks good to me~
What I verified mechanically on this round's increment (
c9edc62→af6326e, clone pristine at head):c67663b(3-way, the only conflict beingResponseFormat.cs, exactly as your body says) produces a tree whose diff againstaf6326eis empty for all 11 code files. The entire rebase delta is README.md (+65/−5) — the "both sides were kept" merge.range-diffagrees: commit 1 replayed byte-identical (=), commit 2 differs in README hunks only. Round 1's verification (build, 1270/1270, 100% line+branch coverage on every new file, 5/5 mutation probes killed, byte-identity proof across the pass move) therefore transfers to this head's code in full.StructuredOutputand the ref pass beside your additions. I read every merged section — no half-resolved sentence anywhere.TreatWarningsAsErrors, and 1369/1369 — your table exactly (1275 + 87 + 7), which is base 1179 plus your 118 + 48 + 24.src/tree is byte-identical to this head's per the proof above — and it agrees with my round-1 reproduction:JsonSchemaStrictness100% line and branch (Checker included),JsonSchemaRefs100%/100%,Tool<TParams>100%/100%,StrictSchemaProblem8/8,JsonPointer2/2. The one uncoveredToolline is the pre-existing parsed-null arm your body discloses.💡 Little ideas (non-blocking)~
Tool.csJsonDocument.Parsedepth asymmetry from round 1 (default 64 vs the pass's 1000) survives the rebase unchanged — still pre-existing, still yours to iron out whenever a future PR touches that file~Fufu~ a rebase this boring is the highest compliment I can give. Ready to merge~ ♪
Automated review by Jibril · 2026-09-25
CI/CD: coverage bot 9459 on the merge ref (src tree byte-identical to head
af6326e) · Local checks: build 0w/0e, 1369/1369 at head, rebase delta proven README-only by tree reconstruction