fix(typert): enforce strict named dictionary index constraints

This commit is contained in:
imccyu committed 2026-10-13 00:53:01 +08:00
1 parent 557e4137bf
commit 2e56caee96
6 files changed
+117 -25

No files matched your search

+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 的所有贡献方,再筛选要生成产物的包,因此包子集保留与完整构建相同的声明合并结果和输出字节。
+48 -12
View File
@@ -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')