Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ async function example() {
example().catch(console.error);
```

## Runtime return validation

Generated wrappers validate returns at runtime after decoding. If a Python
implementation returns a value that does not match its annotation, tywrap throws
`BridgeValidationError` with the wrapped call site and received shape. Fix the
Python annotation or implementation; use `-> Any` only when that return is
intentionally untyped.

## Custom Module Example

Create a wrapper for a custom Python module:
Expand Down
8 changes: 8 additions & 0 deletions docs/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@ async function example() {
example().catch(console.error);
```

## Runtime return validation

Generated wrappers validate returns at runtime after decoding. If a Python
implementation returns a value that does not match its annotation, tywrap throws
`BridgeValidationError` with the wrapped call site and received shape. Fix the
Python annotation or implementation; use `-> Any` only when that return is
intentionally untyped.

## Custom Module Example

Create a wrapper for a custom Python module:
Expand Down
200 changes: 187 additions & 13 deletions src/core/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = (() => {

Check warning on line 366 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (options.requiredKwOnlyNames.length > 0) {
return options.requiredKwOnlyNames
.map(k => `Object.prototype.hasOwnProperty.call(${valueExpr}, ${JSON.stringify(k)})`)
Expand Down Expand Up @@ -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);
}
Comment on lines +402 to +525

Copy link
Copy Markdown

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/Series checks require the module to be absent or start with pandas (Lines 503-508), but the ndarray/NDArray checks — both in the generic branch (Lines 466-468) and the custom branch (Lines 509-511) — match on bare name alone. A user-defined class simply named ndarray/NDArray from any other module will be tagged {kind:'marker', marker:'ndarray'}, but its decoded value will never carry the decodedShapeMetadata tag (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' };
           }
-          if (leaf === 'ndarray' || leaf === 'NDArray' || full.includes('numpy')) {
+          if (
+            full.includes('numpy') ||
+            ((leaf === 'ndarray' || leaf === 'NDArray') &&
+              (!current.module || current.module.startsWith('numpy')))
+          ) {
             return { kind: 'marker', marker: 'ndarray' };
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 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);
}
/**
* 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') {
if (
(leaf === 'NDArray' || leaf === 'ndarray') &&
(!current.module || current.module.startsWith('numpy'))
) {
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 (
full.includes('numpy') ||
((leaf === 'ndarray' || leaf === 'NDArray') &&
(!current.module || current.module.startsWith('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);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/generator.ts` around lines 402 - 525, Update ndarray/NDArray marker
detection in returnSchema’s generic and custom branches to require an absent
module or a NumPy-qualified module, matching the module-guard pattern used for
DataFrame and Series. Preserve marker detection for genuine NumPy types while
allowing same-named classes from unrelated modules to follow normal definition
or any-schema handling.


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,
Expand All @@ -428,7 +583,7 @@
const tsTypeForValue = (p: (typeof filteredParams)[number]): string =>
this.typeToTsFromPython(p.type, genericContext, 'value');

const kwargsType = (() => {

Check warning on line 586 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (!needsKwargsParam) {
return '';
}
Expand Down Expand Up @@ -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
Expand All @@ -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 = (() => {

Check warning on line 667 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (!varArgsParam) {
return null;
}
Expand Down Expand Up @@ -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});
}
`;

Expand All @@ -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);
Expand Down Expand Up @@ -746,7 +902,7 @@
return `${pname}${opt}: ${methodTsValueType(p)}`;
};

const kwargsType = (() => {

Check warning on line 905 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (!needsKwargsParam) {
return '';
}
Expand Down Expand Up @@ -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 =

Check warning on line 947 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

'requiredPosCount' is already declared in the upper scope on line 892 column 15
firstOptionalIndex >= 0 ? firstOptionalIndex : positionalParams.length;
for (let i = requiredPosCount; i <= positionalParams.length; i++) {
const head = positionalParams.slice(0, i).map(p => renderPositionalParam(p, true));
Expand Down Expand Up @@ -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`}`
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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/null

Repository: 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 || true

Repository: 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.ts

Repository: 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 -n

Repository: 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));
JS

Repository: bbopen/tywrap

Length of output: 1430


Hoist returnDefinitions(module) once per module
returnDefinitions(module) is rebuilt for every function/class wrapper and again in emitReturnDefinitions; cache the set once and pass it through to all three call sites. The .toSorted() swap isn’t applicable here because the project still targets ES2022.

🧰 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 Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/generator.ts` around lines 1070 - 1091, Cache the result of
returnDefinitions(module) once in the surrounding module-generation flow, then
reuse that cached value in the generateFunctionWrapper and generateClassWrapper
mappings and the emitReturnDefinitions call. Keep the existing mutable-copy sort
approach unchanged because ES2022 targeting does not support toSorted().

Source: Linters/SAST tools

const typeAliasResults = [...(module.typeAliases ?? [])]
.sort((a, b) => a.name.localeCompare(b.name))
.map(alias => this.generateTypeAlias(alias, module.name, localDeclaredNames));
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
BridgeError,
BridgeCodecError,
BridgeProtocolError,
BridgeValidationError,
BridgeTimeoutError,
BridgeDisposedError,
BridgeExecutionError,
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/base-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ export abstract class BasePythonBridge extends DisposableBase implements PythonR
module: string,
functionName: string,
args: unknown[],
kwargs?: Record<string, unknown>
kwargs?: Record<string, unknown>,
validate?: (result: T) => void
): Promise<T> {
await this.ensureReady();
return this.getRpcClient().call<T>(module, functionName, args, kwargs);
return this.getRpcClient().call<T>(module, functionName, args, kwargs, validate);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/bridge-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,9 @@ export class BridgeCodec {
// Note: Arrow decoders can introduce NaN/Infinity from binary representations.
this.assertNoSpecialFloats(decoded);

// Return-contract validation is intentionally downstream of this codec. At
// this boundary we guarantee only a sound wire envelope and decoded value;
// generated wrappers validate the Python annotation after Arrow/JSON decode.
return decoded as T;
}

Expand Down
21 changes: 21 additions & 0 deletions src/runtime/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ export class BridgeProtocolError extends BridgeError {}
export class BridgeTimeoutError extends BridgeError {}
export class BridgeDisposedError extends BridgeError {}

/**
* A decoded value did not match the return annotation emitted into a wrapper.
*
* This is deliberately separate from protocol/codec errors: the wire response
* was sound, but the Python implementation violated its declared contract.
*/
export class BridgeValidationError extends BridgeError {
readonly declaredType: string;
readonly receivedShape: string;
readonly callSite: string;

constructor(options: { declaredType: string; receivedShape: string; callSite: string }) {
super(
`Return validation failed for ${options.callSite}: expected ${options.declaredType}, received ${options.receivedShape}`
);
this.declaredType = options.declaredType;
this.receivedShape = options.receivedShape;
this.callSite = options.callSite;
}
}

export class BridgeCodecError extends BridgeError {
codecPhase?: string;
valueType?: string;
Expand Down
6 changes: 6 additions & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import type { RuntimeExecution } from '../types/index.js';

// BridgeCodec — validation and serialization for the JS<->Python boundary
export { BridgeCodec, type CodecOptions } from './bridge-codec.js';
export {
createReturnValidator,
describeReceivedShape,
type ReturnSchema,
type ReturnValidator,
} from './validators.js';

// Transport contract — abstract I/O channel interface and guards
export type {
Expand Down
Loading
Loading