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 src/compat/differ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]`.
Expand Down
53 changes: 45 additions & 8 deletions src/compat/extractors/php-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
});
}
}
}

Expand All @@ -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 };
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions src/compat/extractors/php-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ function isResourceClass(cls: PhpClass, resourceBases: Set<string>): 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;
Expand Down Expand Up @@ -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<string, ApiField> = {};
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<string, string | number> = {};
Expand Down
41 changes: 41 additions & 0 deletions src/compat/extractors/python-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ function hasBase(cls: PythonClass, bases: Set<string>): 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<string, PythonClass>, modelBases: Set<string>): boolean {
Expand Down Expand Up @@ -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<string, ApiField> = {};
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<string, ApiMethod[]> = {};
Expand Down
63 changes: 63 additions & 0 deletions src/compat/extractors/ruby-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
Loading