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:
Chinesezjc
2026-08-02 15:35:19 +08:00
parent 96a2e38fa3
commit 51189a650c
2 changed files with 95 additions and 12 deletions

View File

@@ -181,6 +181,62 @@ describe('jsonSchemaToPy', () => {
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', () => {
// Python integers are arbitrary-precision, so the emitted digits ARE the
// value the model programs against. `String(2 ** 60)` prints the rounded