Skip to content

Commit 87a9967

Browse files
authored
actually fix name collisions in derive closures (commontoolsinc#2044)
* actually fix name collisions in derive closures * fix shorthand property assignments; also remove $schema everywhere * fix: update test expectations after rebase - Remove $schema from all test fixtures (main already had this change) - Update asOpaque to asCell for cell types (API change in main) - Fix derive-nested-callback to show proper array schema (typeRegistry fix working correctly)
1 parent 2e128ce commit 87a9967

File tree

101 files changed

+391
-198
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

101 files changed

+391
-198
lines changed

packages/schema-generator/src/schema-generator.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,6 @@ export class SchemaGenerator implements ISchemaGenerator {
400400
// final output
401401
const filtered = this.collectReferencedDefinitions(base, definitions);
402402
const out: Record<string, unknown> = {
403-
$schema: "https://json-schema.org/draft/2020-12/schema",
404403
...(base as Record<string, unknown>),
405404
};
406405
if (Object.keys(filtered).length > 0) out.$defs = filtered;
@@ -701,18 +700,14 @@ export class SchemaGenerator implements ISchemaGenerator {
701700
return rootSchema;
702701
}
703702

704-
// If no definitions were created or used, return simple schema with $schema
703+
// If no definitions were created or used, return simple schema
705704
if (Object.keys(definitions).length === 0 || emittedRefs.size === 0) {
706-
return {
707-
$schema: "https://json-schema.org/draft/2020-12/schema",
708-
...(rootSchema as Record<string, unknown>),
709-
} as SchemaDefinition;
705+
return rootSchema;
710706
}
711707

712708
// Object schema: attach only the definitions actually referenced
713709
const filtered = this.collectReferencedDefinitions(rootSchema, definitions);
714710
const out: Record<string, unknown> = {
715-
$schema: "https://json-schema.org/draft/2020-12/schema",
716711
...(rootSchema as Record<string, unknown>),
717712
};
718713
if (Object.keys(filtered).length > 0) out.$defs = filtered;

packages/ts-transformers/src/closures/transformer.ts

Lines changed: 180 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,6 +1441,39 @@ function extractDeriveCallback(
14411441
return undefined;
14421442
}
14431443

1444+
/**
1445+
* Resolve capture name collisions with the original input parameter name.
1446+
* If a capture has the same name as originalInputParamName, rename it (e.g., multiplier -> multiplier_1).
1447+
* Returns a mapping from original capture names to their potentially renamed versions.
1448+
*/
1449+
function resolveDeriveCaptureNameCollisions(
1450+
originalInputParamName: string,
1451+
captureTree: Map<string, CaptureTreeNode>,
1452+
): Map<string, string> {
1453+
const captureNameMap = new Map<string, string>();
1454+
const usedNames = new Set<string>([originalInputParamName]);
1455+
1456+
for (const [captureName] of captureTree) {
1457+
if (captureName === originalInputParamName) {
1458+
// Collision detected - rename the capture
1459+
let renamed = `${captureName}_1`;
1460+
let suffix = 1;
1461+
while (usedNames.has(renamed) || captureTree.has(renamed)) {
1462+
suffix++;
1463+
renamed = `${captureName}_${suffix}`;
1464+
}
1465+
captureNameMap.set(captureName, renamed);
1466+
usedNames.add(renamed);
1467+
} else {
1468+
// No collision - use original name
1469+
captureNameMap.set(captureName, captureName);
1470+
usedNames.add(captureName);
1471+
}
1472+
}
1473+
1474+
return captureNameMap;
1475+
}
1476+
14441477
/**
14451478
* Build the merged input object containing both the original input and captures.
14461479
* Example: {value, multiplier} where value is the original input and multiplier is a capture.
@@ -1452,6 +1485,7 @@ function buildDeriveInputObject(
14521485
originalInput: ts.Expression,
14531486
originalInputParamName: string,
14541487
captureTree: Map<string, CaptureTreeNode>,
1488+
captureNameMap: Map<string, string>,
14551489
factory: ts.NodeFactory,
14561490
hadZeroParameters: boolean,
14571491
): ts.ObjectLiteralExpression {
@@ -1478,15 +1512,90 @@ function buildDeriveInputObject(
14781512
}
14791513
}
14801514

1481-
// Add captures
1482-
properties.push(...buildCapturePropertyAssignments(captureTree, factory));
1515+
// Add captures with potentially renamed property names
1516+
for (const [originalName, node] of captureTree) {
1517+
const propertyName = captureNameMap.get(originalName) ?? originalName;
1518+
properties.push(
1519+
factory.createPropertyAssignment(
1520+
createPropertyName(propertyName, factory),
1521+
buildHierarchicalParamsValue(node, originalName, factory),
1522+
),
1523+
);
1524+
}
14831525

14841526
return factory.createObjectLiteralExpression(
14851527
properties,
14861528
properties.length > 1,
14871529
);
14881530
}
14891531

1532+
/**
1533+
* Rewrite the callback body to use renamed capture identifiers.
1534+
* For example, if `multiplier` was renamed to `multiplier_1`, replace all
1535+
* references to the captured `multiplier` with `multiplier_1`.
1536+
*/
1537+
function rewriteCaptureReferences(
1538+
body: ts.ConciseBody,
1539+
captureNameMap: Map<string, string>,
1540+
factory: ts.NodeFactory,
1541+
): ts.ConciseBody {
1542+
// Build a reverse map: original capture name -> list of renamed names that should be substituted
1543+
const substitutions = new Map<string, string>();
1544+
for (const [originalName, renamedName] of captureNameMap) {
1545+
if (originalName !== renamedName) {
1546+
substitutions.set(originalName, renamedName);
1547+
}
1548+
}
1549+
1550+
if (substitutions.size === 0) {
1551+
return body; // No substitutions needed
1552+
}
1553+
1554+
const visitor = (node: ts.Node, parent?: ts.Node): ts.Node => {
1555+
// Handle shorthand property assignments specially
1556+
// { multiplier } needs to become { multiplier: multiplier_1 } if multiplier is renamed
1557+
if (ts.isShorthandPropertyAssignment(node)) {
1558+
const substituteName = substitutions.get(node.name.text);
1559+
if (substituteName) {
1560+
// Expand shorthand into full property assignment
1561+
return factory.createPropertyAssignment(
1562+
node.name, // Property name stays the same
1563+
factory.createIdentifier(substituteName), // Value uses renamed identifier
1564+
);
1565+
}
1566+
// No substitution needed, keep as shorthand
1567+
return node;
1568+
}
1569+
1570+
// Don't substitute identifiers that are property names
1571+
if (ts.isIdentifier(node)) {
1572+
// Skip if this identifier is the property name in a property access (e.g., '.get' in 'obj.get')
1573+
if (
1574+
parent && ts.isPropertyAccessExpression(parent) && parent.name === node
1575+
) {
1576+
return node;
1577+
}
1578+
1579+
// Skip if this identifier is a property name in an object literal (e.g., 'foo' in '{ foo: value }')
1580+
if (parent && ts.isPropertyAssignment(parent) && parent.name === node) {
1581+
return node;
1582+
}
1583+
1584+
const substituteName = substitutions.get(node.text);
1585+
if (substituteName) {
1586+
return factory.createIdentifier(substituteName);
1587+
}
1588+
}
1589+
1590+
return ts.visitEachChild(node, (child) => visitor(child, node), undefined);
1591+
};
1592+
1593+
return ts.visitNode(
1594+
body,
1595+
(node) => visitor(node, undefined),
1596+
) as ts.ConciseBody;
1597+
}
1598+
14901599
/**
14911600
* Create the derive callback with parameter aliasing to preserve user's parameter name.
14921601
* Example: ({value: v, multiplier}) => v * multiplier
@@ -1499,6 +1608,7 @@ function createDeriveCallback(
14991608
transformedBody: ts.ConciseBody,
15001609
originalInputParamName: string,
15011610
captureTree: Map<string, CaptureTreeNode>,
1611+
captureNameMap: Map<string, string>,
15021612
context: TransformationContext,
15031613
hadZeroParameters: boolean,
15041614
): ts.ArrowFunction | ts.FunctionExpression {
@@ -1590,14 +1700,19 @@ function createDeriveCallback(
15901700
),
15911701
);
15921702

1593-
// Add bindings for captures
1703+
// Add bindings for captures using the potentially renamed property names
15941704
const createBindingIdentifier = (name: string): ts.Identifier => {
15951705
return reserveIdentifier(name, usedBindingNames, factory);
15961706
};
15971707

1708+
// Create binding elements using the renamed capture names
1709+
const renamedCaptureNames = Array.from(captureTree.keys()).map(
1710+
(originalName) => captureNameMap.get(originalName) ?? originalName,
1711+
);
1712+
15981713
bindingElements.push(
15991714
...createBindingElementsFromNames(
1600-
captureTree.keys(),
1715+
renamedCaptureNames,
16011716
factory,
16021717
createBindingIdentifier,
16031718
),
@@ -1613,6 +1728,13 @@ function createDeriveCallback(
16131728
undefined,
16141729
);
16151730

1731+
// Rewrite the body to use renamed capture identifiers
1732+
const rewrittenBody = rewriteCaptureReferences(
1733+
transformedBody,
1734+
captureNameMap,
1735+
factory,
1736+
);
1737+
16161738
// Create the new callback
16171739
if (ts.isArrowFunction(callback)) {
16181740
return factory.createArrowFunction(
@@ -1621,7 +1743,7 @@ function createDeriveCallback(
16211743
[parameter],
16221744
undefined, // No return type - rely on inference
16231745
callback.equalsGreaterThanToken,
1624-
transformedBody,
1746+
rewrittenBody,
16251747
);
16261748
} else {
16271749
return factory.createFunctionExpression(
@@ -1631,7 +1753,7 @@ function createDeriveCallback(
16311753
callback.typeParameters,
16321754
[parameter],
16331755
undefined, // No return type - rely on inference
1634-
transformedBody as ts.Block,
1756+
rewrittenBody as ts.Block,
16351757
);
16361758
}
16371759
}
@@ -1646,6 +1768,7 @@ function buildDeriveInputSchema(
16461768
originalInputParamName: string,
16471769
originalInput: ts.Expression,
16481770
captureTree: Map<string, CaptureTreeNode>,
1771+
captureNameMap: Map<string, string>,
16491772
context: TransformationContext,
16501773
hadZeroParameters: boolean,
16511774
): ts.TypeNode {
@@ -1677,11 +1800,40 @@ function buildDeriveInputSchema(
16771800
);
16781801
}
16791802

1680-
// Add type elements for captures
1681-
typeElements.push(
1682-
...buildTypeElementsFromCaptureTree(captureTree, context),
1803+
// Add type elements for captures using the existing helper
1804+
const captureTypeElements = buildTypeElementsFromCaptureTree(
1805+
captureTree,
1806+
context,
16831807
);
16841808

1809+
// Rename the property signatures if there are collisions
1810+
for (const typeElement of captureTypeElements) {
1811+
if (
1812+
ts.isPropertySignature(typeElement) && ts.isIdentifier(typeElement.name)
1813+
) {
1814+
const originalName = typeElement.name.text;
1815+
const renamedName = captureNameMap.get(originalName) ?? originalName;
1816+
1817+
if (renamedName !== originalName) {
1818+
// Create a new property signature with the renamed identifier
1819+
typeElements.push(
1820+
factory.createPropertySignature(
1821+
typeElement.modifiers,
1822+
factory.createIdentifier(renamedName),
1823+
typeElement.questionToken,
1824+
typeElement.type,
1825+
),
1826+
);
1827+
} else {
1828+
// No renaming needed
1829+
typeElements.push(typeElement);
1830+
}
1831+
} else {
1832+
// Not a simple property signature, keep as-is
1833+
typeElements.push(typeElement);
1834+
}
1835+
}
1836+
16851837
// Create object type literal
16861838
return factory.createTypeLiteralNode(typeElements);
16871839
}
@@ -1757,11 +1909,18 @@ function transformDeriveCall(
17571909
// In this case, we don't need to preserve the input - just use captures
17581910
const hadZeroParameters = callback.parameters.length === 0;
17591911

1912+
// Resolve capture name collisions with the original input parameter name
1913+
const captureNameMap = resolveDeriveCaptureNameCollisions(
1914+
originalInputParamName,
1915+
captureTree,
1916+
);
1917+
17601918
// Build merged input object
17611919
const mergedInput = buildDeriveInputObject(
17621920
originalInput,
17631921
originalInputParamName,
17641922
captureTree,
1923+
captureNameMap,
17651924
factory,
17661925
hadZeroParameters,
17671926
);
@@ -1772,6 +1931,7 @@ function transformDeriveCall(
17721931
transformedBody,
17731932
originalInputParamName,
17741933
captureTree,
1934+
captureNameMap,
17751935
context,
17761936
hadZeroParameters,
17771937
);
@@ -1782,6 +1942,7 @@ function transformDeriveCall(
17821942
originalInputParamName,
17831943
originalInput,
17841944
captureTree,
1945+
captureNameMap,
17851946
context,
17861947
hadZeroParameters,
17871948
);
@@ -1790,19 +1951,26 @@ function transformDeriveCall(
17901951
// SchemaInjectionTransformer will use this to generate the result schema
17911952
const signature = context.checker.getSignatureFromDeclaration(callback);
17921953
let resultTypeNode: ts.TypeNode | undefined;
1954+
let resultType: ts.Type | undefined;
17931955

17941956
if (callback.type) {
1795-
// Explicit return type annotation - use it
1957+
// Explicit return type annotation - use it directly (no need to register in typeRegistry)
17961958
resultTypeNode = callback.type;
17971959
} else if (signature) {
17981960
// Infer from callback signature
1799-
const returnType = signature.getReturnType();
1961+
resultType = signature.getReturnType();
18001962
resultTypeNode = context.checker.typeToTypeNode(
1801-
returnType,
1963+
resultType,
18021964
context.sourceFile,
18031965
ts.NodeBuilderFlags.NoTruncation |
18041966
ts.NodeBuilderFlags.UseStructuralFallback,
18051967
);
1968+
1969+
// Register the result Type in typeRegistry for the synthetic TypeNode
1970+
// This fixes schema generation for shorthand properties referencing captured variables
1971+
if (resultTypeNode && context.options.typeRegistry) {
1972+
context.options.typeRegistry.set(resultTypeNode, resultType);
1973+
}
18061974
}
18071975

18081976
// Build the derive call expression

packages/ts-transformers/src/transformers/schema-injection.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,11 +345,25 @@ export class SchemaInjectionTransformer extends Transformer {
345345
return ts.visitEachChild(node, visit, transformation);
346346
}
347347

348+
// Check if ClosureTransformer registered Types for these TypeNodes
349+
// This preserves type information for shorthand properties with captured variables
350+
let argumentTypeValue: ts.Type | undefined;
351+
let resultTypeValue: ts.Type | undefined;
352+
353+
if (typeRegistry) {
354+
if (typeRegistry.has(argumentType)) {
355+
argumentTypeValue = typeRegistry.get(argumentType);
356+
}
357+
if (typeRegistry.has(resultType)) {
358+
resultTypeValue = typeRegistry.get(resultType);
359+
}
360+
}
361+
348362
return updateWithSchemas(
349363
argumentType,
350-
undefined,
364+
argumentTypeValue,
351365
resultType,
352-
undefined,
366+
resultTypeValue,
353367
);
354368
}
355369

packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.expected.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ export default recipe({
4343
<button type="button" onClick={adder({ values })}>Add Value</button>
4444
<div>
4545
{values.mapWithPattern(__ctHelpers.recipe({
46-
$schema: "https://json-schema.org/draft/2020-12/schema",
4746
type: "object",
4847
properties: {
4948
element: {

packages/ts-transformers/test/fixtures/ast-transform/schema-generation-builders.expected.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ export default recipe({
4747
</button>
4848
<ul>
4949
{state.items.mapWithPattern(__ctHelpers.recipe({
50-
$schema: "https://json-schema.org/draft/2020-12/schema",
5150
type: "object",
5251
properties: {
5352
element: {

0 commit comments

Comments
 (0)