fix(tools): snapshot const/enum/oneOf reads to close stateful-getter TOCTOU

Address ds-review-bot v5/v6 review round 8. The prior guards re-read a
stateful getter's value between the check and the spelling, so a getter
returning different values across reads could still emit invalid Python:
- renderConstrainedScalar reads node.const ONCE into a local, then checks and
  spells that snapshot; a third-read switch can no longer produce
  Literal[[object Object]].
- The enum path snapshots via [...raw] (reading each element exactly once,
  covering accessor-property elements) and requires the snapshot be a non-empty
  all-scalar array; an emptied re-read no longer spells Literal[], and a
  non-array re-read degrades.
- The oneOf branch build guards a non-array or empty re-read to Any instead of
  joining to '' (a missing type).
- pyScalar spells null as None; its JSDoc no longer claims null cannot reach it.
Tests cover each re-read shape; py-types.ts stays at 100% coverage.
This commit is contained in:
Chinesezjc
2026-08-02 15:51:23 +08:00
parent 51189a650c
commit 7518a5cb65
2 changed files with 130 additions and 20 deletions

View File

@@ -200,10 +200,9 @@ describe('jsonSchemaToPy', () => {
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.
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', {
@@ -219,24 +218,118 @@ describe('jsonSchemaToPy', () => {
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.
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', 'b'] : [{}]
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