fix(tools): detect render-phase cycles and fix class-name JSDoc placement

Address ds-review-bot v5/v6 review round 6:
- renderType tracks the active ancestor schemas by object identity (the frame
  stack is the DFS path). A stateful getter can mutate the graph after
  validation so a child returns an ancestor at render time; without this the
  walk pushed frames forever instead of degrading. A repeated ancestor now
  degrades to Any, honoring the never-throw contract; distinct nodes in a
  legitimately deep chain are different objects, so it stays O(1) per push and
  O(depth) memory.
- The multiline allocateClassName JSDoc was still attached to the
  MAX_CLASS_NAME_BASE constant (a self-referential @link, and the function had
  no doc). Move the doc onto the function and give the constant its own
  one-liner.
- Tests cover the post-validation cycle and a non-object render-time child;
  py-types.ts stays at 100% per-file coverage.
This commit is contained in:
Chinesezjc
2026-08-02 15:24:10 +08:00
parent 55224387d9
commit 96a2e38fa3
2 changed files with 65 additions and 2 deletions

View File

@@ -144,6 +144,43 @@ describe('jsonSchemaToPy', () => {
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('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