fix(tools): track functions in cycle detection and guard scalar re-reads
Address ds-review-bot v5/v6 review round 7: - The render-walk cycle guard tracked only plain objects; a function has typeof 'function' yet carries own properties and can reference itself, so a post-validation getter returning a self-referential function bypassed the guard and looped forever. A hasIdentity() helper now covers objects AND functions, applied symmetrically at the three sites (root add, finish remove, child check). - renderConstrainedScalar re-reads const/enum at render time; a stateful getter that validated as a scalar could return an object, spelling the invalid Literal[[object Object]]. It now degrades to the broad type when the re-read value is not a scalar (or the enum not an all-scalar array). - The activeSchemas comment notes the out-of-scope boundary: a getter fabricating a fresh node per read never repeats an ancestor and is indistinguishable from a legitimately unbounded-depth schema. Tests cover the function cycle and non-scalar const/enum re-reads; py-types.ts stays at 100% per-file coverage.
This commit is contained in:
@@ -20,6 +20,17 @@ import type { ToolSdkSchema } from './ts-types.ts'
|
|||||||
/** Property names that are valid bare Python identifiers; anything else is subscripted. */
|
/** Property names that are valid bare Python identifiers; anything else is subscripted. */
|
||||||
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
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
|
* Python hard keywords: reserved everywhere, so a tool or field named
|
||||||
* ``class`` or ``lambda`` is legal on the wire but not as an attribute
|
* ``class`` or ``lambda`` is legal on the wire but not as an attribute
|
||||||
@@ -175,6 +186,11 @@ function pyScalar(value: JsonSchemaScalar): string {
|
|||||||
return String(value)
|
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
|
* Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to
|
||||||
* the broad type. Deliberately deviates from PEP 586, which restricts `Literal`
|
* the broad type. Deliberately deviates from PEP 586, which restricts `Literal`
|
||||||
@@ -185,12 +201,18 @@ function pyScalar(value: JsonSchemaScalar): string {
|
|||||||
*/
|
*/
|
||||||
function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string {
|
function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string {
|
||||||
if (Object.hasOwn(node, 'const')) {
|
if (Object.hasOwn(node, 'const')) {
|
||||||
|
// Re-read at render time: a stateful getter validated as a scalar can now
|
||||||
|
// return anything. A non-scalar would spell `Literal[[object Object]]`
|
||||||
|
// (invalid Python), so degrade to the broad type per the contract.
|
||||||
|
if (!isPyScalar(node.const)) return broad
|
||||||
state.typing.add('Literal')
|
state.typing.add('Literal')
|
||||||
return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]`
|
return `Literal[${pyScalar(node.const)}]`
|
||||||
}
|
}
|
||||||
if (Object.hasOwn(node, 'enum')) {
|
if (Object.hasOwn(node, 'enum')) {
|
||||||
|
const raw = node.enum
|
||||||
|
if (!Array.isArray(raw) || !raw.every(isPyScalar)) return broad
|
||||||
state.typing.add('Literal')
|
state.typing.add('Literal')
|
||||||
return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]`
|
return `Literal[${raw.map(pyScalar).join(', ')}]`
|
||||||
}
|
}
|
||||||
return broad
|
return broad
|
||||||
}
|
}
|
||||||
@@ -222,15 +244,20 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
|||||||
const newFrame = (schema: unknown, className: string, validated: boolean): Frame =>
|
const newFrame = (schema: unknown, className: string, validated: boolean): Frame =>
|
||||||
({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated })
|
({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated })
|
||||||
const frames: Frame[] = [newFrame(schema, className, false)]
|
const frames: Frame[] = [newFrame(schema, className, false)]
|
||||||
// Ancestor schemas by object identity — the frame stack IS the DFS path, so
|
// Ancestor schemas by reference identity — the frame stack IS the DFS path,
|
||||||
// this set holds exactly the current node's ancestors. A stateful getter can
|
// so this set holds exactly the current node's ancestors. A stateful getter
|
||||||
// mutate the graph after validation (an `items`/property that validated as a
|
// can mutate the graph after validation (an `items`/property that validated
|
||||||
// scalar but returns an ancestor at render time); without this, the walk
|
// 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
|
// would push frames forever. A repeated ancestor degrades to `Any` per the
|
||||||
// never-throw contract. Distinct nodes in a legitimately deep chain are all
|
// never-throw contract. Distinct nodes in a legitimately deep chain are all
|
||||||
// different objects, so this stays O(1) per push and O(depth) memory.
|
// 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>()
|
const activeSchemas = new Set<object>()
|
||||||
if (typeof schema === 'object' && schema !== null) activeSchemas.add(schema)
|
if (hasIdentity(schema)) activeSchemas.add(schema)
|
||||||
let result: string | undefined
|
let result: string | undefined
|
||||||
// The no-throw contract must hold across the WHOLE walk, not just the root
|
// 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
|
// validation: a hostile stateful getter (a `type` that returns a scalar on
|
||||||
@@ -243,7 +270,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
|||||||
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
|
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
|
||||||
const finish = (type: string): void => {
|
const finish = (type: string): void => {
|
||||||
const popped = frames.pop()
|
const popped = frames.pop()
|
||||||
if (popped !== undefined && typeof popped.schema === 'object' && popped.schema !== null) {
|
if (popped !== undefined && hasIdentity(popped.schema)) {
|
||||||
activeSchemas.delete(popped.schema)
|
activeSchemas.delete(popped.schema)
|
||||||
}
|
}
|
||||||
const parent = frames.at(-1)
|
const parent = frames.at(-1)
|
||||||
@@ -265,9 +292,9 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
|||||||
frame.childIndex++
|
frame.childIndex++
|
||||||
// A child schema already on the active path is a cycle a post-
|
// A child schema already on the active path is a cycle a post-
|
||||||
// validation mutation introduced; degrade it to `Any` rather than
|
// validation mutation introduced; degrade it to `Any` rather than
|
||||||
// recurse forever. A fresh object joins the path (finish removes it);
|
// recurse forever. A fresh reference joins the path (finish removes
|
||||||
// a non-object child carries no identity to track.
|
// it); a value with no reference identity carries none to track.
|
||||||
if (typeof child.schema === 'object' && child.schema !== null) {
|
if (hasIdentity(child.schema)) {
|
||||||
if (activeSchemas.has(child.schema)) {
|
if (activeSchemas.has(child.schema)) {
|
||||||
state.typing.add('Any')
|
state.typing.add('Any')
|
||||||
frame.childTypes.push('Any')
|
frame.childTypes.push('Any')
|
||||||
|
|||||||
@@ -181,6 +181,62 @@ describe('jsonSchemaToPy', () => {
|
|||||||
expect(out).toBe('list[Any]')
|
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 to the broad type when a const getter re-reads as a non-scalar', () => {
|
||||||
|
// `const` validates as a string, then returns an object at render time.
|
||||||
|
// A naive spelling would emit Literal[[object Object]] (invalid Python);
|
||||||
|
// the render must fall back to the broad type instead.
|
||||||
|
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('degrades to the broad type when an enum getter re-reads as a non-scalar array', () => {
|
||||||
|
// `enum` validates as scalars, then returns an array containing an object
|
||||||
|
// at render time; the render must fall back to the broad type.
|
||||||
|
let reads = 0
|
||||||
|
const schema: Record<string, unknown> = { type: 'string' }
|
||||||
|
Object.defineProperty(schema, 'enum', {
|
||||||
|
enumerable: true,
|
||||||
|
get() {
|
||||||
|
reads += 1
|
||||||
|
return reads <= 1 ? ['a', 'b'] : [{}]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let out: string | undefined
|
||||||
|
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||||
|
expect(out).toBe('str')
|
||||||
|
expect(out).not.toContain('object Object')
|
||||||
|
})
|
||||||
|
|
||||||
it('emits exact digits for a beyond-safe-range integer literal', () => {
|
it('emits exact digits for a beyond-safe-range integer literal', () => {
|
||||||
// Python integers are arbitrary-precision, so the emitted digits ARE the
|
// Python integers are arbitrary-precision, so the emitted digits ARE the
|
||||||
// value the model programs against. `String(2 ** 60)` prints the rounded
|
// value the model programs against. `String(2 ** 60)` prints the rounded
|
||||||
|
|||||||
Reference in New Issue
Block a user