diff --git a/src/compat/differ.ts b/src/compat/differ.ts index eca6db1..30c373b 100644 --- a/src/compat/differ.ts +++ b/src/compat/differ.ts @@ -652,6 +652,14 @@ function bareTypeName(t: string): string { // Go pointer prefix: `*Foo` → `Foo`. Strip first because the rest of the // patterns expect a leading identifier character. s = s.replace(/^\*/, ''); + // PHP namespace prefix: `\Vendor\Pkg\Foo` → `Foo`. PHP fully-qualified + // type references in generated method signatures lead with a backslash + // and use backslash-separated segments. Split on the last segment to + // preserve nested generics inside the path. + if (s.startsWith('\\')) { + const lastBackslash = s.lastIndexOf('\\'); + s = s.slice(lastBackslash + 1); + } // Nullable suffixes: `Foo | null`, `Foo?`. s = s.replace(/\s*\|\s*null$/, '').replace(/\?$/, ''); // Array suffix: `Foo[]`. diff --git a/src/compat/extractors/php-parser.ts b/src/compat/extractors/php-parser.ts index d98d335..4dfd579 100644 --- a/src/compat/extractors/php-parser.ts +++ b/src/compat/extractors/php-parser.ts @@ -194,8 +194,9 @@ function parseConstElement(constElement: SyntaxNode): { name: string; value: str // Method extraction // --------------------------------------------------------------------------- -function parseMethods(classBody: SyntaxNode): PhpMethod[] { +function parseMethods(classBody: SyntaxNode): { methods: PhpMethod[]; promotedProperties: PhpProperty[] } { const methods: PhpMethod[] = []; + const allPromotedProperties: PhpProperty[] = []; for (const child of classBody.namedChildren) { if (child.type !== 'method_declaration') continue; @@ -213,12 +214,22 @@ function parseMethods(classBody: SyntaxNode): PhpMethod[] { // Get PHPDoc info const docInfo = findDocComment(child); - // Parse parameters + // Parse parameters. Two PHP parameter shapes are accepted: + // - `simple_parameter`: standard `Type $name = default` + // - `property_promotion_parameter`: PHP 8 constructor-promoted + // property `public Type $name`. These declare *both* a parameter + // on the constructor AND a property on the class — used by every + // model emitted in the WorkOS PHP SDK (`readonly class Foo { + // function __construct(public Type $field, …) {} }`). Without + // accepting this node type the parser skips the parameter + // entirely, model fields disappear from the surface, and the + // compat differ has no field-level signal to pair renamed types. + const promotedProperties: PhpProperty[] = []; const params: PhpParam[] = []; const paramsList = child.childForFieldName('parameters'); if (paramsList) { for (const paramNode of paramsList.namedChildren) { - if (paramNode.type !== 'simple_parameter') continue; + if (paramNode.type !== 'simple_parameter' && paramNode.type !== 'property_promotion_parameter') continue; const paramNameNode = paramNode.namedChildren.find((c) => c.type === 'variable_name'); if (!paramNameNode) continue; @@ -248,6 +259,20 @@ function parseMethods(classBody: SyntaxNode): PhpMethod[] { type: paramType, optional: hasDefault, }); + + // When the parameter carries a visibility modifier it is a + // *promoted property* — `public Type $field` declares the field + // on the class. Surface as a property so model classes pick up + // their fields without needing a separate `property_declaration`. + if (paramNode.type === 'property_promotion_parameter') { + const visibilityNode = paramNode.namedChildren.find((c) => c.type === 'visibility_modifier'); + const visibility = (visibilityNode?.text as 'public' | 'protected' | 'private' | undefined) ?? 'public'; + promotedProperties.push({ + name: paramName, + type: paramType, + visibility, + }); + } } } @@ -271,9 +296,16 @@ function parseMethods(classBody: SyntaxNode): PhpMethod[] { params, returnType, }); + + // Promoted properties surface only from `__construct`. PHP's grammar + // technically allows them on any method, but only the constructor's + // promoted-properties create class fields per the language spec. + if (methodName === '__construct') { + allPromotedProperties.push(...promotedProperties); + } } - return methods; + return { methods, promotedProperties: allPromotedProperties }; } // --------------------------------------------------------------------------- @@ -407,20 +439,25 @@ function parseClassDeclarations(tree: Parser.Tree, sourceFile: string, namespace const bodyNode = node.childForFieldName('body'); if (!bodyNode) continue; - const methods = parseMethods(bodyNode); + const { methods, promotedProperties } = parseMethods(bodyNode); const properties = parseProperties(bodyNode); const constants = parseConstants(bodyNode); const resourceAttributes = parseResourceAttributes(bodyNode); const hasCustomConstructor = methods.some((m) => m.name === 'constructFromResponse' && m.isStatic); + // Merge constructor-promoted properties into the class properties so + // model classes (PHP 8 readonly classes that put their fields on the + // constructor) carry their field info into the surface. + const allProperties: PhpProperty[] = [...properties, ...promotedProperties]; + classes.push({ name: nameNode.text, namespace, extends: extendsName, isInterface: false, methods, - properties, + properties: allProperties, constants, resourceAttributes, hasCustomConstructor, @@ -441,7 +478,7 @@ function parseInterfaceDeclarations(tree: Parser.Tree, sourceFile: string, names const bodyNode = node.childForFieldName('body'); if (!bodyNode) continue; - const methods = parseMethods(bodyNode); + const { methods } = parseMethods(bodyNode); interfaces.push({ name: nameNode.text, @@ -494,7 +531,7 @@ function parseEnumDeclarations(tree: Parser.Tree, sourceFile: string, namespace: constants.push({ name: caseNameNode.text, value }); } - const methods = parseMethods(bodyNode); + const { methods } = parseMethods(bodyNode); enums.push({ name: nameNode.text, diff --git a/src/compat/extractors/php-surface.ts b/src/compat/extractors/php-surface.ts index 474d5a6..b86c378 100644 --- a/src/compat/extractors/php-surface.ts +++ b/src/compat/extractors/php-surface.ts @@ -38,6 +38,28 @@ function isResourceClass(cls: PhpClass, resourceBases: Set): boolean { return !!cls.extends && resourceBases.has(cls.extends) && cls.resourceAttributes.length > 0; } +/** + * Check if a class is a "value object" — PHP 8 model class whose fields + * live on the constructor as promoted properties (`public Type $field`). + * Every model class generated by the WorkOS PHP emitter follows this + * shape: `readonly class Foo implements \JsonSerializable { use + * JsonSerializableTrait; public function __construct(public Type $field, + * …) {} }`. The parser collects promoted properties under + * `cls.properties` so any class with at least one public promoted + * property is a value object — the field set is its identity. + * + * Exception classes are explicitly excluded: an exception subclass that + * happens to declare a public property (e.g. `BaseRequestException` with + * a `$requestId` field) is still semantically an exception, not a model. + * The `isExceptionClass` check is performed by the caller so this stays + * a pure structural test. + */ +function isValueObjectClass(cls: PhpClass): boolean { + if (cls.isInterface) return false; + if (cls.constants.length > 0) return false; // enum-shaped, not a model + return cls.properties.some((p) => p.visibility === 'public'); +} + /** Check if a class is enum-like (only constants, no public methods). */ function isEnumClass(cls: PhpClass): boolean { if (cls.isInterface) return false; @@ -113,6 +135,29 @@ export function buildSurface( ...(cls.hasCustomConstructor ? { hasCustomConstructor: true } : {}), }; collector.add(cls.sourceFile, cls.name); + } else if (isValueObjectClass(cls) && !isExceptionClass(cls, exceptionBases)) { + // PHP 8 value-object class (constructor-promoted properties) → + // ApiInterface. The promoted properties are the public fields; the + // class's `__construct` / `fromArray` / `toArray` helpers don't + // contribute identity. This is the shape every WorkOS PHP model + // takes; without this branch the class would fall through to the + // service-class path below and lose its field info. + const fields: Record = {}; + for (const prop of cls.properties) { + if (prop.visibility !== 'public') continue; + fields[prop.name] = { + name: prop.name, + type: prop.type, + optional: false, + }; + } + interfaces[cls.name] = { + name: cls.name, + sourceFile: cls.sourceFile, + fields, // Intentionally NOT sorted — preserves declaration order. + extends: cls.extends ? [cls.extends] : [], + }; + collector.add(cls.sourceFile, cls.name); } else if (isEnumClass(cls)) { // Enum-like class → ApiEnum const members: Record = {}; diff --git a/src/compat/extractors/python-surface.ts b/src/compat/extractors/python-surface.ts index 033ea32..78c433c 100644 --- a/src/compat/extractors/python-surface.ts +++ b/src/compat/extractors/python-surface.ts @@ -46,6 +46,21 @@ function hasBase(cls: PythonClass, bases: Set): boolean { return cls.bases.some((b) => bases.has(b)); } +/** + * True when the class carries a `@dataclass` decorator (with or without + * arguments — `@dataclass`, `@dataclass()`, `@dataclass(slots=True)`, etc.). + * + * Dataclasses are the canonical "data-shape" Python construct: their + * structural identity is the field set, even when generated alongside + * helper methods like `from_dict` / `to_dict`. Without this signal the + * extractor categorizes them as plain method-bearing classes (category 5) + * and loses the field info — which then prevents the compat differ from + * recognizing renames structurally. + */ +function isDataclass(cls: PythonClass): boolean { + return cls.decorators.some((d) => /^dataclass(\s*\(.*\))?$/.test(d)); +} + /** Check if a class is a model class (inherits from BaseModel or configured model bases, * transitively from another class that does). */ function isModelClass(cls: PythonClass, allClasses: Map, modelBases: Set): boolean { @@ -308,6 +323,32 @@ export function buildSurface( continue; } + // 4b. Dataclasses → ApiInterface. A `@dataclass`-decorated class is + // fundamentally a data-shape; its structural identity is the + // field set even when oagen also emits `from_dict` / `to_dict` + // helper methods on it. Without this branch the class falls + // through to category 5 (ApiClass with methods) and the field + // info is dropped — which prevents the compat differ from + // pairing renamed types structurally. + if (isDataclass(cls) && cls.fields.length > 0) { + const fields: Record = {}; + for (const field of cls.fields) { + fields[field.name] = { + name: field.name, + type: field.type, + optional: field.hasDefault, + }; + } + interfaces[cls.name] = { + name: cls.name, + sourceFile: cls.sourceFile, + fields: sortRecord(fields), + extends: [], + }; + collector.add(cls.sourceFile, cls.name); + continue; + } + // 5. Other class with methods → ApiClass if (cls.methods.length > 0) { const apiMethods: Record = {}; diff --git a/src/compat/extractors/ruby-parser.ts b/src/compat/extractors/ruby-parser.ts index e3e1d2b..095e701 100644 --- a/src/compat/extractors/ruby-parser.ts +++ b/src/compat/extractors/ruby-parser.ts @@ -44,6 +44,12 @@ export function extractClasses(source: string): ApiClass[] { // Skip if this class contains a singleton_class (class << self) — those are service modules if (node.descendantsOfType('singleton_class').length > 0) continue; + // Skip enum-shaped classes — they're handled by `extractEnumModules` + // (which also picks up class-shape enums alongside module-shape ones). + // Without this skip we'd double-emit the same name as both an + // ApiClass and an ApiEnum. + if (isEnumShapedClass(node)) continue; + const nameNode = node.childForFieldName('name'); if (!nameNode) continue; const className = nameNode.text; @@ -128,6 +134,25 @@ export function extractServiceModules(source: string): ApiClass[] { return services; } +/** + * True when a `class` node looks like a Ruby enum: only constant + * assignments, no methods, no attr_*, no singleton class. The + * `extractEnumModules` pass picks these up; `extractClasses` skips + * them to avoid double-emission. Shared so the two stay in sync. + */ +function isEnumShapedClass(classNode: SyntaxNode): boolean { + const bodyNode = classNode.childForFieldName('body'); + if (!bodyNode) return false; + // Reject if the class has any method-shaped or call-shaped child + // (call covers attr_accessor / attr_reader / include). + for (const c of bodyNode.namedChildren) { + if (c.type === 'method' || c.type === 'singleton_method' || c.type === 'call') return false; + } + // Must have at least 2 scalar constants to be considered an enum. + const constants = extractEnumConstants(bodyNode); + return Object.keys(constants).length >= 2; +} + /** Extract enum-like modules (modules with string/number constants, no class << self). */ export function extractEnumModules(source: string): ApiEnum[] { const tree = safeParse(source); @@ -158,6 +183,44 @@ export function extractEnumModules(source: string): ApiEnum[] { }); } + // Also extract enum-shaped *classes*: WorkOS Ruby SDKs emit dedup'd + // ordering enums and similar value-set types as + // `class ApplicationsOrder; ASC = "asc"; …; ALL = [...].freeze; end` + // — a class with only constants (no instance methods, no constructor). + // Without this branch they fall through to `extractClasses`, become + // ApiClass with no methods/properties, and surface as `kind: + // 'service_accessor'` with no enum_member children — which the compat + // differ can't pair on for canonical-flip detection. + for (const node of tree.rootNode.descendantsOfType('class')) { + if (node.type !== 'class') continue; + if (isInsideRanges(node.startPosition.row, serviceRanges)) continue; + if (node.descendantsOfType('singleton_class').length > 0) continue; + + const nameNode = node.childForFieldName('name'); + if (!nameNode) continue; + + const bodyNode = node.childForFieldName('body'); + if (!bodyNode) continue; + + // Reject if the class has any non-constant declarations (methods, + // attr_accessor, etc.). The `ALL` aggregator constant (`ALL = [A, + // B, C].freeze`) is allowed and skipped — `extractEnumConstants` + // only collects scalar string/number values, so non-scalar + // constants (arrays) are naturally excluded. + const hasNonConstantBody = bodyNode.namedChildren.some( + (c) => c.type === 'method' || c.type === 'singleton_method' || c.type === 'call', + ); + if (hasNonConstantBody) continue; + + const constants = extractEnumConstants(bodyNode); + if (Object.keys(constants).length < 2) continue; + + enums.push({ + name: nameNode.text, + members: sortRecord(constants), + }); + } + return enums; }