fix(typert): enforce strict named dictionary index constraints
This commit is contained in:
1 parent
557e4137bf
commit
2e56caee96
6 files changed
+117
-25
No files matched your search
@@ -54,7 +54,7 @@ Generated declarations use type-only imports for owner and schema types. Importi
|
||||
|
||||
Missing JSDoc and unsupported tool JSON Schema do not block otherwise valid ordinary generation or its strict codecs. Metadata retains parsers and available documentation independently of projection: each unsupported input variant or output projection omits `jsonSchema` and records its own `jsonSchemaError`. A tool consumer explicitly selects methods and requires its documentation and supported input schema; an output parser is required, but output JSON Schema is optional unless that consumer requires it. Metadata alone does not make every Remote method a tool.
|
||||
|
||||
Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values still validate. `result` keeps its single normal codec. Tool projection targets the tool-schema subset that [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) enforces on tool parameters, tool output, and the generated PTC SDK; it supports scalars, objects, arrays, and disjoint unions, while canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. A type outside that subset is reported instead of projected more weakly. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation.
|
||||
Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values also validate named fields against their index value type. Strict parsing closes the combined nested fields of a named dictionary value, not each constraint separately. `result` keeps its single normal codec. Tool projection targets the tool-schema subset that [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) enforces on tool parameters, tool output, and the generated PTC SDK; it supports scalars, objects, arrays, and disjoint unions, while canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. A type outside that subset is reported instead of projected more weakly. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation.
|
||||
|
||||
`WorkspaceTypertGenerator` analyzes all contributors of each selected face before filtering emitted packages, so a package subset retains the same declaration merges and output bytes as a complete build.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ factory 返回类实例,并保留 spec,直到 `descriptors` 或 `methods` ge
|
||||
|
||||
缺少 JSDoc 或不支持工具 JSON Schema,不会阻止其他方面有效的普通生成及其严格 codec。元数据独立于投影保留解析器与可用文档:每个不受支持的输入变体或输出投影都省略 `jsonSchema`,并记录各自的 `jsonSchemaError`。工具消费方显式选择方法,并要求其所需文档和受支持的输入 schema;输出解析器是必需的,但输出 JSON Schema 是可选的,除非该消费方要求提供。仅有元数据不会让每个 Remote 方法自动成为工具。
|
||||
|
||||
每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。`result` 保留唯一的普通 codec。工具投影的目标是 [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) 对工具参数、工具输出和生成的 PTC SDK 强制执行的 tool-schema 子集;它支持标量、对象、数组和互斥联合类型,规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。子集之外的类型会被报告,而不是以更弱的 schema 投影。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。
|
||||
每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,具名字典字段也须满足索引值类型的约束。严格解析会合并具名字典值的嵌套字段后再关闭对象,而不是分别关闭每项约束。`result` 保留唯一的普通 codec。工具投影的目标是 [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) 对工具参数、工具输出和生成的 PTC SDK 强制执行的 tool-schema 子集;它支持标量、对象、数组和互斥联合类型,规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。子集之外的类型会被报告,而不是以更弱的 schema 投影。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。
|
||||
|
||||
`WorkspaceTypertGenerator` 先分析每个选中 face 的所有贡献方,再筛选要生成产物的包,因此包子集保留与完整构建相同的声明合并结果和输出字节。
|
||||
|
||||
|
||||
@@ -937,9 +937,10 @@ class SchemaEmitter {
|
||||
subject: string,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const properties: string[] = []
|
||||
const properties: Extract<MemberModel, { kind: 'property' }>[] = []
|
||||
const indices: string[] = []
|
||||
let stringIndexValue: string | undefined
|
||||
let stringIndexType: TypeNodeId | undefined
|
||||
let symbolMembers = 0
|
||||
for (const member of members) {
|
||||
if (member.static || member.visibility !== 'public') continue
|
||||
@@ -957,32 +958,67 @@ class SchemaEmitter {
|
||||
}
|
||||
const key = this.typeSchema(parameter.type, substitutions)
|
||||
const value = this.typeSchema(member.signature.returns, substitutions)
|
||||
if (key === 'z.string()') stringIndexValue = value
|
||||
if (key === 'z.string()') {
|
||||
stringIndexValue = value
|
||||
stringIndexType = member.signature.returns
|
||||
}
|
||||
indices.push(this.readonly(`z.record(${key}, ${value})`, member.readonly))
|
||||
continue
|
||||
}
|
||||
if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`)
|
||||
// Freezing a union may freeze its populated Uint8Array member.
|
||||
const schema = this.readonly(this.typeSchema(member.type, substitutions),
|
||||
member.readonly && !containsBytes(this.renderer, member.type, false))
|
||||
const property = this.describe(
|
||||
this.optional(schema, member.optional),
|
||||
member,
|
||||
)
|
||||
properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`)
|
||||
properties.push(member)
|
||||
}
|
||||
if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature')
|
||||
// A unique-symbol-only object is a compile-time marker and imposes no JSON shape.
|
||||
if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()'
|
||||
const object = `z.${this.options.closedObjects && indices.length === 0 ? 'strictObject' : 'object'}({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})`
|
||||
const fields = properties.map((member) => {
|
||||
// Freezing a union may freeze its populated Uint8Array member.
|
||||
const type = this.options.closedObjects && stringIndexType !== undefined
|
||||
? this.indexedPropertySchema(member.type, stringIndexType, substitutions)
|
||||
: this.typeSchema(member.type, substitutions)
|
||||
const schema = this.readonly(type,
|
||||
member.readonly && !containsBytes(this.renderer, member.type, false))
|
||||
return `${quote(member.jsonName ?? member.name)}: ${this.describe(this.optional(schema, member.optional), member)}`
|
||||
})
|
||||
const object = `z.${this.options.closedObjects && indices.length === 0 ? 'strictObject' : 'object'}({${fields.length === 0 ? '' : `\n${fields.map(field => ` ${field},`).join('\n')}\n`}})`
|
||||
const index = indices[0]
|
||||
if (index === undefined) return object
|
||||
if (properties.length === 0) return index
|
||||
// Declared properties can narrow index values; only additional keys use the index schema.
|
||||
// Named keys enforce both their declared type and the index value; the catchall checks other keys.
|
||||
if (this.options.closedObjects && stringIndexValue !== undefined) return `${object}.catchall(${stringIndexValue})`
|
||||
return `z.intersection(${object}, ${index})`
|
||||
}
|
||||
|
||||
private indexedPropertySchema(
|
||||
declared: TypeNodeId,
|
||||
indexed: TypeNodeId,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
if (declared === indexed) return this.typeSchema(declared, substitutions)
|
||||
const own = this.renderer.node(declared)
|
||||
const value = this.renderer.node(indexed)
|
||||
const ownProperties = own.kind === 'object'
|
||||
? own.members.filter((member): member is Extract<MemberModel, { kind: 'property' }> => member.kind === 'property') : []
|
||||
const valueProperties = value.kind === 'object'
|
||||
? value.members.filter((member): member is Extract<MemberModel, { kind: 'property' }> => member.kind === 'property') : []
|
||||
if (own.kind === 'object' && value.kind === 'object' && !own.json && !value.json
|
||||
&& ownProperties.length === own.members.length && valueProperties.length === value.members.length
|
||||
&& [...ownProperties, ...valueProperties].every(member => !member.static
|
||||
&& member.visibility === 'public' && member.computed === undefined)) {
|
||||
const indexedFields = new Map(valueProperties.map(member => [member.jsonName ?? member.name, member]))
|
||||
const overlaps = ownProperties.flatMap((member) => {
|
||||
const inherited = indexedFields.get(member.jsonName ?? member.name)
|
||||
if (inherited === undefined) return []
|
||||
const combined = this.indexedPropertySchema(member.type, inherited.type, substitutions)
|
||||
const schema = this.readonly(this.optional(combined, member.optional && inherited.optional),
|
||||
(member.readonly || inherited.readonly) && !containsBytes(this.renderer, member.type, false))
|
||||
return [`${quote(member.jsonName ?? member.name)}: ${this.describe(schema, member)}`]
|
||||
})
|
||||
return `z.strictObject({ ...${this.typeSchema(indexed, substitutions)}.shape, ...${this.typeSchema(declared, substitutions)}.shape${overlaps.length === 0 ? '' : `, ${overlaps.join(', ')}`} })`
|
||||
}
|
||||
return `z.intersection(${this.typeSchema(declared, substitutions)}, ${this.typeSchema(indexed, substitutions)})`
|
||||
}
|
||||
|
||||
private exportSchemaName(model: SchemaModel): string {
|
||||
const name = this.schemaName(model.symbol)
|
||||
const declaration = this.renderer.declaration(model.symbol)
|
||||
|
||||
@@ -737,6 +737,73 @@ void ready
|
||||
expect(() => method.input({ strict: true }).create().parse(withExtraValue)).toThrow()
|
||||
})
|
||||
|
||||
it('enforces index values on named fields without rejecting their merged nested fields', async () => {
|
||||
const root = fixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
|
||||
'readonly title: string\n readonly payload: Record<string, { readonly base: string }> & { readonly fixed: { readonly own: string } }'))
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
const generated = await executable(artifact!.remote!.js)
|
||||
const method = generated.TYPERT_REMOTE.methods['goals/create']!
|
||||
const valid = { agentId: 'agent', request: { title: 'ship', payload: {
|
||||
fixed: { base: 'common', own: 'specific' }, extra: { base: 'value' },
|
||||
} } }
|
||||
const strict = method.input({ strict: true })
|
||||
expect(strict.jsonSchema).toBeUndefined()
|
||||
expect(strict.jsonSchemaError).toContain('selected dictionaries')
|
||||
for (const view of [method.input(), strict]) {
|
||||
expect(view.create().parse(valid)).toEqual(valid)
|
||||
expect(() => view.create().parse({ ...valid, request: { ...valid.request, payload: {
|
||||
...valid.request.payload, fixed: { own: 'specific' },
|
||||
} } })).toThrow()
|
||||
}
|
||||
expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: {
|
||||
...valid.request.payload, fixed: { base: 'common' },
|
||||
} } })).toThrow()
|
||||
expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: {
|
||||
...valid.request.payload, fixed: { ...valid.request.payload.fixed, unexpected: true },
|
||||
} } })).toThrow()
|
||||
expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: {
|
||||
...valid.request.payload, extra: { base: 'value', unexpected: true },
|
||||
} } })).toThrow()
|
||||
expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: {
|
||||
...valid.request.payload, extra: { own: 'missing base' },
|
||||
} } })).toThrow()
|
||||
const outer = { ...valid, request: { ...valid.request, payload: { ...valid.request.payload, extraOuter: true } } }
|
||||
expect(() => strict.create().parse(outer)).toThrow()
|
||||
})
|
||||
|
||||
it('merges overlapping indexed fields before closing their nested objects', async () => {
|
||||
const root = fixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
|
||||
'readonly title: string\n readonly payload: Record<string, { readonly base: string; readonly nested: { readonly left: string } }> & { readonly fixed: { readonly own: string; readonly nested: { readonly right: string } } }'))
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
const generated = await executable(artifact!.remote!.js)
|
||||
const strict = generated.TYPERT_REMOTE.methods['goals/create']!.input({ strict: true })
|
||||
const fixed = { base: 'common', own: 'specific', nested: { left: 'index', right: 'declared' } }
|
||||
const input = { agentId: 'agent', request: { title: 'ship', payload: { fixed } } }
|
||||
expect(strict.create().parse(input)).toEqual(input)
|
||||
for (const nested of [{ right: 'declared' }, { left: 'index' }, { ...fixed.nested, extra: true }]) {
|
||||
expect(() => strict.create().parse({ ...input, request: { ...input.request, payload: {
|
||||
fixed: { ...fixed, nested },
|
||||
} } })).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a shared indexed value type valid for a named field', async () => {
|
||||
const root = fixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => `${source}\ntype Entry = { readonly base: string }\n`)
|
||||
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
|
||||
'readonly title: string\n readonly payload: Record<string, Entry> & { readonly fixed: Entry }'))
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
const generated = await executable(artifact!.remote!.js)
|
||||
const strict = generated.TYPERT_REMOTE.methods['goals/create']!.input({ strict: true })
|
||||
const input = { agentId: 'agent', request: { title: 'ship', payload: { fixed: { base: 'common' } } } }
|
||||
expect(strict.create().parse(input)).toEqual(input)
|
||||
expect(() => strict.create().parse({ ...input, request: { ...input.request, payload: {
|
||||
fixed: { base: 'common', extra: true },
|
||||
} } })).toThrow()
|
||||
})
|
||||
|
||||
it('keeps normal and closed recursive helper identifiers distinct', async () => {
|
||||
const root = fixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => `${source}\nexport interface Branch { readonly value: string; readonly next?: Branch }\nexport interface closed$Branch { readonly value: string; readonly next?: closed$Branch }\n`)
|
||||
|
||||
@@ -553,13 +553,6 @@ export type {`,
|
||||
expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message))
|
||||
})
|
||||
|
||||
it.each(['committed', 'emitSourceDef', 'rejectUnknownFields'])('rejects the removed %s option', (option) => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => source.replace(' @Remote\n', ` @Remote({ ${option}: true })\n`))
|
||||
expect(() => analyzeRemote(root, false))
|
||||
.toThrow('Remote() options require name or mode: "stream"')
|
||||
})
|
||||
|
||||
it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => `${source}
|
||||
|
||||
@@ -310,10 +310,6 @@ describe('typert-protocol Remote declarations', () => {
|
||||
const bogusMode: string = 'duplex'
|
||||
expect(() => Remote({ mode: bogusMode } as { mode: 'stream' }))
|
||||
.toThrow('Remote options require name or mode: "stream"')
|
||||
expect(() => Remote({ rejectUnknownFields: false } as never)).toThrow('Remote options require')
|
||||
expect(() => Remote({ strict: true } as never)).toThrow('Remote options require')
|
||||
expect(() => Remote({ committed: true } as never)).toThrow('Remote options require')
|
||||
expect(() => Remote({ rejectUnknownFields: true } as never)).toThrow('Remote options require')
|
||||
expect(() => Remote({})).toThrow('Remote options require')
|
||||
expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key')
|
||||
expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name')
|
||||
|
||||
Reference in new issue
Block a user