Skip to content

Commit 6ad2e69

Browse files
mathpirateclaude
andauthored
Feature/derive closure transformer (commontoolsinc#2029)
Key features: - Parameter aliasing to preserve user's parameter names - Hierarchical capture organization - Object literal input merging - Type annotation handling via inference - Comprehensive edge case coverage - 19 test fixtures planned Reuses ~80% of existing closure transformation infrastructure: - collectCaptures() for detection - groupCapturesByRoot() for organization - buildTypeElementsFromCaptureTree() for schemas - buildHierarchicalParamsValue() for params objects Co-authored-by: Claude <[email protected]>
1 parent b50f95d commit 6ad2e69

File tree

76 files changed

+2742
-279
lines changed

Some content is hidden

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

76 files changed

+2742
-279
lines changed

packages/generated-patterns/integration/patterns/compliance-checklist.pattern.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,13 @@ const formatLabelFromId = (id: string): string =>
236236

237237
const normalizeWord = (value: string): string => {
238238
if (!value) return value;
239+
// Handle hyphenated compound words like "Third-Party"
240+
if (value.includes("-")) {
241+
return value.split("-").map((part) => {
242+
const lower = part.toLowerCase();
243+
return lower.slice(0, 1).toUpperCase() + lower.slice(1);
244+
}).join("-");
245+
}
239246
const lower = value.toLowerCase();
240247
return lower.slice(0, 1).toUpperCase() + lower.slice(1);
241248
};

packages/schema-generator/src/formatters/array-formatter.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ export class ArrayFormatter implements TypeFormatter {
3636
// Handle special cases for any[], unknown[], and never[] with JSON Schema shortcuts
3737
const elementFlags = info.elementType.flags;
3838

39-
if (elementFlags & ts.TypeFlags.Any) {
39+
// Special case: explicit any[] or unknown[] (without concrete node info)
40+
if ((elementFlags & ts.TypeFlags.Any) && !info.elementNode) {
4041
// any[] - allow any item type
4142
return { type: "array", items: true };
4243
}
4344

44-
if (elementFlags & ts.TypeFlags.Unknown) {
45+
if ((elementFlags & ts.TypeFlags.Unknown) && !info.elementNode) {
4546
// unknown[] - allow any item type (type safety at compile time)
4647
return { type: "array", items: true };
4748
}
@@ -51,6 +52,8 @@ export class ArrayFormatter implements TypeFormatter {
5152
return { type: "array", items: false };
5253
}
5354

55+
// Use formatChildType - it will auto-detect whether to use type-based
56+
// or node-based analysis based on whether the type is reliable
5457
const items = this.schemaGenerator.formatChildType(
5558
info.elementType,
5659
context,

packages/schema-generator/src/formatters/common-tools-formatter.ts

Lines changed: 132 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,28 @@ export class CommonToolsFormatter implements TypeFormatter {
474474
typeNode: ts.TypeNode,
475475
context: GenerationContext,
476476
): unknown {
477+
// Handle typeof expressions (TypeQuery nodes)
478+
// These reference a variable's value, like: typeof defaultRoutes
479+
if (ts.isTypeQueryNode(typeNode)) {
480+
return this.extractValueFromTypeQuery(typeNode, context);
481+
}
482+
483+
// Handle type references that represent empty objects
484+
// This includes Record<string, never>, Record<K, never>, and similar mapped types
485+
if (ts.isTypeReferenceNode(typeNode) && typeNode.typeArguments) {
486+
// For mapped types like Record<K, V>, if V is never, the result is an empty object
487+
// Check the last type argument (the value type in mapped types)
488+
const lastTypeArg =
489+
typeNode.typeArguments[typeNode.typeArguments.length - 1];
490+
if (lastTypeArg) {
491+
const lastType = context.typeChecker.getTypeFromTypeNode(lastTypeArg);
492+
// If the value type is never, this represents an empty object
493+
if (lastType.flags & ts.TypeFlags.Never) {
494+
return {};
495+
}
496+
}
497+
}
498+
477499
// Handle literal types
478500
if (ts.isLiteralTypeNode(typeNode)) {
479501
const literal = typeNode.literal;
@@ -549,102 +571,131 @@ export class CommonToolsFormatter implements TypeFormatter {
549571
return undefined;
550572
}
551573

552-
private extractComplexDefaultFromTypeSymbol(
553-
type: ts.Type,
554-
_symbol: ts.Symbol,
574+
private extractValueFromTypeQuery(
575+
typeQueryNode: ts.TypeQueryNode,
555576
context: GenerationContext,
556577
): unknown {
557-
// For now, try to extract from type string - this is a fallback approach
558-
const typeString = context.typeChecker.typeToString(type);
559-
560-
// Handle array literals like ["item1", "item2"]
561-
if (typeString.startsWith("[") && typeString.endsWith("]")) {
562-
try {
563-
return JSON.parse(typeString);
564-
} catch {
565-
// If JSON parsing fails, try simpler extraction
566-
return this.parseArrayLiteral(typeString);
567-
}
578+
// Get the entity name being queried (e.g., "defaultRoutes" in "typeof defaultRoutes")
579+
const exprName = typeQueryNode.exprName;
580+
581+
// Get the symbol for the referenced entity
582+
const symbol = context.typeChecker.getSymbolAtLocation(exprName);
583+
if (!symbol) {
584+
return undefined;
568585
}
569586

570-
// Handle object literals like { theme: "dark", count: 10 }
571-
if (typeString.startsWith("{") && typeString.endsWith("}")) {
572-
try {
573-
// Convert TS object syntax to JSON syntax
574-
const jsonString = typeString
575-
.replace(/(\w+):/g, '"$1":') // Quote property names
576-
.replace(/'/g, '"'); // Convert single quotes to double quotes
577-
return JSON.parse(jsonString);
578-
} catch {
579-
// If JSON parsing fails, return a simpler fallback
580-
return this.parseObjectLiteral(typeString);
581-
}
587+
return this.extractValueFromSymbol(symbol, context);
588+
}
589+
590+
/**
591+
* Extract a runtime value from a symbol's value declaration.
592+
* Works for variables with initializers like: const foo = [1, 2, 3]
593+
*/
594+
private extractValueFromSymbol(
595+
symbol: ts.Symbol,
596+
context: GenerationContext,
597+
): unknown {
598+
const valueDeclaration = symbol.valueDeclaration;
599+
if (!valueDeclaration) {
600+
return undefined;
601+
}
602+
603+
// Check if it's a variable declaration with an initializer
604+
if (
605+
ts.isVariableDeclaration(valueDeclaration) &&
606+
valueDeclaration.initializer
607+
) {
608+
return this.extractValueFromExpression(
609+
valueDeclaration.initializer,
610+
context,
611+
);
582612
}
583613

584614
return undefined;
585615
}
586616

587-
private parseArrayLiteral(str: string): unknown[] {
588-
// Simple array parsing for basic cases
589-
if (str === "[]") return [];
590-
591-
// Remove brackets and split by comma
592-
const inner = str.slice(1, -1);
593-
if (!inner.trim()) return [];
617+
private extractValueFromExpression(
618+
expr: ts.Expression,
619+
context: GenerationContext,
620+
): unknown {
621+
// Handle array literals like [1, 2, 3] or [{ id: "a" }, { id: "b" }]
622+
if (ts.isArrayLiteralExpression(expr)) {
623+
return expr.elements.map((element) =>
624+
this.extractValueFromExpression(element, context)
625+
);
626+
}
594627

595-
const items = inner.split(",").map((item) => {
596-
const trimmed = item.trim();
597-
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
598-
return trimmed.slice(1, -1); // String literal
599-
}
600-
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
601-
return trimmed.slice(1, -1); // String literal
602-
}
603-
if (!isNaN(Number(trimmed))) {
604-
return Number(trimmed); // Number literal
628+
// Handle object literals like { id: "a", name: "test" }
629+
if (ts.isObjectLiteralExpression(expr)) {
630+
const obj: Record<string, unknown> = {};
631+
for (const property of expr.properties) {
632+
if (
633+
ts.isPropertyAssignment(property) && ts.isIdentifier(property.name)
634+
) {
635+
const propName = property.name.text;
636+
obj[propName] = this.extractValueFromExpression(
637+
property.initializer,
638+
context,
639+
);
640+
} else if (ts.isShorthandPropertyAssignment(property)) {
641+
// Handle shorthand like { id } where id is a variable
642+
const propName = property.name.text;
643+
obj[propName] = this.extractValueFromExpression(
644+
property.name,
645+
context,
646+
);
647+
}
605648
}
606-
if (trimmed === "true") return true;
607-
if (trimmed === "false") return false;
608-
if (trimmed === "null") return null;
609-
return trimmed; // Fallback
610-
});
649+
return obj;
650+
}
651+
652+
// Handle string literals
653+
if (ts.isStringLiteral(expr)) {
654+
return expr.text;
655+
}
656+
657+
// Handle numeric literals
658+
if (ts.isNumericLiteral(expr)) {
659+
return Number(expr.text);
660+
}
661+
662+
// Handle boolean literals
663+
if (expr.kind === ts.SyntaxKind.TrueKeyword) {
664+
return true;
665+
}
666+
if (expr.kind === ts.SyntaxKind.FalseKeyword) {
667+
return false;
668+
}
669+
670+
// Handle null
671+
if (expr.kind === ts.SyntaxKind.NullKeyword) {
672+
return null;
673+
}
611674

612-
return items;
675+
// For more complex expressions, return undefined
676+
return undefined;
613677
}
614678

615-
private parseObjectLiteral(str: string): Record<string, unknown> {
616-
// Very basic object parsing - this is a fallback
617-
const obj: Record<string, unknown> = {};
618-
619-
// Remove braces
620-
const inner = str.slice(1, -1).trim();
621-
if (!inner) return obj;
622-
623-
// This is a simplified parser - for more complex cases we'd need proper AST parsing
624-
const pairs = inner.split(",");
625-
for (const pair of pairs) {
626-
const [key, ...valueParts] = pair.split(":");
627-
if (key && valueParts.length > 0) {
628-
const keyTrimmed = key.trim().replace(/"/g, "");
629-
const valueStr = valueParts.join(":").trim();
630-
631-
// Parse simple values
632-
if (valueStr.startsWith('"') && valueStr.endsWith('"')) {
633-
obj[keyTrimmed] = valueStr.slice(1, -1);
634-
} else if (!isNaN(Number(valueStr))) {
635-
obj[keyTrimmed] = Number(valueStr);
636-
} else if (valueStr === "true") {
637-
obj[keyTrimmed] = true;
638-
} else if (valueStr === "false") {
639-
obj[keyTrimmed] = false;
640-
} else if (valueStr === "null") {
641-
obj[keyTrimmed] = null;
642-
} else {
643-
obj[keyTrimmed] = valueStr;
644-
}
645-
}
679+
private extractComplexDefaultFromTypeSymbol(
680+
type: ts.Type,
681+
symbol: ts.Symbol,
682+
context: GenerationContext,
683+
): unknown {
684+
// Try to extract from the symbol's value declaration initializer (AST-based)
685+
const extracted = this.extractValueFromSymbol(symbol, context);
686+
if (extracted !== undefined) {
687+
return extracted;
688+
}
689+
690+
// Check if this is an empty object type (no properties, object type)
691+
// This handles cases like Record<string, never>
692+
if (
693+
(type.flags & ts.TypeFlags.Object) !== 0 &&
694+
context.typeChecker.getPropertiesOfType(type).length === 0
695+
) {
696+
return {};
646697
}
647698

648-
return obj;
699+
return undefined;
649700
}
650701
}

packages/schema-generator/src/formatters/object-formatter.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,54 @@ const logger = getLogger("schema-generator.object", {
2020
level: "warn",
2121
});
2222

23+
/**
24+
* Check if a type is a union that includes undefined.
25+
* When a property type is `T | undefined`, the property is considered optional.
26+
*/
27+
function isUnionWithUndefined(type: ts.Type): boolean {
28+
if (!(type.flags & ts.TypeFlags.Union)) {
29+
return false;
30+
}
31+
const unionType = type as ts.UnionType;
32+
return unionType.types.some((t) => (t.flags & ts.TypeFlags.Undefined) !== 0);
33+
}
34+
35+
/**
36+
* Check if a typeNode represents Default<T | undefined, V>.
37+
* When the inner type T includes undefined, the property is optional.
38+
*/
39+
function isDefaultNodeWithUndefined(
40+
typeNode: ts.TypeNode | undefined,
41+
checker: ts.TypeChecker,
42+
): boolean {
43+
if (!typeNode || !ts.isTypeReferenceNode(typeNode)) {
44+
return false;
45+
}
46+
47+
// Check if this is a reference to Default
48+
const typeName = ts.isIdentifier(typeNode.typeName)
49+
? typeNode.typeName.text
50+
: undefined;
51+
if (typeName !== "Default") {
52+
return false;
53+
}
54+
55+
// Get the first type argument (T from Default<T, V>)
56+
const typeArgs = typeNode.typeArguments;
57+
if (!typeArgs || typeArgs.length === 0) {
58+
return false;
59+
}
60+
61+
const innerTypeNode = typeArgs[0];
62+
if (!innerTypeNode) {
63+
return false;
64+
}
65+
66+
// Get the type from the node and check if it's a union with undefined
67+
const innerType = checker.getTypeFromTypeNode(innerTypeNode);
68+
return isUnionWithUndefined(innerType);
69+
}
70+
2371
/**
2472
* Formatter for object types (interfaces, type literals, etc.)
2573
*/
@@ -90,7 +138,20 @@ export class ObjectFormatter implements TypeFormatter {
90138
continue;
91139
}
92140

93-
const isOptional = (prop.flags & ts.SymbolFlags.Optional) !== 0;
141+
// Property is optional (excluded from required array) if:
142+
// 1. It has the `?` optional flag (e.g., `foo?: string`)
143+
// 2. Its type is `T | undefined` (e.g., `foo: string | undefined`)
144+
// 3. Its type is `Default<T | undefined, V>` (undefined makes it optional)
145+
// In all cases, the property may be omitted at runtime (JSON-like semantics).
146+
const hasOptionalFlag = (prop.flags & ts.SymbolFlags.Optional) !== 0;
147+
const hasUndefinedUnion = isUnionWithUndefined(resolvedPropType);
148+
const isDefaultWithUndefinedInner = isDefaultNodeWithUndefined(
149+
propTypeNode,
150+
checker,
151+
);
152+
const isOptional = hasOptionalFlag || hasUndefinedUnion ||
153+
isDefaultWithUndefinedInner;
154+
94155
if (!isOptional) required.push(propName);
95156

96157
// Delegate to the main generator (specific formatters handle wrappers/defaults)

0 commit comments

Comments
 (0)