Skip to content

Commit 545bbf1

Browse files
authored
Dx1/cleanup cell brand detection (#2015)
Simplifications in CT formatter w.r.t. cell_brand. Refactor to central utility. Add some tests and update test harness to actually use
1 parent 6244f8b commit 545bbf1

32 files changed

+612
-415
lines changed

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
"merkle-reference": "npm:merkle-reference@^2.2.0",
117117
"multiformats": "npm:multiformats@^13.3.2",
118118
"turndown": "npm:turndown@^7.1.2",
119-
"zod": "npm:zod@^3.24.1"
119+
"zod": "npm:zod@^3.24.1",
120+
"@commontools/schema-generator/cell-brand": "./packages/schema-generator/src/typescript/cell-brand.ts"
120121
}
121122
}

packages/schema-generator/deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"version": "0.1.0",
44
"exports": {
55
".": "./src/index.ts",
6-
"./interface": "./src/interface.ts"
6+
"./interface": "./src/interface.ts",
7+
"./typescript/cell-brand": "./src/typescript/cell-brand.ts"
78
},
89
"imports": {
910
"typescript": "npm:typescript",

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

Lines changed: 82 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import ts from "typescript";
2+
import {
3+
type CellWrapperKind,
4+
getCellBrand,
5+
getCellWrapperInfo,
6+
isCellBrand,
7+
} from "../typescript/cell-brand.ts";
28
import type {
39
GenerationContext,
410
SchemaDefinition,
@@ -7,7 +13,7 @@ import type {
713
import type { SchemaGenerator } from "../schema-generator.ts";
814
import { detectWrapperViaNode, resolveWrapperNode } from "../type-utils.ts";
915

10-
type WrapperKind = "Cell" | "Stream" | "OpaqueRef";
16+
type WrapperKind = CellWrapperKind;
1117

1218
/**
1319
* Formatter for Common Tools specific types (Cell<T>, Stream<T>, OpaqueRef<T>, Default<T,V>)
@@ -25,12 +31,12 @@ export class CommonToolsFormatter implements TypeFormatter {
2531
}
2632

2733
supportsType(type: ts.Type, context: GenerationContext): boolean {
28-
// Check via typeNode for Default (erased at type-level) and all wrapper aliases
34+
// Check via typeNode for Default (erased at type-level)
2935
const wrapperViaNode = detectWrapperViaNode(
3036
context.typeNode,
3137
context.typeChecker,
3238
);
33-
if (wrapperViaNode) {
39+
if (wrapperViaNode === "Default") {
3440
return true;
3541
}
3642

@@ -39,8 +45,12 @@ export class CommonToolsFormatter implements TypeFormatter {
3945
return true;
4046
}
4147

48+
if ((type.flags & ts.TypeFlags.Union) !== 0) {
49+
return false;
50+
}
51+
4252
// Check if this is a wrapper type (Cell/Stream/OpaqueRef) via type structure
43-
const wrapperInfo = this.getWrapperTypeInfo(type);
53+
const wrapperInfo = getCellWrapperInfo(type, context.typeChecker);
4454
return wrapperInfo !== undefined;
4555
}
4656

@@ -86,61 +96,46 @@ export class CommonToolsFormatter implements TypeFormatter {
8696
}
8797
}
8898

89-
// Handle Cell/Stream/OpaqueRef via node (direct or alias)
90-
if (resolvedWrapper && resolvedWrapper.kind !== "Default") {
91-
// Use the ACTUAL type from the usage site (which has concrete type arguments)
92-
const wrapperInfo = this.getWrapperTypeInfo(type);
93-
if (wrapperInfo) {
94-
// For choosing which node to pass to formatWrapperType:
95-
// - If original node has type arguments: use it (has concrete types from usage site)
96-
// - If original node is just identifier (alias): use resolved node
97-
// formatWrapperType will check if node has type args before extracting inner types
98-
const nodeToPass = n && ts.isTypeReferenceNode(n) && n.typeArguments
99-
? n // Original has type args, use it
100-
: resolvedWrapper.node; // Original is just alias, use resolved (but won't extract inner types from it)
99+
const wrapperInfo = getCellWrapperInfo(type, context.typeChecker);
100+
if (wrapperInfo && !(type.flags & ts.TypeFlags.Union)) {
101+
const nodeToPass = this.selectWrapperTypeNode(
102+
n,
103+
resolvedWrapper,
104+
wrapperInfo.kind,
105+
);
106+
return this.formatWrapperType(
107+
wrapperInfo.typeRef,
108+
nodeToPass,
109+
context,
110+
wrapperInfo.kind,
111+
);
112+
}
101113

114+
// If we detected a wrapper syntactically but the current type is wrapped in
115+
// additional layers (e.g., Opaque<OpaqueRef<...>>), recursively unwrap using
116+
// brand information until we reach the underlying wrapper.
117+
const wrapperKinds: WrapperKind[] = ["OpaqueRef", "Cell", "Stream"];
118+
for (const kind of wrapperKinds) {
119+
const unwrappedType = this.recursivelyUnwrapOpaqueRef(
120+
type,
121+
kind,
122+
context.typeChecker,
123+
);
124+
if (unwrappedType) {
125+
const nodeToPass = this.selectWrapperTypeNode(
126+
n,
127+
resolvedWrapper,
128+
unwrappedType.kind,
129+
);
102130
return this.formatWrapperType(
103-
wrapperInfo.typeRef,
131+
unwrappedType.typeRef,
104132
nodeToPass,
105133
context,
106-
wrapperInfo.kind,
134+
unwrappedType.kind,
107135
);
108-
} else {
109-
// If we detected a wrapper via typeNode but the type structure is complex
110-
// (e.g., wrapped in Opaque union), recursively unwrap to find the base wrapper type
111-
const unwrappedType = this.recursivelyUnwrapOpaqueRef(
112-
type,
113-
resolvedWrapper.kind,
114-
context.typeChecker,
115-
);
116-
if (unwrappedType) {
117-
const nodeToPass = n && ts.isTypeReferenceNode(n) && n.typeArguments
118-
? n
119-
: resolvedWrapper.node;
120-
121-
return this.formatWrapperType(
122-
unwrappedType.typeRef,
123-
nodeToPass,
124-
context,
125-
unwrappedType.kind,
126-
);
127-
}
128-
// If we couldn't unwrap, fall through to regular handling
129136
}
130137
}
131138

132-
// Fallback: try to get wrapper type information from type structure
133-
// (for cases where we don't have a typeNode)
134-
const wrapperInfo = this.getWrapperTypeInfo(type);
135-
if (wrapperInfo) {
136-
return this.formatWrapperType(
137-
wrapperInfo.typeRef,
138-
n,
139-
context,
140-
wrapperInfo.kind,
141-
);
142-
}
143-
144139
const nodeName = this.getTypeRefIdentifierName(n);
145140
throw new Error(
146141
`Unexpected CommonTools type: ${nodeName}`,
@@ -219,13 +214,18 @@ export class CommonToolsFormatter implements TypeFormatter {
219214
}
220215

221216
// Cell<T>: disallow Cell<Stream<T>> to avoid ambiguous semantics
222-
if (wrapperKind === "Cell" && this.isStreamType(innerType)) {
217+
if (
218+
wrapperKind === "Cell" &&
219+
this.isStreamType(innerType, context.typeChecker)
220+
) {
223221
throw new Error(
224222
"Cell<Stream<T>> is unsupported. Wrap the stream: Cell<{ stream: Stream<T> }>.",
225223
);
226224
}
227225

228226
// Determine the property name to add based on wrapper kind
227+
// TODO(gideon): Consider updating as[Cell,Opaque,Stream] properties to use an array of brands
228+
// instead of boolean values, to support multiple brands like ["cell", "comparable"]
229229
const propertyName = wrapperKind === "Cell" ? "asCell" : "asOpaque";
230230

231231
// Handle case where innerSchema might be boolean (per JSON Schema spec)
@@ -256,9 +256,11 @@ export class CommonToolsFormatter implements TypeFormatter {
256256
}
257257

258258
// Check if this type itself is the target wrapper
259-
const wrapperInfo = this.getWrapperTypeInfo(type);
260-
if (wrapperInfo && wrapperInfo.kind === targetWrapperKind) {
261-
return { type, typeRef: wrapperInfo.typeRef, kind: wrapperInfo.kind };
259+
if ((type.flags & ts.TypeFlags.Union) === 0) {
260+
const wrapperInfo = getCellWrapperInfo(type, checker);
261+
if (wrapperInfo && wrapperInfo.kind === targetWrapperKind) {
262+
return { type, typeRef: wrapperInfo.typeRef, kind: wrapperInfo.kind };
263+
}
262264
}
263265

264266
// If this is a union (e.g., from Opaque<T>), check each member
@@ -363,67 +365,8 @@ export class CommonToolsFormatter implements TypeFormatter {
363365
return { baseType: baseMember };
364366
}
365367

366-
/**
367-
* Check if a type has a CELL_BRAND property (is a cell type)
368-
*/
369-
private isCellType(type: ts.Type): boolean {
370-
return type.getProperty("CELL_BRAND") !== undefined;
371-
}
372-
373-
/**
374-
* Get the CELL_BRAND string value from a type, if it has one.
375-
* Returns the brand string ("opaque", "cell", "stream", etc.) or undefined.
376-
*/
377-
private getCellBrand(
378-
type: ts.Type,
379-
checker: ts.TypeChecker,
380-
): string | undefined {
381-
const brandSymbol = type.getProperty("CELL_BRAND");
382-
if (brandSymbol && brandSymbol.valueDeclaration) {
383-
const brandType = checker.getTypeOfSymbolAtLocation(
384-
brandSymbol,
385-
brandSymbol.valueDeclaration,
386-
);
387-
if (brandType.flags & ts.TypeFlags.StringLiteral) {
388-
return (brandType as ts.StringLiteralType).value;
389-
}
390-
}
391-
return undefined;
392-
}
393-
394-
/**
395-
* Check if a type is an OpaqueRef type by checking the CELL_BRAND property.
396-
* All cell types (OpaqueCell, Cell, Stream) are intersections with CELL_BRAND,
397-
* but only OpaqueCell has brand "opaque".
398-
*/
399368
private isOpaqueRefType(type: ts.Type, checker: ts.TypeChecker): boolean {
400-
// Try CELL_BRAND first - most reliable method
401-
const brand = this.getCellBrand(type, checker);
402-
if (brand === "opaque") {
403-
return true;
404-
}
405-
406-
// Fallback: check by constituent interface names for backward compatibility
407-
if (type.flags & ts.TypeFlags.Intersection) {
408-
const intersectionType = type as ts.IntersectionType;
409-
for (const constituent of intersectionType.types) {
410-
if (constituent.flags & ts.TypeFlags.Object) {
411-
const objectType = constituent as ts.ObjectType;
412-
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
413-
const typeRef = objectType as ts.TypeReference;
414-
const name = typeRef.target?.symbol?.name;
415-
// Check for OpaqueRef-specific interface names (old and new)
416-
if (
417-
name === "OpaqueRefMethods" || name === "OpaqueCell" ||
418-
name === "IOpaqueCell"
419-
) {
420-
return true;
421-
}
422-
}
423-
}
424-
}
425-
}
426-
return false;
369+
return isCellBrand(type, checker, "opaque");
427370
}
428371

429372
/**
@@ -433,82 +376,36 @@ export class CommonToolsFormatter implements TypeFormatter {
433376
type: ts.Type,
434377
checker: ts.TypeChecker,
435378
): ts.Type | undefined {
436-
if (!(type.flags & ts.TypeFlags.Intersection)) {
379+
const wrapperInfo = getCellWrapperInfo(type, checker);
380+
if (!wrapperInfo || wrapperInfo.kind !== "OpaqueRef") {
437381
return undefined;
438382
}
439383

440-
const intersectionType = type as ts.IntersectionType;
441-
for (const constituent of intersectionType.types) {
442-
if (constituent.flags & ts.TypeFlags.Object) {
443-
const objectType = constituent as ts.ObjectType;
444-
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
445-
const typeRef = objectType as ts.TypeReference;
446-
const name = typeRef.target?.symbol?.name;
447-
// Check for both old (OpaqueRefMethods) and new (OpaqueCell, IOpaqueCell, BrandedCell) names
448-
if (
449-
name === "OpaqueRefMethods" || name === "OpaqueCell" ||
450-
name === "IOpaqueCell" || name === "BrandedCell"
451-
) {
452-
// Found wrapper type with type argument, extract T
453-
const typeArgs = checker.getTypeArguments(typeRef);
454-
if (typeArgs && typeArgs.length > 0) {
455-
return typeArgs[0];
456-
}
457-
}
458-
}
459-
}
460-
}
461-
return undefined;
384+
const typeArgs = wrapperInfo.typeRef.typeArguments ??
385+
checker.getTypeArguments(wrapperInfo.typeRef);
386+
return typeArgs && typeArgs.length > 0 ? typeArgs[0] : undefined;
462387
}
463388

464-
/**
465-
* Get wrapper type information (Cell/Stream/OpaqueRef)
466-
* Handles both direct references and intersection types (e.g., OpaqueRef<"literal">)
467-
* Returns the wrapper kind and the TypeReference needed for formatting
468-
*/
469-
private getWrapperTypeInfo(
470-
type: ts.Type,
471-
): { kind: WrapperKind; typeRef: ts.TypeReference } | undefined {
472-
// Check direct object type reference
473-
if (type.flags & ts.TypeFlags.Object) {
474-
const objectType = type as ts.ObjectType;
475-
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
476-
const typeRef = objectType as ts.TypeReference;
477-
const name = typeRef.target?.symbol?.name;
478-
if (
479-
name === "Cell" || name === "Stream" || name === "OpaqueRef" ||
480-
name === "OpaqueCell"
481-
) {
482-
// OpaqueCell should be treated as OpaqueRef
483-
const kind = name === "OpaqueCell" ? "OpaqueRef" : name;
484-
return { kind, typeRef };
485-
}
389+
private selectWrapperTypeNode(
390+
originalNode: ts.TypeNode | undefined,
391+
resolvedWrapper:
392+
| {
393+
kind: "Default" | WrapperKind;
394+
node: ts.TypeReferenceNode;
486395
}
396+
| undefined,
397+
targetKind: WrapperKind,
398+
): ts.TypeReferenceNode | undefined {
399+
if (
400+
originalNode &&
401+
ts.isTypeReferenceNode(originalNode) &&
402+
originalNode.typeArguments
403+
) {
404+
return originalNode;
487405
}
488-
489-
// OpaqueRef/OpaqueCell with literal type arguments becomes an intersection
490-
// e.g., OpaqueRef<"initial"> expands to: OpaqueCell<"initial"> & IOpaqueCell<"initial">
491-
// We need to detect these internal types to handle this case
492-
if (type.flags & ts.TypeFlags.Intersection) {
493-
const intersectionType = type as ts.IntersectionType;
494-
for (const constituent of intersectionType.types) {
495-
if (constituent.flags & ts.TypeFlags.Object) {
496-
const objectType = constituent as ts.ObjectType;
497-
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
498-
const typeRef = objectType as ts.TypeReference;
499-
const name = typeRef.target?.symbol?.name;
500-
// Check for both old (OpaqueRefMethods) and new (OpaqueCell, IOpaqueCell) internal types
501-
if (
502-
name === "OpaqueRefMethods" || name === "OpaqueCell" ||
503-
name === "IOpaqueCell"
504-
) {
505-
return { kind: "OpaqueRef", typeRef };
506-
}
507-
}
508-
}
509-
}
406+
if (resolvedWrapper?.kind === targetKind) {
407+
return resolvedWrapper.node;
510408
}
511-
512409
return undefined;
513410
}
514411

@@ -520,10 +417,8 @@ export class CommonToolsFormatter implements TypeFormatter {
520417
return ts.isIdentifier(tn) ? tn.text : undefined;
521418
}
522419

523-
private isStreamType(type: ts.Type): boolean {
524-
const objectType = type as ts.ObjectType;
525-
return !!(objectType.objectFlags & ts.ObjectFlags.Reference) &&
526-
(type as ts.TypeReference).target?.symbol?.name === "Stream";
420+
private isStreamType(type: ts.Type, checker: ts.TypeChecker): boolean {
421+
return getCellBrand(type, checker) === "stream";
527422
}
528423

529424
private formatDefaultType(

0 commit comments

Comments
 (0)