refactor(tools): restore py-types to the ts-types trusted-after-validation stance
Rounds 6-9 of the bot review kept finding adjacent hostile-getter variants (post-validation cycles, TOCTOU on const/enum/oneOf, self-referential functions) because the renderer had grown per-shape runtime defenses the sibling ts-types renderer does not have. Those inputs are unreachable: the schema is a first-party defineTool object literal that already passed assertSupportedJsonSchema, and per AGENTS.md "Trust TypeScript at typed same-process seams" a typed same-process seam does not add hostile-input handling for values the static interface forbids. renderType now validates the whole tree once and trusts it, wrapping the walk in one try/catch that degrades to Any — byte-for-byte the stance of the ts-types sibling. This removes the cycle-tracking (activeSchemas/hasIdentity), the const/enum/oneOf read snapshots, the isPyScalar re-check, the typing rollback, and the pyScalar null->None re-read handling; the corresponding hostile-getter tests are removed. Behavior fixes that hold for legitimate input are kept: RESERVED soft-keyword exclusion, closed-empty-object TypedDict, class-name cap + per-base collision counter, BigInt digits for beyond-safe integers. py-types.ts stays at 100% per-file coverage. The language-dispatch Agent Note documents the stance and its symmetry with ts-types so the boundary is not re-litigated.
This commit is contained in:
@@ -20,17 +20,6 @@ import type { ToolSdkSchema } from './ts-types.ts'
|
||||
/** Property names that are valid bare Python identifiers; anything else is subscripted. */
|
||||
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
/**
|
||||
* Whether a schema value carries a trackable reference identity for the render
|
||||
* walk's cycle detection. Both plain objects AND functions qualify: a function
|
||||
* has `typeof 'function'` yet can carry own properties (`oneOf`, `items`) and
|
||||
* reference itself, so a post-validation getter returning a self-referential
|
||||
* function would otherwise bypass the object-only guard and loop forever.
|
||||
*/
|
||||
function hasIdentity(value: unknown): value is object {
|
||||
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Python hard keywords: reserved everywhere, so a tool or field named
|
||||
* ``class`` or ``lambda`` is legal on the wire but not as an attribute
|
||||
@@ -67,8 +56,8 @@ function pad(indent: number): string {
|
||||
/**
|
||||
* Collector threaded through {@link renderType}: the emitted `TypedDict` class
|
||||
* declarations (nested classes precede the parent that references them), the
|
||||
* class names already taken (for collision suffixing), and the `typing`
|
||||
* symbols the render actually used.
|
||||
* class names already taken (for collision suffixing), a per-base collision
|
||||
* counter, and the `typing` symbols the render actually used.
|
||||
*/
|
||||
interface RenderState {
|
||||
readonly classes: string[]
|
||||
@@ -161,11 +150,10 @@ function allocateClassName(base: string, state: RenderState): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one validated scalar as Python literal text (`True`/`False`, `None`,
|
||||
* JSON-quoted strings, bare numbers). A validated `const`/`enum` never carries
|
||||
* a bare `null` on a non-`null` scalar type, but a post-validation stateful
|
||||
* getter can re-read one as `null`, so `null` is spelled `None` rather than the
|
||||
* JS `String(null)` = `"null"`.
|
||||
* Render one validated scalar as Python literal text (`True`/`False`,
|
||||
* JSON-quoted strings, bare numbers). `null` cannot reach here: the `null`
|
||||
* type renders directly as `None`, and the unified validator rejects a null
|
||||
* `const`/`enum` entry on every other scalar type.
|
||||
*
|
||||
* A beyond-safe-range integral number takes `BigInt` digits rather than
|
||||
* `String`: Python integers are arbitrary-precision, so the emitted digits ARE
|
||||
@@ -180,7 +168,6 @@ function allocateClassName(base: string, state: RenderState): string {
|
||||
function pyScalar(value: JsonSchemaScalar): string {
|
||||
if (value === true) return 'True'
|
||||
if (value === false) return 'False'
|
||||
if (value === null) return 'None'
|
||||
if (typeof value === 'string') return JSON.stringify(value)
|
||||
if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
||||
return BigInt(value).toString()
|
||||
@@ -188,11 +175,6 @@ function pyScalar(value: JsonSchemaScalar): string {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** Whether a value is a JSON scalar `Literal[...]` can spell (a re-read getter may return anything). */
|
||||
function isPyScalar(value: unknown): value is JsonSchemaScalar {
|
||||
return value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to
|
||||
* the broad type. Deliberately deviates from PEP 586, which restricts `Literal`
|
||||
@@ -203,24 +185,12 @@ function isPyScalar(value: unknown): value is JsonSchemaScalar {
|
||||
*/
|
||||
function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string {
|
||||
if (Object.hasOwn(node, 'const')) {
|
||||
// Snapshot the value with ONE read: a stateful getter can return different
|
||||
// values across reads, so a separate check-read and spell-read could still
|
||||
// pass the check and then spell a non-scalar (`Literal[[object Object]]`).
|
||||
const value = node.const
|
||||
if (!isPyScalar(value)) return broad
|
||||
state.typing.add('Literal')
|
||||
return `Literal[${pyScalar(value)}]`
|
||||
return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]`
|
||||
}
|
||||
if (Object.hasOwn(node, 'enum')) {
|
||||
const raw = node.enum
|
||||
// `[...raw]` reads each element exactly once (elements may be accessor
|
||||
// properties that change between reads); then check and spell that
|
||||
// snapshot. Require non-empty: an emptied re-read would spell `Literal[]`,
|
||||
// a Python SyntaxError that breaks the whole SDK.
|
||||
const values: unknown[] | undefined = Array.isArray(raw) ? [...(raw as unknown[])] : undefined
|
||||
if (values === undefined || values.length === 0 || !values.every(isPyScalar)) return broad
|
||||
state.typing.add('Literal')
|
||||
return `Literal[${values.map(pyScalar).join(', ')}]`
|
||||
return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]`
|
||||
}
|
||||
return broad
|
||||
}
|
||||
@@ -231,9 +201,10 @@ function renderConstrainedScalar(node: Record<string, unknown>, broad: string, s
|
||||
* needs. `className` is the name to give an object node with properties (and
|
||||
* the prefix for its nested objects). Handles every unified schema construct —
|
||||
* `oneOf` (→ `X | Y`), `const`/`enum` (→ `Literal[...]`), `integer` (→ `int`),
|
||||
* `null` (→ `None`) — and degrades malformed or unsupported inputs to `Any`
|
||||
* without throwing. {@link jsonSchemaToPy} is the context-free entry point;
|
||||
* this is the collecting core.
|
||||
* `null` (→ `None`) — and degrades an unsupported or malformed schema to `Any`
|
||||
* without throwing, the same trusted-after-validation stance as the sibling
|
||||
* {@link ./ts-types.ts | ts-types} renderer. {@link jsonSchemaToPy} is the
|
||||
* context-free entry point; this is the collecting core.
|
||||
*/
|
||||
function renderType(schema: unknown, className: string, state: RenderState): string {
|
||||
interface Frame {
|
||||
@@ -247,46 +218,28 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
childTypes: string[]
|
||||
entries: [string, unknown][]
|
||||
allocated?: string
|
||||
validated: boolean
|
||||
}
|
||||
const newFrame = (schema: unknown, className: string, validated: boolean): Frame =>
|
||||
({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated })
|
||||
const frames: Frame[] = [newFrame(schema, className, false)]
|
||||
// Ancestor schemas by reference identity — the frame stack IS the DFS path,
|
||||
// so this set holds exactly the current node's ancestors. A stateful getter
|
||||
// can mutate the graph after validation (an `items`/property that validated
|
||||
// as a scalar but returns an ancestor at render time); without this, the walk
|
||||
// would push frames forever. A repeated ancestor degrades to `Any` per the
|
||||
// never-throw contract. Distinct nodes in a legitimately deep chain are all
|
||||
// different references, so this stays O(1) per push and O(depth) memory.
|
||||
// Both objects and functions are tracked (see {@link hasIdentity}). Out of
|
||||
// scope: a getter fabricating a FRESH node per read never repeats an ancestor
|
||||
// and is locally indistinguishable from a legitimately unbounded-depth schema
|
||||
// (which this module supports), so cycle detection is the reachable best
|
||||
// defense rather than a depth cap that would break the legitimate case.
|
||||
const activeSchemas = new Set<object>()
|
||||
if (hasIdentity(schema)) activeSchemas.add(schema)
|
||||
let result: string | undefined
|
||||
// The no-throw contract must hold across the WHOLE walk, not just the root
|
||||
// validation: a hostile stateful getter (a `type` that returns a scalar on
|
||||
// the first read and throws on a later one) reaches the render phase past
|
||||
// validation. Any throw here degrades to `Any`, discarding classes this call
|
||||
// partially emitted so no broken declaration escapes.
|
||||
const classFloor = state.classes.length
|
||||
const typingFloor = new Set(state.typing)
|
||||
/* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
|
||||
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
|
||||
const finish = (type: string): void => {
|
||||
const popped = frames.pop()
|
||||
if (popped !== undefined && hasIdentity(popped.schema)) {
|
||||
activeSchemas.delete(popped.schema)
|
||||
}
|
||||
const parent = frames.at(-1)
|
||||
if (parent === undefined) result = type
|
||||
else parent.childTypes.push(type)
|
||||
}
|
||||
|
||||
const newFrame = (schema: unknown, className: string): Frame =>
|
||||
({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [] })
|
||||
try {
|
||||
// Validate the WHOLE tree once, then trust it — the same contract the
|
||||
// sibling ts-types renderer follows at a typed same-process seam. Every
|
||||
// node past this point is a validated JSON-schema node, so the walk reads
|
||||
// its fields without re-checking. An unsupported or malformed schema throws
|
||||
// here (before anything is emitted) and degrades to `Any`, the Python
|
||||
// counterpart of the TS flavor's `unknown`.
|
||||
assertSupportedJsonSchema(schema)
|
||||
const frames: Frame[] = [newFrame(schema, className)]
|
||||
let result: string | undefined
|
||||
/* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
|
||||
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
|
||||
const finish = (type: string): void => {
|
||||
frames.pop()
|
||||
const parent = frames.at(-1)
|
||||
if (parent === undefined) result = type
|
||||
else parent.childTypes.push(type)
|
||||
}
|
||||
|
||||
while (frames.length > 0) {
|
||||
const frame = frames.at(-1)
|
||||
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
||||
@@ -298,19 +251,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
/* v8 ignore next -- childIndex is bounded by children.length. */
|
||||
if (child === undefined) throw new Error('missing python render child')
|
||||
frame.childIndex++
|
||||
// A child schema already on the active path is a cycle a post-
|
||||
// validation mutation introduced; degrade it to `Any` rather than
|
||||
// recurse forever. A fresh reference joins the path (finish removes
|
||||
// it); a value with no reference identity carries none to track.
|
||||
if (hasIdentity(child.schema)) {
|
||||
if (activeSchemas.has(child.schema)) {
|
||||
state.typing.add('Any')
|
||||
frame.childTypes.push('Any')
|
||||
continue
|
||||
}
|
||||
activeSchemas.add(child.schema)
|
||||
}
|
||||
frames.push(newFrame(child.schema, child.className, true))
|
||||
frames.push(newFrame(child.schema, child.className))
|
||||
continue
|
||||
}
|
||||
if (frame.kind === 'oneOf') {
|
||||
@@ -319,9 +260,9 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
if (frame.kind === 'array') {
|
||||
// `list[A | B]` needs no parentheses in Python. Array frames always
|
||||
// schedule exactly one child, so its type is present.
|
||||
/* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */
|
||||
// `list[A | B]` needs no parentheses in Python. Array frames always
|
||||
// schedule exactly one child, so its type is present.
|
||||
/* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */
|
||||
finish(`list[${frame.childTypes[0] ?? 'Any'}]`)
|
||||
continue
|
||||
}
|
||||
@@ -366,31 +307,10 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
}
|
||||
|
||||
frame.phase = 'children'
|
||||
// Validate the WHOLE tree once at the root frame (the assertion walks it
|
||||
// with an explicit stack); child frames are inside that validated tree, so
|
||||
// re-asserting them would make a deep schema quadratic.
|
||||
if (!frame.validated) {
|
||||
try {
|
||||
assertSupportedJsonSchema(frame.schema)
|
||||
} catch {
|
||||
state.typing.add('Any')
|
||||
finish('Any')
|
||||
continue
|
||||
}
|
||||
}
|
||||
const node = frame.schema as Record<string, unknown>
|
||||
if (Object.hasOwn(node, 'oneOf')) {
|
||||
// Snapshot the branches with ONE read (a getter can change them
|
||||
// between reads). A re-read that is not a non-empty array would join to
|
||||
// `''` (or drop branches), so degrade to `Any` instead.
|
||||
const branches = node.oneOf
|
||||
if (!Array.isArray(branches) || branches.length === 0) {
|
||||
state.typing.add('Any')
|
||||
finish('Any')
|
||||
continue
|
||||
}
|
||||
frame.kind = 'oneOf'
|
||||
frame.children = (branches as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` }))
|
||||
frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` }))
|
||||
continue
|
||||
}
|
||||
if (!Object.hasOwn(node, 'type')) {
|
||||
@@ -416,13 +336,11 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
// A missing `properties` is an empty property map, exactly as the
|
||||
// unified validator and the TS renderer read it — NOT an unknown
|
||||
// shape. assertSupportedJsonSchema already rejected a non-object
|
||||
// `properties` (degraded to `Any` above), so the only non-map case
|
||||
// left is omission. The openness of the resulting empty object is
|
||||
// decided below, so a closed empty object still declares an empty
|
||||
// TypedDict rather than a permissive `dict[str, Any]`.
|
||||
// A missing `properties` is an empty property map, exactly as the
|
||||
// unified validator and the TS renderer read it — NOT an unknown
|
||||
// shape. The openness of the resulting empty object is decided below,
|
||||
// so a closed empty object still declares an empty TypedDict rather
|
||||
// than a permissive `dict[str, Any]`.
|
||||
const entries = Object.entries((node.properties ?? {}) as Record<string, unknown>)
|
||||
// An empty `className` marks the context-free `jsonSchemaToPy` entry:
|
||||
// there is no naming context to declare into, so degrade. A field
|
||||
@@ -461,25 +379,16 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
}
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- every root frame produces one expression. */
|
||||
return result ?? 'Any'
|
||||
} catch {
|
||||
// Reached by a render-phase throw the root validation could not catch:
|
||||
// either a hostile stateful getter (a `type` that passes validation then
|
||||
// throws on a later read) OR one of this module's own v8-ignored internal
|
||||
// invariant errors (`missing python render child` etc.). Both degrade the
|
||||
// whole node to `Any` — an internal renderer bug thus surfaces as a lost
|
||||
// type rather than a loud crash during prompt assembly, the deliberate
|
||||
// trade for the never-throw contract. Roll back the classes and typing
|
||||
// symbols the discarded subtree added so the import line still lists
|
||||
// exactly the symbols the surviving output uses; `usedClassNames`/counter
|
||||
// retention is harmless (conservative uniqueness).
|
||||
state.classes.length = classFloor
|
||||
state.typing.clear()
|
||||
for (const symbol of typingFloor) state.typing.add(symbol)
|
||||
// An unsupported or malformed schema failed validation (before any
|
||||
// emission), or an unreachable internal invariant tripped. Either degrades
|
||||
// the node to `Any` rather than crashing prompt assembly — the Python
|
||||
// counterpart of the TS flavor's `unknown` fallback.
|
||||
state.typing.add('Any')
|
||||
return 'Any'
|
||||
}
|
||||
/* v8 ignore next -- every root frame produces one expression. */
|
||||
return result ?? 'Any'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,10 +397,10 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
* to `dict[str, Any]`: naming a `TypedDict` requires the render context that
|
||||
* {@link renderToolsSdkPy} supplies), `const`/`enum` (→ `Literal[...]`),
|
||||
* `oneOf` (→ union), `string`/`number`/`integer`/`boolean`/`null`, `array`
|
||||
* (`items` → `list[T]`) — and returns `Any` for anything else, without
|
||||
* throwing. Type annotations in the emitted SDK are advisory: Python does not
|
||||
* enforce them at runtime, matching the TS flavor's advisory-type stance.
|
||||
* @param schema - the JSON-Schema node (any shape; hostile inputs degrade).
|
||||
* (`items` → `list[T]`) — and returns `Any` for an unsupported or malformed
|
||||
* schema, matching the TS flavor's `unknown` fallback. Type annotations in the
|
||||
* emitted SDK are advisory: Python does not enforce them at runtime.
|
||||
* @param schema - the JSON-Schema node.
|
||||
* @returns the Python type text.
|
||||
*/
|
||||
export function jsonSchemaToPy(schema: unknown): string {
|
||||
|
||||
@@ -51,285 +51,6 @@ describe('jsonSchemaToPy', () => {
|
||||
expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any')
|
||||
})
|
||||
|
||||
it('degrades to Any when a stateful getter throws in the render phase after passing validation', () => {
|
||||
// A hostile `type` getter returns a scalar on the validation read, then
|
||||
// throws on the render read. The no-throw contract must still hold across
|
||||
// the whole walk, degrading the node to Any rather than escaping. Assert
|
||||
// the FIRST call's result: within it, root validation reads `type` once
|
||||
// and the render phase reads it again (the throw), so this exercises the
|
||||
// render-phase catch, not the validation-catch path.
|
||||
let reads = 0
|
||||
const schema = {
|
||||
get type() {
|
||||
reads += 1
|
||||
if (reads <= 1) return 'string'
|
||||
throw new Error('stateful getter')
|
||||
},
|
||||
}
|
||||
let first: string | undefined
|
||||
expect(() => { first = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(first).toBe('Any')
|
||||
})
|
||||
|
||||
it('rolls back partial class declarations when a nested render-phase throw degrades a tool', () => {
|
||||
// The throwing field must not leave a half-emitted TypedDict in the output.
|
||||
let reads = 0
|
||||
const hostileField = {
|
||||
get type() {
|
||||
reads += 1
|
||||
if (reads <= 1) return 'string'
|
||||
throw new Error('stateful getter')
|
||||
},
|
||||
}
|
||||
const tool: ToolSdkSchema = {
|
||||
name: 'hostile',
|
||||
description: 'Has a field whose getter throws on the render read.',
|
||||
parameters: { type: 'object', additionalProperties: false, properties: { bad: hostileField as never }, required: ['bad'] },
|
||||
output: { type: 'string' },
|
||||
}
|
||||
const text = renderToolsSdkPy([tool])
|
||||
// The whole args render degrades to Any (a render-phase throw unwinds the
|
||||
// entire renderType call); no partial TypedDict for it is declared.
|
||||
expect(text).toContain('async def hostile(self, args: Any) -> str: ...')
|
||||
expect(text).not.toContain('class HostileArgs(TypedDict):')
|
||||
// The import line lists only symbols the surviving output uses: the
|
||||
// discarded subtree's TypedDict/NotRequired must not leak into it.
|
||||
expect(text).not.toContain('TypedDict')
|
||||
expect(text).toContain('from typing import Any, Protocol')
|
||||
})
|
||||
|
||||
it('keeps class names and total output linear for a deep single-field object chain', () => {
|
||||
// Child class names derive from their parent's; without a cap the sum of
|
||||
// names is Theta(depth^2). Bound it so a deep schema stays linear.
|
||||
const depth = 4000
|
||||
let schema: Record<string, unknown> = { type: 'string' }
|
||||
for (let i = 0; i < depth; i++) {
|
||||
schema = { type: 'object', additionalProperties: false, properties: { inner: schema }, required: ['inner'] }
|
||||
}
|
||||
const tool: ToolSdkSchema = {
|
||||
name: 'deep',
|
||||
description: 'Deeply nested single-field chain.',
|
||||
parameters: schema,
|
||||
output: { type: 'string' },
|
||||
}
|
||||
const text = renderToolsSdkPy([tool])
|
||||
// No emitted class name exceeds the cap plus a short collision suffix, so
|
||||
// total text is O(depth) rather than O(depth^2) (a quadratic 4000-deep
|
||||
// chain would be tens of MB).
|
||||
const longestClassName = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0)
|
||||
expect(longestClassName).toBeLessThanOrEqual(140)
|
||||
expect(text.length).toBeLessThan(depth * 400)
|
||||
})
|
||||
|
||||
it('skips an already-taken counter suffix when a sibling object occupies it', () => {
|
||||
// `phase` and `Phase` both CamelCase to the base `FooArgsPhase`; `phase2`
|
||||
// independently allocates `FooArgsPhase2` first. When `Phase` collides, the
|
||||
// counter's first candidate `FooArgsPhase2` is already taken, so the scan
|
||||
// must advance to `FooArgsPhase3` (exercises the collision-skip loop).
|
||||
const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } })
|
||||
const tool: ToolSdkSchema = {
|
||||
name: 'foo',
|
||||
description: 'Sibling objects with colliding class bases.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') },
|
||||
required: ['phase', 'phase2', 'Phase'],
|
||||
},
|
||||
output: { type: 'string' },
|
||||
}
|
||||
const text = renderToolsSdkPy([tool])
|
||||
expect(text).toContain('class FooArgsPhase(TypedDict):')
|
||||
expect(text).toContain('class FooArgsPhase2(TypedDict):')
|
||||
expect(text).toContain('class FooArgsPhase3(TypedDict):')
|
||||
})
|
||||
|
||||
it('degrades to Any instead of looping when a stateful getter introduces a cycle after validation', () => {
|
||||
// `items` validates as a scalar, then returns the root schema at render
|
||||
// time — a cycle a post-validation mutation introduced. The walk must
|
||||
// degrade to Any rather than push frames forever.
|
||||
let itemReads = 0
|
||||
const root: Record<string, unknown> = { type: 'array' }
|
||||
Object.defineProperty(root, 'items', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
itemReads += 1
|
||||
return itemReads <= 1 ? { type: 'string' } : root
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(root) }).not.toThrow()
|
||||
// list[...] of a self-cycle: the inner cycle degrades to Any.
|
||||
expect(out).toBe('list[Any]')
|
||||
})
|
||||
|
||||
it('degrades to Any when a stateful getter returns a non-object child at render time', () => {
|
||||
// `items` validates as a scalar node, then returns a bare string (a
|
||||
// non-object) at render. The walk must handle a non-object child without
|
||||
// tracking identity and degrade it, not throw.
|
||||
let itemReads = 0
|
||||
const root: Record<string, unknown> = { type: 'array' }
|
||||
Object.defineProperty(root, 'items', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
itemReads += 1
|
||||
return itemReads <= 1 ? { type: 'string' } : 'not-a-schema-object'
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(root) }).not.toThrow()
|
||||
expect(out).toBe('list[Any]')
|
||||
})
|
||||
|
||||
it('degrades to Any when a stateful getter returns a self-referential function as a child', () => {
|
||||
// A function has typeof 'function' yet can carry own props and reference
|
||||
// itself; the cycle guard must track it too, or the walk loops forever.
|
||||
let itemReads = 0
|
||||
const root: Record<string, unknown> = { type: 'array' }
|
||||
const fn = Object.assign(function () {}, {}) as Record<string, unknown> & (() => void)
|
||||
;(fn as Record<string, unknown>).oneOf = [fn]
|
||||
Object.defineProperty(root, 'items', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
itemReads += 1
|
||||
return itemReads <= 1 ? { type: 'string' } : fn
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(root) }).not.toThrow()
|
||||
expect(out).toBe('list[Any]')
|
||||
})
|
||||
|
||||
it('degrades a const that snapshots as a non-scalar to the broad type', () => {
|
||||
// The single snapshot read returns an object (validation read returned a
|
||||
// scalar); the check must degrade rather than spell Literal[[object Object]].
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'const', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 1 ? 'fixed' : {}
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out).toBe('str')
|
||||
expect(out).not.toContain('object Object')
|
||||
})
|
||||
|
||||
it('snapshots const with one read so a third-read switch cannot spell a non-scalar', () => {
|
||||
// A getter returning 'fixed' on the validation AND check reads but an
|
||||
// object on a third read would defeat a separate check-read/spell-read.
|
||||
// The render snapshots once, so it either spells the checked value or
|
||||
// degrades — never Literal[[object Object]].
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'const', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 2 ? 'fixed' : {}
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out === 'str' || out === 'Literal["fixed"]').toBe(true)
|
||||
expect(out).not.toContain('object Object')
|
||||
})
|
||||
|
||||
it('degrades to the broad type when an enum getter re-reads as a non-array', () => {
|
||||
// A validated enum array that re-reads as a non-array must degrade, not
|
||||
// spread a non-iterable or spell a bad literal.
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'enum', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 1 ? ['a'] : 'not-an-array'
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out).toBe('str')
|
||||
})
|
||||
|
||||
it('degrades to the broad type when an enum getter re-reads as an empty array', () => {
|
||||
// A validated non-empty enum that re-reads as [] would spell Literal[] — a
|
||||
// Python SyntaxError that breaks the whole SDK. Require non-empty at render.
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'enum', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 1 ? ['a'] : []
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out).toBe('str')
|
||||
expect(out).not.toContain('Literal[]')
|
||||
})
|
||||
|
||||
it('degrades the broad type when an enum element is an accessor that re-reads as a non-scalar', () => {
|
||||
// `[...raw]` reads each element exactly once; the validation read saw a
|
||||
// scalar, the spread read returns an object. The snapshot's every(isPyScalar)
|
||||
// check must degrade rather than spell Literal[[object Object]].
|
||||
let elemReads = 0
|
||||
const arr: unknown[] = []
|
||||
Object.defineProperty(arr, '0', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get() {
|
||||
elemReads += 1
|
||||
return elemReads <= 1 ? 'a' : {}
|
||||
},
|
||||
})
|
||||
arr.length = 1
|
||||
const schema = { type: 'string', enum: arr }
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out).toBe('str')
|
||||
expect(out).not.toContain('object Object')
|
||||
})
|
||||
|
||||
it('spells a const re-read as null with None, not the JS string "null"', () => {
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'const', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 1 ? 'fixed' : null
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
// Either the checked value spells, or a null re-read spells None — never "null".
|
||||
expect(out === 'Literal["fixed"]' || out === 'Literal[None]').toBe(true)
|
||||
expect(out).not.toContain('Literal[null]')
|
||||
})
|
||||
|
||||
it('degrades a oneOf that re-reads as an empty array to Any, not an empty string', () => {
|
||||
// oneOf validates as two branches, then returns [] at render; a naive join
|
||||
// would produce '' (a missing type). Degrade to Any instead.
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = {}
|
||||
Object.defineProperty(schema, 'oneOf', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 1 ? [{ type: 'string' }, { type: 'number' }] : []
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out).toBe('Any')
|
||||
expect(out).not.toBe('')
|
||||
})
|
||||
|
||||
it('emits exact digits for a beyond-safe-range integer literal', () => {
|
||||
// Python integers are arbitrary-precision, so the emitted digits ARE the
|
||||
// value the model programs against. `String(2 ** 60)` prints the rounded
|
||||
@@ -549,6 +270,43 @@ describe('renderToolsSdkPy', () => {
|
||||
expect(text).toContain('class MyToolArgs2(TypedDict):')
|
||||
})
|
||||
|
||||
it('caps class-name length so a deep single-field chain stays linear', () => {
|
||||
// Child class names derive from their parent's, so without a cap the sum of
|
||||
// names would be Theta(depth^2). MAX_CLASS_NAME_BASE (120) bounds each name.
|
||||
const depth = 4000
|
||||
let schema: Record<string, unknown> = { type: 'string' }
|
||||
for (let i = 0; i < depth; i++) {
|
||||
schema = { type: 'object', additionalProperties: false, properties: { inner: schema }, required: ['inner'] }
|
||||
}
|
||||
const tool: ToolSdkSchema = { name: 'deep', description: 'Deep chain.', parameters: schema, output: { type: 'string' } }
|
||||
const text = renderToolsSdkPy([tool])
|
||||
const longestClassName = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0)
|
||||
expect(longestClassName).toBeLessThanOrEqual(140)
|
||||
expect(text.length).toBeLessThan(depth * 400)
|
||||
})
|
||||
|
||||
it('skips an already-taken counter suffix when a sibling object occupies it', () => {
|
||||
// `phase` and `Phase` both CamelCase to base `FooArgsPhase`; `phase2`
|
||||
// independently takes `FooArgsPhase2`, so `Phase`'s collision scan must
|
||||
// advance to `FooArgsPhase3` (exercises the collision-skip loop).
|
||||
const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } })
|
||||
const tool: ToolSdkSchema = {
|
||||
name: 'foo',
|
||||
description: 'Sibling objects with colliding class bases.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') },
|
||||
required: ['phase', 'phase2', 'Phase'],
|
||||
},
|
||||
output: { type: 'string' },
|
||||
}
|
||||
const text = renderToolsSdkPy([tool])
|
||||
expect(text).toContain('class FooArgsPhase(TypedDict):')
|
||||
expect(text).toContain('class FooArgsPhase2(TypedDict):')
|
||||
expect(text).toContain('class FooArgsPhase3(TypedDict):')
|
||||
})
|
||||
|
||||
it('references the named TypedDict from a reserved/subscript tool too', () => {
|
||||
const tool: ToolSdkSchema = {
|
||||
name: 'class',
|
||||
|
||||
Reference in New Issue
Block a user