From b3f33ba8f5fb63315762a3ef9938b26cd23f0ca6 Mon Sep 17 00:00:00 2001 From: rechedev9 Date: Fri, 27 Mar 2026 21:17:29 +0100 Subject: [PATCH] fix(core): cover optional spec schema properties --- packages/core/src/types/schemas.ts | 13 +- packages/core/test/spec.types.test.ts | 167 ++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index c8f0c9978..cd46bf84a 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -996,6 +996,10 @@ export const PromptArgumentSchema = z.object({ * The name of the argument. */ name: z.string(), + /** + * A human-readable title for the argument. + */ + title: z.optional(z.string()), /** * A human-readable description of the argument. */ @@ -1314,6 +1318,7 @@ export const ToolSchema = z.object({ */ inputSchema: z .object({ + $schema: z.string().optional(), type: z.literal('object'), properties: z.record(z.string(), JSONValueSchema).optional(), required: z.array(z.string()).optional() @@ -1326,6 +1331,7 @@ export const ToolSchema = z.object({ */ outputSchema: z .object({ + $schema: z.string().optional(), type: z.literal('object'), properties: z.record(z.string(), JSONValueSchema).optional(), required: z.array(z.string()).optional() @@ -1863,6 +1869,7 @@ export const ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.ex * Only top-level properties are allowed, without nesting. */ requestedSchema: z.object({ + $schema: z.string().optional(), type: z.literal('object'), properties: z.record(z.string(), PrimitiveSchemaDefinitionSchema), required: z.array(z.string()).optional() @@ -1972,7 +1979,11 @@ export const PromptReferenceSchema = z.object({ /** * The name of the prompt or prompt template */ - name: z.string() + name: z.string(), + /** + * A human-readable title for the prompt. + */ + title: z.string().optional() }); /** diff --git a/packages/core/test/spec.types.test.ts b/packages/core/test/spec.types.test.ts index d7be7cb2f..d3def4c5a 100644 --- a/packages/core/test/spec.types.test.ts +++ b/packages/core/test/spec.types.test.ts @@ -8,8 +8,11 @@ import fs from 'node:fs'; import path from 'node:path'; +import * as ts from 'typescript'; + import type * as SpecTypes from '../src/types/spec.types.js'; import type * as SDKTypes from '../src/types/index.js'; +import * as SchemaExports from '../src/types/schemas.js'; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -778,6 +781,134 @@ function extractExportedTypes(source: string): string[] { return matches.map(m => m[1]!); } +type IntrospectableSchema = { + def?: { + element?: unknown; + innerType?: unknown; + }; + element?: unknown; + shape?: Record; + type?: string; + unwrap?: () => unknown; +}; + +const specProgram = ts.createProgram([SPEC_TYPES_FILE], { + module: ts.ModuleKind.ESNext, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022 +}); +const specSourceFile = specProgram.getSourceFile(SPEC_TYPES_FILE); + +if (!specSourceFile) { + throw new Error(`Unable to load ${SPEC_TYPES_FILE}`); +} + +const specTypeChecker = specProgram.getTypeChecker(); +const specModuleSymbol = specTypeChecker.getSymbolAtLocation(specSourceFile); + +if (!specModuleSymbol) { + throw new Error(`Unable to resolve exports for ${SPEC_TYPES_FILE}`); +} + +const specExportSymbols = new Map(specTypeChecker.getExportsOfModule(specModuleSymbol).map(symbol => [symbol.getName(), symbol])); + +function unwrapOptionalSchema(schema: unknown): unknown { + let current = schema as IntrospectableSchema | undefined; + + while (current?.type === 'optional') { + current = (current.def?.innerType ?? current.unwrap?.() ?? current) as IntrospectableSchema; + } + + return current; +} + +function getSchemaShape(schema: unknown): Record | undefined { + const candidate = unwrapOptionalSchema(schema) as IntrospectableSchema | undefined; + return candidate?.shape && typeof candidate.shape === 'object' ? candidate.shape : undefined; +} + +function getArrayElementSchema(schema: unknown): unknown | undefined { + const candidate = unwrapOptionalSchema(schema) as IntrospectableSchema | undefined; + + if (candidate?.type !== 'array') { + return undefined; + } + + return candidate.element ?? candidate.def?.element; +} + +function getNamedSpecProperties(type: ts.Type): ts.Symbol[] { + return specTypeChecker.getPropertiesOfType(type).filter(symbol => !symbol.getName().startsWith('__')); +} + +function isOptionalSpecProperty(property: ts.Symbol): boolean { + return (property.getFlags() & ts.SymbolFlags.Optional) !== 0; +} + +function getArrayElementType(type: ts.Type): ts.Type | undefined { + const nonNullableType = specTypeChecker.getNonNullableType(type); + + if (specTypeChecker.isArrayType(nonNullableType) || specTypeChecker.isTupleType(nonNullableType)) { + return specTypeChecker.getTypeArguments(nonNullableType as ts.TypeReference)[0]; + } + + if (!specTypeChecker.isArrayLikeType(nonNullableType)) { + return undefined; + } + + return specTypeChecker.getTypeArguments(nonNullableType as ts.TypeReference)[0]; +} + +function collectMissingSchemaProperties(specType: ts.Type, schema: unknown, pathPrefix = '', visited = new Set()): string[] { + const shape = getSchemaShape(schema); + + if (!shape) { + return []; + } + + const nonNullableSpecType = specTypeChecker.getNonNullableType(specType); + const visitKey = `${pathPrefix}:${specTypeChecker.typeToString(nonNullableSpecType)}`; + + if (visited.has(visitKey)) { + return []; + } + + visited.add(visitKey); + + const missing: string[] = []; + + for (const property of getNamedSpecProperties(nonNullableSpecType)) { + const propertyName = property.getName(); + const propertyPath = pathPrefix ? `${pathPrefix}.${propertyName}` : propertyName; + const schemaProperty = shape[propertyName]; + + if (schemaProperty === undefined) { + if (isOptionalSpecProperty(property)) { + missing.push(propertyPath); + } + continue; + } + + const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0] ?? specSourceFile!; + const propertyType = specTypeChecker.getTypeOfSymbolAtLocation(property, propertyDeclaration); + const nestedObjectShape = getSchemaShape(schemaProperty); + + if (nestedObjectShape) { + missing.push(...collectMissingSchemaProperties(propertyType, schemaProperty, propertyPath, visited)); + continue; + } + + const arrayElementSchema = getArrayElementSchema(schemaProperty); + const arrayElementType = getArrayElementType(propertyType); + + if (arrayElementSchema && arrayElementType) { + missing.push(...collectMissingSchemaProperties(arrayElementType, arrayElementSchema, `${propertyPath}[]`, visited)); + } + } + + return missing; +} + describe('Spec Types', () => { const specTypes = extractExportedTypes(fs.readFileSync(SPEC_TYPES_FILE, 'utf8')); const sdkTypes = extractExportedTypes(fs.readFileSync(SDK_TYPES_FILE, 'utf8')); @@ -812,4 +943,40 @@ describe('Spec Types', () => { expect(sdkTypeChecks[type as keyof typeof sdkTypeChecks]).toBeUndefined(); }); }); + + it('should cover named optional spec properties in object schemas', () => { + const checkedTypes: string[] = []; + const missingPropertiesByType: Array<{ typeName: string; missing: string[] }> = []; + + for (const [schemaExportName, schema] of Object.entries(SchemaExports)) { + if (!schemaExportName.endsWith('Schema') || !getSchemaShape(schema)) { + continue; + } + + const typeName = schemaExportName.slice(0, -'Schema'.length); + const specSymbol = specExportSymbols.get(typeName); + + if (!specSymbol) { + continue; + } + + const specType = specTypeChecker.getDeclaredTypeOfSymbol(specSymbol); + + if (getNamedSpecProperties(specType).length === 0) { + continue; + } + + checkedTypes.push(typeName); + + const missing = collectMissingSchemaProperties(specType, schema); + + if (missing.length > 0) { + missingPropertiesByType.push({ typeName, missing }); + } + } + + expect(checkedTypes).toContain('Implementation'); + expect(checkedTypes.length).toBeGreaterThan(50); + expect(missingPropertiesByType).toEqual([]); + }); });