-
Notifications
You must be signed in to change notification settings - Fork 0
feat(runtime)!: validate decoded returns against declared types #302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,8 @@ | |
| emittedParamSpecs: Set<string>; | ||
| } | ||
|
|
||
| type ReturnDefinitionNames = ReadonlySet<string>; | ||
|
|
||
| export interface CodeGeneratorOptions { | ||
| /** Reports a generated annotation that cannot be represented by emitted declarations. */ | ||
| onTypeDegrade?: (typeName: string) => void; | ||
|
|
@@ -361,7 +363,7 @@ | |
| ): string { | ||
| const base = `typeof ${valueExpr} === 'object' && ${valueExpr} !== null && !globalThis.Array.isArray(${valueExpr}) && (Object.getPrototypeOf(${valueExpr}) === Object.prototype || Object.getPrototypeOf(${valueExpr}) === null)`; | ||
|
|
||
| const keyCheck = (() => { | ||
| if (options.requiredKwOnlyNames.length > 0) { | ||
| return options.requiredKwOnlyNames | ||
| .map(k => `Object.prototype.hasOwnProperty.call(${valueExpr}, ${JSON.stringify(k)})`) | ||
|
|
@@ -397,11 +399,164 @@ | |
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Preserve Python provenance in generated return validators instead of | ||
| * reconstructing a checker from the final TypeScript spelling. In particular | ||
| * pandas/NumPy values retain their marker contract after Arrow decoding. | ||
| */ | ||
| private returnSchema(type: PythonType, definitions: ReturnDefinitionNames = new Set()): unknown { | ||
| // A bare `-> None` return maps to TS void and validates nothing, but a | ||
| // NESTED None (union member, tuple element) is a real wire value — it | ||
| // decodes to null and must be checked as null, or a union containing it | ||
| // would carry an any-member and accept everything. | ||
| if (type.kind === 'primitive' && type.name === 'None') { | ||
| return { kind: 'any' }; | ||
| } | ||
| const schema = (current: PythonType): unknown => { | ||
| switch (current.kind) { | ||
| case 'primitive': | ||
| return current.name === 'int' || current.name === 'float' | ||
| ? { kind: 'primitive', type: 'number' } | ||
| : current.name === 'str' | ||
| ? { kind: 'primitive', type: 'string' } | ||
| : current.name === 'bool' | ||
| ? { kind: 'primitive', type: 'boolean' } | ||
| : current.name === 'bytes' | ||
| ? { kind: 'primitive', type: 'Uint8Array' } | ||
| : current.name === 'None' | ||
| ? { kind: 'primitive', type: 'null' } | ||
| : { kind: 'any' }; | ||
| case 'literal': | ||
| return { kind: 'literal', value: current.value }; | ||
| case 'annotated': | ||
| return schema(current.base); | ||
| case 'final': | ||
| case 'classvar': | ||
| return schema(current.type); | ||
| case 'optional': | ||
| return { | ||
| kind: 'union', | ||
| options: [schema(current.type), { kind: 'primitive', type: 'null' }], | ||
| }; | ||
| case 'union': | ||
| return { kind: 'union', options: current.types.map(schema) }; | ||
| case 'collection': { | ||
| if (current.name === 'tuple') { | ||
| if (current.itemTypes[1]?.kind === 'custom' && current.itemTypes[1].name === '...') { | ||
| return { | ||
| kind: 'array', | ||
| element: schema(current.itemTypes[0] ?? { kind: 'custom', name: 'Any' }), | ||
| }; | ||
| } | ||
| return { kind: 'tuple', elements: current.itemTypes.map(schema) }; | ||
| } | ||
| if (current.name === 'dict') { | ||
| return { | ||
| kind: 'record', | ||
| values: schema(current.itemTypes[1] ?? { kind: 'custom', name: 'Any' }), | ||
| }; | ||
| } | ||
| return { | ||
| kind: 'array', | ||
| element: schema(current.itemTypes[0] ?? { kind: 'custom', name: 'Any' }), | ||
| }; | ||
| } | ||
| case 'generic': { | ||
| const leaf = current.name.split('.').at(-1) ?? current.name; | ||
| if (leaf === 'NDArray' || leaf === 'ndarray') { | ||
| return { kind: 'marker', marker: 'ndarray' }; | ||
| } | ||
| if ( | ||
| [ | ||
| 'list', | ||
| 'List', | ||
| 'Sequence', | ||
| 'Iterable', | ||
| 'Iterator', | ||
| 'Generator', | ||
| 'set', | ||
| 'frozenset', | ||
| ].includes(leaf) | ||
| ) { | ||
| return { | ||
| kind: 'array', | ||
| element: schema(current.typeArgs[0] ?? { kind: 'custom', name: 'Any' }), | ||
| }; | ||
| } | ||
| if (['dict', 'Dict', 'Mapping', 'MutableMapping'].includes(leaf)) { | ||
| return { | ||
| kind: 'record', | ||
| values: schema(current.typeArgs[1] ?? { kind: 'custom', name: 'Any' }), | ||
| }; | ||
| } | ||
| return definitions.has(leaf) ? { kind: 'ref', name: leaf } : { kind: 'any' }; | ||
| } | ||
| case 'custom': { | ||
| const leaf = current.name.split('.').at(-1) ?? current.name; | ||
| const full = `${current.module ?? ''}.${leaf}`; | ||
| if (leaf === 'None') { | ||
| return { kind: 'primitive', type: 'null' }; | ||
| } | ||
| if (leaf === 'Any' || leaf === 'object' || leaf === 'NoReturn' || leaf === 'Never') { | ||
| return { kind: 'any' }; | ||
| } | ||
| if (leaf === 'DataFrame' && (!current.module || current.module.startsWith('pandas'))) { | ||
| return { kind: 'marker', marker: 'dataframe' }; | ||
| } | ||
| if (leaf === 'Series' && (!current.module || current.module.startsWith('pandas'))) { | ||
| return { kind: 'marker', marker: 'series' }; | ||
| } | ||
| if (leaf === 'ndarray' || leaf === 'NDArray' || full.includes('numpy')) { | ||
| return { kind: 'marker', marker: 'ndarray' }; | ||
| } | ||
| return definitions.has(leaf) ? { kind: 'ref', name: leaf } : { kind: 'any' }; | ||
| } | ||
| case 'typevar': | ||
| case 'paramspec': | ||
| case 'paramspec_args': | ||
| case 'paramspec_kwargs': | ||
| case 'typevartuple': | ||
| case 'unpack': | ||
| case 'callable': | ||
| return { kind: 'any' }; | ||
| } | ||
| }; | ||
| return schema(type); | ||
| } | ||
|
|
||
| private returnDefinitions(module: PythonModule): ReturnDefinitionNames { | ||
| return new Set( | ||
| module.classes | ||
| .filter(cls => cls.kind === 'typed_dict' || cls.decorators.includes('__typed_dict__')) | ||
| .map(cls => cls.name) | ||
| ); | ||
| } | ||
|
|
||
| private emitReturnDefinitions(module: PythonModule): string { | ||
| const definitions = this.returnDefinitions(module); | ||
| const entries = module.classes | ||
| .filter(cls => cls.kind === 'typed_dict' || cls.decorators.includes('__typed_dict__')) | ||
| .map(cls => { | ||
| const fields = Object.fromEntries( | ||
| cls.properties.map(property => [ | ||
| property.name, | ||
| { | ||
| schema: this.returnSchema(property.type, definitions), | ||
| optional: property.optional === true, | ||
| }, | ||
| ]) | ||
| ); | ||
| return [cls.name, { kind: 'record', fields }]; | ||
| }); | ||
| return `const __tywrapReturnDefinitions: Record<string, ReturnSchema> = ${JSON.stringify(Object.fromEntries(entries))};\n\n`; | ||
| } | ||
|
|
||
| generateFunctionWrapper( | ||
| func: PythonFunction, | ||
| moduleName?: string, | ||
| annotatedJSDoc = false, | ||
| localDeclaredNames: Set<string> = new Set() | ||
| localDeclaredNames: Set<string> = new Set(), | ||
| returnDefinitions: ReturnDefinitionNames = new Set() | ||
| ): GeneratedCode { | ||
| const jsdoc = this.generateJsDoc( | ||
| func.docstring, | ||
|
|
@@ -428,7 +583,7 @@ | |
| const tsTypeForValue = (p: (typeof filteredParams)[number]): string => | ||
| this.typeToTsFromPython(p.type, genericContext, 'value'); | ||
|
|
||
| const kwargsType = (() => { | ||
| if (!needsKwargsParam) { | ||
| return ''; | ||
| } | ||
|
|
@@ -489,6 +644,8 @@ | |
| const returnType = this.typeToTsFromPython(func.returnType, genericContext, 'return'); | ||
| const fname = this.escapeIdentifier(func.name); | ||
| const moduleId = moduleName ?? '__main__'; | ||
| const validatorName = `__validate${this.escapeIdentifier(func.name, { preserveCase: true })}Result`; | ||
| const returnValidator = `const ${validatorName} = createReturnValidator(${JSON.stringify(this.returnSchema(func.returnType, returnDefinitions))}, ${JSON.stringify(`${moduleId}.${func.name}`)}, __tywrapReturnDefinitions);\n\n`; | ||
|
|
||
| // Overloads: generate trailing optional parameter drop variants (exclude *args/**kwargs). | ||
| // Why: Python APIs frequently have many optional tail params. TypeScript callers expect | ||
|
|
@@ -507,7 +664,7 @@ | |
| for (let i = requiredPosCount; i <= positionalParams.length; i++) { | ||
| const head = positionalParams.slice(0, i).map(p => renderPositionalParam(p, true)); | ||
| const rest: string[] = []; | ||
| const v = (() => { | ||
| if (!varArgsParam) { | ||
| return null; | ||
| } | ||
|
|
@@ -577,10 +734,8 @@ | |
| const callPreludeLines = emitCallPrelude(callDescriptor, this.callEmitHelpers()); | ||
| const callPrelude = callPreludeLines.length > 0 ? `${callPreludeLines.join('\n')}\n` : ''; | ||
|
|
||
| const ts = `${jsdoc}${overloadDecl}export async function ${fname}${typeParamDecl}(${paramDecl}): Promise<${returnType}> { | ||
| ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.name}', __args${ | ||
| hasKwArgs ? ', __kwargs' : '' | ||
| }); | ||
| const ts = `${jsdoc}${returnValidator}${overloadDecl}export async function ${fname}${typeParamDecl}(${paramDecl}): Promise<${returnType}> { | ||
| ${callPrelude}${guards} return getRuntimeBridge().call<${returnType}>('${moduleId}', '${func.name}', __args, ${hasKwArgs ? '__kwargs' : 'undefined'}, ${validatorName}); | ||
| } | ||
| `; | ||
|
|
||
|
|
@@ -596,7 +751,8 @@ | |
| cls: PythonClass, | ||
| moduleName?: string, | ||
| _annotatedJSDoc = false, | ||
| localDeclaredNames: Set<string> = new Set() | ||
| localDeclaredNames: Set<string> = new Set(), | ||
| returnDefinitions: ReturnDefinitionNames = new Set() | ||
| ): GeneratedCode { | ||
| const moduleDeclaredNames = new Set(localDeclaredNames); | ||
| moduleDeclaredNames.add(cls.name); | ||
|
|
@@ -746,7 +902,7 @@ | |
| return `${pname}${opt}: ${methodTsValueType(p)}`; | ||
| }; | ||
|
|
||
| const kwargsType = (() => { | ||
| if (!needsKwargsParam) { | ||
| return ''; | ||
| } | ||
|
|
@@ -782,11 +938,13 @@ | |
| 'return' | ||
| ); | ||
| const mname = this.escapeIdentifier(method.name); | ||
| const validatorName = `__validate${this.escapeIdentifier(cls.name, { preserveCase: true })}${this.escapeIdentifier(method.name, { preserveCase: true })}Result`; | ||
| const returnValidator = `const ${validatorName} = createReturnValidator(${JSON.stringify(this.returnSchema(method.returnType, returnDefinitions))}, ${JSON.stringify(`${moduleId}.${cls.name}.${method.name}`)}, __tywrapReturnDefinitions);\n\n`; | ||
|
|
||
| const overloads: string[] = []; | ||
| if (needsKwargsParam && requiredKwOnlyNames.length > 0) { | ||
| const firstOptionalIndex = positionalParams.findIndex(p => p.optional); | ||
| const requiredPosCount = | ||
| firstOptionalIndex >= 0 ? firstOptionalIndex : positionalParams.length; | ||
| for (let i = requiredPosCount; i <= positionalParams.length; i++) { | ||
| const head = positionalParams.slice(0, i).map(p => renderPositionalParam(p, true)); | ||
|
|
@@ -829,11 +987,9 @@ | |
| const guardLines = emitArgGuards(callDescriptor); | ||
| const guards = guardLines.length > 0 ? `${guardLines.join('\n')}\n` : ''; | ||
|
|
||
| const callExpr = `getRuntimeBridge().call('${moduleId}', '${cls.name}.${method.name}', __args${ | ||
| needsKwargsParam ? ', __kwargs' : '' | ||
| })`; | ||
| const callExpr = `getRuntimeBridge().call<${returnType}>('${moduleId}', '${cls.name}.${method.name}', __args, ${needsKwargsParam ? '__kwargs' : 'undefined'}, ${validatorName})`; | ||
| methodBodies.push(`${overloadDecl} ${staticPrefix}async ${mname}${methodTypeParamDecl}(${paramsDecl}): Promise<${returnType}> { | ||
| ${callPrelude}${guards} return ${callExpr}; | ||
| ${returnValidator}${callPrelude}${guards} return ${callExpr}; | ||
| }`); | ||
| methodDeclarations.push( | ||
| `${overloadDecl}${overloads.length > 0 ? '' : ` ${staticPrefix}${mname}${methodTypeParamDecl}(${paramsDecl}): Promise<${returnType}>;\n`}` | ||
|
|
@@ -913,10 +1069,26 @@ | |
| ]); | ||
| const functionResults = [...module.functions] | ||
| .sort((a, b) => a.name.localeCompare(b.name)) | ||
| .map(f => this.generateFunctionWrapper(f, module.name, annotatedJSDoc, localDeclaredNames)); | ||
| .map(f => | ||
| this.generateFunctionWrapper( | ||
| f, | ||
| module.name, | ||
| annotatedJSDoc, | ||
| localDeclaredNames, | ||
| this.returnDefinitions(module) | ||
| ) | ||
| ); | ||
| const classResults = [...module.classes] | ||
| .sort((a, b) => a.name.localeCompare(b.name)) | ||
| .map(c => this.generateClassWrapper(c, module.name, annotatedJSDoc, localDeclaredNames)); | ||
| .map(c => | ||
| this.generateClassWrapper( | ||
| c, | ||
| module.name, | ||
| annotatedJSDoc, | ||
| localDeclaredNames, | ||
| this.returnDefinitions(module) | ||
| ) | ||
| ); | ||
|
Comment on lines
1070
to
+1091
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
rg -n '"target"|"lib"' tsconfig.json tsconfig.*.json 2>/dev/null
rg -n '"engines"' package.json
jq '.engines' package.json 2>/dev/nullRepository: bbopen/tywrap Length of output: 339 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the relevant section of src/core/generator.ts with line numbers.
sed -n '1040,1135p' src/core/generator.ts | cat -n
# Check whether .toSorted() is already used anywhere in the repo.
rg -n '\.toSorted\(' .
# Check TypeScript version and compile target/lib context.
jq '{devDependencies,dependencies,engines}' package.json 2>/dev/null || trueRepository: bbopen/tywrap Length of output: 4511 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Read the relevant part of generator.ts around the review comment.
sed -n '1048,1125p' src/core/generator.ts | cat -n
# Find returnDefinitions and emitReturnDefinitions definitions.
rg -n 'returnDefinitions|emitReturnDefinitions' src/core/generator.tsRepository: bbopen/tywrap Length of output: 4915 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the implementation of returnDefinitions and emitReturnDefinitions.
sed -n '1120,1185p' src/core/generator.ts | cat -nRepository: bbopen/tywrap Length of output: 2931 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect returnDefinitions and emitReturnDefinitions implementations.
sed -n '520,550p' src/core/generator.ts | cat -n
# Check whether Array.prototype.toSorted is supported by the current TS lib target from the repo config.
node - <<'JS'
const fs = require('fs');
const tsconfig = JSON.parse(fs.readFileSync('tsconfig.json', 'utf8'));
console.log(JSON.stringify({ target: tsconfig.compilerOptions.target, lib: tsconfig.compilerOptions.lib }, null, 2));
JSRepository: bbopen/tywrap Length of output: 1430 Hoist 🧰 Tools🪛 ESLint[error] 1070-1071: Use module.functions.toSorted() instead of copying and sorting (e18e/prefer-array-to-sorted) [error] 1081-1082: Use module.classes.toSorted() instead of copying and sorting (e18e/prefer-array-to-sorted) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| const typeAliasResults = [...(module.typeAliases ?? [])] | ||
| .sort((a, b) => a.name.localeCompare(b.name)) | ||
| .map(alias => this.generateTypeAlias(alias, module.name, localDeclaredNames)); | ||
|
|
@@ -932,7 +1104,9 @@ | |
| return kind === 'class' && !c.decorators.includes('__typed_dict__'); | ||
| }); | ||
| const needsRuntime = module.functions.length > 0 || hasRuntimeClasses; | ||
| const bridgeDecl = needsRuntime ? `import { getRuntimeBridge } from 'tywrap/runtime';\n\n` : ''; | ||
| const bridgeDecl = needsRuntime | ||
| ? `import { createReturnValidator, getRuntimeBridge, type ReturnSchema } from 'tywrap/runtime';\n\n${this.emitReturnDefinitions(module)}` | ||
| : ''; | ||
|
|
||
| const ts = `${`${header}${bridgeDecl}${functionCodes}\n${classCodes}\n${typeAliasCodes}`.trimEnd()}\n`; | ||
| const declaration = `${`${declarationHeader}${functionResults | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
ndarray/NDArray marker detection is missing the module guard its siblings use.
DataFrame/Serieschecks require the module to be absent or start withpandas(Lines 503-508), but thendarray/NDArraychecks — both in thegenericbranch (Lines 466-468) and thecustombranch (Lines 509-511) — match on bare name alone. A user-defined class simply namedndarray/NDArrayfrom any other module will be tagged{kind:'marker', marker:'ndarray'}, but its decoded value will never carry thedecodedShapeMetadatatag (that's only applied by the numpy-specific codec path), so validation will always fail for an otherwise correctly-typed return. Given this PR turns mismatches into hard failures, this false positive is a real regression risk.🐛 Proposed fix to gate ndarray marker matching on module, consistent with DataFrame/Series
if (leaf === 'NDArray' || leaf === 'ndarray') { - return { kind: 'marker', marker: 'ndarray' }; + if ( + (leaf === 'NDArray' || leaf === 'ndarray') && + (!current.module || current.module.startsWith('numpy')) + ) { + return { kind: 'marker', marker: 'ndarray' }; }📝 Committable suggestion
🤖 Prompt for AI Agents