From 8a204359749f205c56b04bb744792d7e0b17774b Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Thu, 4 Dec 2025 19:22:34 +0200 Subject: [PATCH] Add TypeScript generator for MCP PHP DTOs Implement a complete pipeline to generate PHP Data Transfer Objects from the official MCP TypeScript schema specification. The generator handles enums, unions, inheritance hierarchies, and domain-based file organization. This enables automatic PHP schema updates when new MCP spec versions are released, ensuring the Composer package stays in sync with the protocol specification. --- .gitattributes | 7 + .gitignore | 31 +- CLAUDE.md | 72 + README.md | 71 + composer.json | 38 + composer.lock | 74 + generator/.prettierrc | 10 + generator/README.md | 60 + generator/config/2024-11-05.json | 8 + generator/config/2025-03-26.json | 8 + generator/config/2025-06-18.json | 8 + generator/config/2025-11-25.json | 8 + generator/config/versions.json | 10 + generator/docs/README.md | 118 + generator/docs/architecture.md | 222 ++ generator/docs/configuration.md | 118 + generator/docs/design-decisions.md | 235 ++ generator/eslint.config.js | 36 + generator/package-lock.json | 1918 +++++++++++++++++ generator/package.json | 65 + generator/src/cli/index.ts | 169 ++ generator/src/config/index.ts | 187 ++ generator/src/extractors/index.ts | 12 + generator/src/extractors/synthetic-dto.ts | 328 +++ generator/src/fetcher/index.ts | 153 ++ generator/src/generators/builder.ts | 315 +++ generator/src/generators/contract.ts | 240 +++ generator/src/generators/domain-classifier.ts | 210 ++ generator/src/generators/dto.ts | 1232 +++++++++++ generator/src/generators/enum.ts | 188 ++ generator/src/generators/factory.ts | 648 ++++++ generator/src/generators/index.ts | 65 + generator/src/generators/inheritance-graph.ts | 610 ++++++ generator/src/generators/type-mapper.ts | 508 +++++ generator/src/generators/type-resolver.ts | 414 ++++ generator/src/generators/union.ts | 219 ++ generator/src/index.ts | 482 +++++ generator/src/parser/index.ts | 286 +++ generator/src/types/index.ts | 355 +++ generator/src/version-tracker/index.ts | 598 +++++ generator/src/writers/index.ts | 382 ++++ generator/tsconfig.json | 37 + phpstan.neon | 5 + 43 files changed, 10746 insertions(+), 14 deletions(-) create mode 100644 .gitattributes create mode 100644 CLAUDE.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 generator/.prettierrc create mode 100644 generator/README.md create mode 100644 generator/config/2024-11-05.json create mode 100644 generator/config/2025-03-26.json create mode 100644 generator/config/2025-06-18.json create mode 100644 generator/config/2025-11-25.json create mode 100644 generator/config/versions.json create mode 100644 generator/docs/README.md create mode 100644 generator/docs/architecture.md create mode 100644 generator/docs/configuration.md create mode 100644 generator/docs/design-decisions.md create mode 100644 generator/eslint.config.js create mode 100644 generator/package-lock.json create mode 100644 generator/package.json create mode 100644 generator/src/cli/index.ts create mode 100644 generator/src/config/index.ts create mode 100644 generator/src/extractors/index.ts create mode 100644 generator/src/extractors/synthetic-dto.ts create mode 100644 generator/src/fetcher/index.ts create mode 100644 generator/src/generators/builder.ts create mode 100644 generator/src/generators/contract.ts create mode 100644 generator/src/generators/domain-classifier.ts create mode 100644 generator/src/generators/dto.ts create mode 100644 generator/src/generators/enum.ts create mode 100644 generator/src/generators/factory.ts create mode 100644 generator/src/generators/index.ts create mode 100644 generator/src/generators/inheritance-graph.ts create mode 100644 generator/src/generators/type-mapper.ts create mode 100644 generator/src/generators/type-resolver.ts create mode 100644 generator/src/generators/union.ts create mode 100644 generator/src/index.ts create mode 100644 generator/src/parser/index.ts create mode 100644 generator/src/types/index.ts create mode 100644 generator/src/version-tracker/index.ts create mode 100644 generator/src/writers/index.ts create mode 100644 generator/tsconfig.json create mode 100644 phpstan.neon diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..78c5f63 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Exclude generator and dev files from Packagist distribution +/generator export-ignore +/.claude export-ignore +/.idea export-ignore +/.gitattributes export-ignore +/phpstan.neon export-ignore +*.DS_Store export-ignore diff --git a/.gitignore b/.gitignore index 6b97099..2b1605b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Dependencies -/vendor/ -/node_modules/ +vendor/ +node_modules/ + +# Build output +dist/ # IDE .idea/ @@ -13,21 +16,21 @@ .DS_Store Thumbs.db -# Claude Code -.claude/settings.local.json +# Logs +*.log +npm-debug.log* + +# Test coverage +coverage/ -# PHP -*.phar -phpunit.xml -.phpunit.result.cache -.phpunit.cache/ +# Cache +.cache/ +.npm/ # Environment .env .env.local -.env.*.local -# Build artifacts -/build/ -/dist/ -/.cache/ +# Temporary files +tmp/ +temp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..83c07d2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This repository contains PHP DTOs for the Model Context Protocol (MCP) specification, distributed as a Composer package. It also includes a TypeScript generator (in `generator/`) that creates PHP code from the official MCP TypeScript schema. + +## Commands + +### PHP Package (root) + +```bash +composer analyse # Run PHPStan static analysis (level 8) +composer phpstan # Alias for analyse +``` + +### Generator (generator/) + +```bash +cd generator +npm install # Install dependencies +npm run build # Compile TypeScript +npm run generate # Generate PHP schema to ../src +npm run lint # ESLint +npm run format # Prettier +``` + +### Generate with specific version + +```bash +cd generator +npx mcp-php-generator generate -c config/2025-11-25.json +npm run generate:check -- -c config/2025-11-25.json # Generate + PHPStan validation +``` + +## Architecture + +### Repository Structure + +- `src/` - Generated PHP DTOs (the Composer package distributed via Packagist) +- `generator/` - TypeScript generator (excluded from Packagist via `.gitattributes`) + +### PHP Schema (`src/`) + +**Do not manually edit files in `src/`.** All PHP code is auto-generated by the TypeScript generator. To make changes, modify the generator and regenerate. + +Organized by MCP domain with PSR-4 autoloading (`WP\McpSchema\` → `src/`): + +- `Common/` - Shared base classes, traits, JSON-RPC, protocol types +- `Server/` - Server-side types (Tools, Resources, Prompts, Logging) +- `Client/` - Client-side types (Sampling, Elicitation, Roots, Tasks) + +### Generator Pipeline (`generator/src/`) + +``` +schema.ts → parser/ → extractors/ → generators/ → writers/ → PHP files +``` + +- `fetcher/` - Downloads schema from GitHub with caching +- `parser/` - ts-morph AST parsing +- `extractors/` - Extract type info, @category tags, inheritance +- `generators/` - PHP code generation (DTO, Enum, Union, Factory) +- `writers/` - File output organized by domain/subdomain +- `cli/` - Command-line interface + +### Key Design Decisions + +- PHP 7.4 compatibility (class-based enums, typed properties) +- Uses `@category` JSDoc tags for domain classification +- Generator outputs to `../src` relative to `generator/` directory +- PHPStan level 8 for strict static analysis diff --git a/README.md b/README.md index 9f731af..e8be23e 100644 --- a/README.md +++ b/README.md @@ -1 +1,72 @@ # PHP MCP Schema + +A PHP representation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) schema types. + +This package provides Data Transfer Objects (DTOs), Enums, and Unions that mirror the official MCP TypeScript schema. +It is **not** an SDK, client, or server implementation; +just the type definitions for building your own MCP-compatible applications in PHP. + +## Installation + +```bash +composer require wordpress/php-mcp-schema +``` + +Requires PHP 7.4 or higher. + +## Usage + +```php +use WP\McpSchema\Server\Tools\Tool; +use WP\McpSchema\Server\Tools\CallToolRequest; +use WP\McpSchema\Common\Content\TextContent; + +// Create a tool definition +$tool = Tool::fromArray([ + 'name' => 'get_weather', + 'description' => 'Get current weather for a location', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'location' => ['type' => 'string', 'description' => 'City name'], + ], + 'required' => ['location'], + ], +]); +``` + +## Available Types + +### Server Types (`WP\McpSchema\Server\`) + +- **Tools** - `Tool`, `CallToolRequest`, `CallToolResult`, `ListToolsRequest`, `ListToolsResult` +- **Resources** - `Resource`, `ResourceTemplate`, `ReadResourceRequest`, `ReadResourceResult` +- **Prompts** - `Prompt`, `PromptMessage`, `GetPromptRequest`, `GetPromptResult` +- **Logging** - `LoggingMessageNotification`, `SetLevelRequest` + +### Client Types (`WP\McpSchema\Client\`) + +- **Sampling** - `CreateMessageRequest`, `CreateMessageResult`, `SamplingMessage` +- **Elicitation** - `ElicitRequest`, `ElicitResult` +- **Roots** - `ListRootsRequest`, `ListRootsResult`, `Root` + +### Common Types (`WP\McpSchema\Common\`) + +- **Protocol** - `InitializeRequest`, `InitializeResult`, `PingRequest` +- **Content** - `TextContent`, `ImageContent`, `AudioContent` +- **JSON-RPC** - `JSONRPCRequest`, `JSONRPCNotification`, `JSONRPCResultResponse`, `JSONRPCErrorResponse` + +## Generator + +The PHP code in `src/` is auto-generated from the official MCP TypeScript schema. The generator is located in the `generator/` directory and is not included in the Composer package. + +See [generator/README.md](generator/README.md) for setup and usage instructions. + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +## Links + +- [Model Context Protocol Specification](https://spec.modelcontextprotocol.io/) +- [MCP GitHub Repository](https://github.com/modelcontextprotocol/modelcontextprotocol) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..03d5079 --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "wordpress/php-mcp-schema", + "description": "PHP DTOs for the Model Context Protocol (MCP) specification", + "type": "library", + "license": "MIT", + "keywords": [ + "mcp", + "model-context-protocol", + "dto", + "schema", + "php" + ], + "homepage": "https://github.com/WordPress/php-mcp-schema", + "authors": [ + { + "name": "WordPress", + "homepage": "https://wordpress.org" + } + ], + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpstan/phpstan": "^1.10" + }, + "autoload": { + "psr-4": { + "WP\\McpSchema\\": "src/" + } + }, + "config": { + "sort-packages": true + }, + "scripts": { + "analyse": "phpstan analyse", + "phpstan": "@analyse" + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..c904390 --- /dev/null +++ b/composer.lock @@ -0,0 +1,74 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "68b71570b5641a18d3a40e18e1019722", + "packages": [], + "packages-dev": [ + { + "name": "phpstan/phpstan", + "version": "1.12.32", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", + "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-09-30T10:16:31+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=7.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/generator/.prettierrc b/generator/.prettierrc new file mode 100644 index 0000000..b6b0fde --- /dev/null +++ b/generator/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/generator/README.md b/generator/README.md new file mode 100644 index 0000000..780d71b --- /dev/null +++ b/generator/README.md @@ -0,0 +1,60 @@ +# MCP PHP Schema Generator + +TypeScript generator that creates PHP DTOs from the official MCP TypeScript schema. + +## Setup + +```bash +npm install +npm run build +``` + +## Running the Generator + +The generator requires a configuration file that specifies the schema version. Config files are located in `config/`. + +**Available versions:** +- `2024-11-05.json` - Initial MCP release +- `2025-03-26.json` +- `2025-06-18.json` +- `2025-11-25.json` - Latest + +**Generate PHP schema:** + +```bash +npx mcp-php-generator generate -c config/2025-11-25.json +``` + +**Generate and run PHPStan validation:** + +```bash +npm run generate:check -- -c config/2025-11-25.json +``` + +## CLI Options + +```bash +npx mcp-php-generator generate --help + +Options: + -c, --config Configuration file (required) + -o, --output Output directory (overrides config) + -n, --namespace PHP namespace (overrides config) + -p, --php-version PHP version (overrides config) + --builders Generate builder classes + --no-factories Disable factory generation + --dry-run Show what would be generated without writing files + --fresh Force fresh fetch from GitHub (ignore cache) + --verbose Enable verbose output +``` + +## Other Commands + +```bash +npm run build # Compile TypeScript +npm run lint # Run ESLint +npm run format # Run Prettier +npx mcp-php-generator info # Show generator info +npx mcp-php-generator configs # List available config files +npx mcp-php-generator clear-cache # Clear schema cache +``` \ No newline at end of file diff --git a/generator/config/2024-11-05.json b/generator/config/2024-11-05.json new file mode 100644 index 0000000..b765318 --- /dev/null +++ b/generator/config/2024-11-05.json @@ -0,0 +1,8 @@ +{ + "schema": { + "version": "2024-11-05" + }, + "output": { + "generateBuilders": false + } +} diff --git a/generator/config/2025-03-26.json b/generator/config/2025-03-26.json new file mode 100644 index 0000000..3ed7d23 --- /dev/null +++ b/generator/config/2025-03-26.json @@ -0,0 +1,8 @@ +{ + "schema": { + "version": "2025-03-26" + }, + "output": { + "generateBuilders": false + } +} diff --git a/generator/config/2025-06-18.json b/generator/config/2025-06-18.json new file mode 100644 index 0000000..9f9e7b2 --- /dev/null +++ b/generator/config/2025-06-18.json @@ -0,0 +1,8 @@ +{ + "schema": { + "version": "2025-06-18" + }, + "output": { + "generateBuilders": false + } +} diff --git a/generator/config/2025-11-25.json b/generator/config/2025-11-25.json new file mode 100644 index 0000000..c99c447 --- /dev/null +++ b/generator/config/2025-11-25.json @@ -0,0 +1,8 @@ +{ + "schema": { + "version": "2025-11-25" + }, + "output": { + "generateBuilders": false + } +} diff --git a/generator/config/versions.json b/generator/config/versions.json new file mode 100644 index 0000000..95bf1e6 --- /dev/null +++ b/generator/config/versions.json @@ -0,0 +1,10 @@ +{ + "$schema": "./versions.schema.json", + "description": "List of available MCP schema versions in chronological order. Used by the version tracker to determine which versions to compare when generating changelogs.", + "versions": [ + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25" + ] +} diff --git a/generator/docs/README.md b/generator/docs/README.md new file mode 100644 index 0000000..f54d421 --- /dev/null +++ b/generator/docs/README.md @@ -0,0 +1,118 @@ +# MCP PHP Schema Generator + +A TypeScript application that generates PHP 7.4 DTOs directly from the MCP TypeScript schema. + +## Overview + +The generator fetches the official MCP TypeScript schema from GitHub, parses it using ts-morph AST analysis, and produces production-quality PHP code including: + +- **DTOs** - Data Transfer Objects with `fromArray()`/`toArray()` methods +- **Enums** - Class-based enums (PHP 7.4 compatible) +- **Union Interfaces** - Marker interfaces for polymorphic types +- **Factories** - Discriminator-based instantiation for unions +- **Builders** - Optional fluent builder pattern classes +- **Contracts** - Marker interfaces for type hierarchies + +## Quick Start + +```bash +# Install dependencies +npm install + +# Build the generator +npm run build + +# Generate PHP schema (uses latest config) +npm run generate + +# Or run directly with a specific config +node dist/cli/index.js generate -c config/2025-11-25.json +``` + +## CLI Commands + +```bash +# Generate PHP files from schema +generate -c [options] + -c, --config Config file path (required) + -o, --output Override output directory + -n, --namespace Override PHP namespace + -p, --php-version PHP version (7.4-8.3) + --builders Enable builder generation + --no-factories Disable factory generation + --dry-run Preview without writing files + --fresh Force fetch from GitHub (ignore cache) + --verbose Show detailed progress + +# Clear cached schemas +clear-cache + +# Show generator info +info + +# List available config versions +configs +``` + +## Configuration + +Configuration files are stored in `config/` as JSON: + +```json +{ + "schema": { + "version": "2025-11-25" + }, + "output": { + "generateBuilders": false + } +} +``` + +See [Configuration Guide](./configuration.md) for all options. + +## Generated Output + +The generator produces PHP files organized by MCP domain: + +``` +src/ +├── Common/ +│ ├── Protocol/ # Core protocol types +│ ├── JsonRpc/ # JSON-RPC message types +│ └── Content/ # Content block types +├── Server/ +│ ├── Tools/ # Tool definitions +│ ├── Resources/ # Resource management +│ ├── Prompts/ # Prompt templates +│ └── Logging/ # Logging types +├── Client/ +│ ├── Sampling/ # LLM sampling +│ ├── Elicitation/ # User input elicitation +│ ├── Roots/ # Root directory management +│ └── Tasks/ # Background tasks +└── Contracts/ # Shared interfaces +``` + +## Documentation + +- [Architecture](./architecture.md) - Pipeline and module overview +- [Configuration](./configuration.md) - All configuration options +- [Design Decisions](./design-decisions.md) - Key architectural choices + +## Development + +```bash +npm run lint # ESLint +npm run format # Prettier +npm run build # Compile TypeScript +``` + +## Validation + +Generated PHP must pass PHPStan level 8: + +```bash +cd .. +composer analyse +``` diff --git a/generator/docs/architecture.md b/generator/docs/architecture.md new file mode 100644 index 0000000..d92243c --- /dev/null +++ b/generator/docs/architecture.md @@ -0,0 +1,222 @@ +# Architecture + +## Directory Structure + +``` +generator/ +├── src/ +│ ├── index.ts # Main entry point & orchestration +│ ├── cli/ +│ │ └── index.ts # Commander.js CLI interface +│ ├── config/ +│ │ └── index.ts # Configuration management +│ ├── types/ +│ │ └── index.ts # TypeScript type definitions +│ ├── fetcher/ +│ │ └── index.ts # Schema fetching with caching +│ ├── parser/ +│ │ └── index.ts # ts-morph AST parsing +│ ├── extractors/ +│ │ ├── index.ts # Extractor exports +│ │ └── synthetic-dto.ts # Inline object type extraction +│ ├── generators/ +│ │ ├── index.ts # Generator exports +│ │ ├── domain-classifier.ts # Type domain/subdomain mapping +│ │ ├── type-mapper.ts # TypeScript → PHP type mapping +│ │ ├── type-resolver.ts # Type reference resolution +│ │ ├── inheritance-graph.ts # Inheritance tracking +│ │ ├── dto.ts # DTO class generation +│ │ ├── enum.ts # Enum class generation +│ │ ├── union.ts # Union interface generation +│ │ ├── factory.ts # Factory class generation +│ │ ├── builder.ts # Builder class generation +│ │ └── contract.ts # Contract interface generation +│ ├── writers/ +│ │ └── index.ts # File writing & base classes +│ └── version-tracker/ +│ └── index.ts # Schema version tracking +├── config/ # Version-specific configs +└── dist/ # Compiled output +``` + +## Generation Pipeline + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ INPUT: schema.ts │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 1. FETCH SCHEMA │ +│ fetchSchema() / fetchSchemaFresh() │ +│ - GitHub raw content API │ +│ - Cache: .cache/schemas/{repo}_{version}_schema.ts │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 2. PARSE SCHEMA │ +│ parseSchema() using ts-morph │ +│ - Extracts interfaces, type aliases, enums │ +│ - Handles JSDoc tags (@category, @internal) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 3. EXTRACT SYNTHETIC TYPES │ +│ SyntheticDtoExtractor.extract() │ +│ - Converts inline objects: { foo: string } → synthetic DTOs │ +│ - Recursive extraction for nested objects │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 4. BUILD UNION MEMBERSHIP MAP │ +│ - Maps DTOs to their union interfaces │ +│ - Detects discriminator fields and values │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 5. BUILD INHERITANCE GRAPH │ +│ buildInheritanceGraph() │ +│ - Parent-child relationships from extends │ +│ - Topological sort for generation order │ +│ - Property classification (own/inherited/narrowed) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 6. CLASSIFY DOMAINS │ +│ DomainClassifier.classify() │ +│ - @category tags → domain/subdomain │ +│ - Fallback: name-based pattern matching │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 7. GENERATE PHP CODE │ +│ ├── Base Classes (AbstractDataTransferObject, AbstractEnum) │ +│ ├── DTOs (from interfaces) │ +│ ├── Enums (from string literal unions) │ +│ ├── Union Interfaces (from object type unions) │ +│ ├── Factories (for discriminated unions) │ +│ ├── Builders (optional, fluent construction) │ +│ └── Contracts (marker interfaces) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 8. WRITE FILES │ +│ FileWriter.writeFiles() │ +│ - Directory structure by domain/subdomain │ +│ - Dry-run mode support │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ OUTPUT: PHP files in src/ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Module Responsibilities + +### Fetcher (`fetcher/index.ts`) + +Retrieves the MCP TypeScript schema from GitHub with local caching. + +- `fetchSchema()` - Uses cache if available +- `fetchSchemaFresh()` - Bypasses cache +- `clearCache()` - Removes cached schemas + +### Parser (`parser/index.ts`) + +Uses ts-morph for TypeScript AST parsing. + +- `parseSchema()` - Parse TypeScript content +- `extractInterfaces()` - Get interface declarations +- `extractTypeAliases()` - Get type aliases +- `resolveInheritance()` - Flatten inheritance chain + +### Extractors (`extractors/`) + +**SyntheticDtoExtractor** - Handles inline object types: + +```typescript +// Input: property: { nested: string; value: number } +// Output: Creates ParentPropertyName interface +``` + +### Generators (`generators/`) + +**DtoGenerator** - PHP DTO classes with: +- Constructor with validation +- `fromArray()` static factory (auto-hydrates nested objects) +- `toArray()` serialization (recursive) +- Proper inheritance (`extends`) + +**EnumGenerator** - Class-based enums: +- Constants for each value +- Static factory methods +- `values()` method + +**UnionGenerator** - Marker interfaces: +- Interface per union type +- DTOs implement their union interfaces + +**FactoryGenerator** - Discriminator-based routing: +- Detects discriminator field (`method`, `type`, `kind`, `role`) +- Switch statement routing to concrete types +- Returns `null` if no discriminator detected + +**BuilderGenerator** - Fluent builders: +- `withPropertyName()` setters +- `build()` returns DTO + +**ContractGenerator** - Marker interfaces: +- `WithArrayTransformation` +- `ResultContract`, `RequestContract`, etc. + +### Type Mapping (`generators/type-mapper.ts`) + +TypeScript to PHP type conversion: + +| TypeScript | PHP | +|------------|-----| +| `string` | `string` | +| `number` | `int` or `float` (context-aware) | +| `boolean` | `bool` | +| `null` | `null` | +| `Type[]` | `array` with PHPDoc | +| `Type \| null` | `?Type` | +| inline object | Synthetic DTO | + +Integer detection patterns: `*Id`, `*Length`, `*Count`, `*Index`, `*Items` + +### Domain Classifier (`generators/domain-classifier.ts`) + +Maps `@category` tags to PHP namespaces: + +| Category | Domain | Subdomain | +|----------|--------|-----------| +| `Tools` | Server | Tools | +| `Resources` | Server | Resources | +| `Sampling` | Client | Sampling | +| `JSON-RPC` | Common | JsonRpc | + +### Inheritance Graph (`generators/inheritance-graph.ts`) + +Manages TypeScript `extends` relationships: + +- Builds parent-child maps +- Provides topological sort (parents before children) +- Classifies properties as own/inherited/narrowed + +### Writers (`writers/index.ts`) + +File output with organization: + +- DTOs: `Domain/Subdomain/ClassName.php` +- Others: `Domain/Subdomain/Type/ClassName.php` +- Generates base classes (`AbstractDataTransferObject`, `AbstractEnum`, `ValidatesRequiredFields` trait) diff --git a/generator/docs/configuration.md b/generator/docs/configuration.md new file mode 100644 index 0000000..db43aa7 --- /dev/null +++ b/generator/docs/configuration.md @@ -0,0 +1,118 @@ +# Configuration + +## Config File Format + +Configuration files are JSON stored in `config/`: + +```json +{ + "schema": { + "type": "github", + "repository": "modelcontextprotocol/modelcontextprotocol", + "branch": "main", + "path": "schema", + "version": "2025-11-25" + }, + "output": { + "outputDir": "../src", + "namespace": "WP\\McpSchema", + "phpVersion": "7.4", + "indentation": "spaces", + "indentSize": 4, + "generateBuilders": false, + "generateFactories": true + } +} +``` + +## Schema Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `type` | `"github"` \| `"local"` | `"github"` | Schema source type | +| `repository` | `string` | `"modelcontextprotocol/modelcontextprotocol"` | GitHub repository | +| `branch` | `string` | `"main"` | Git branch | +| `path` | `string` | `"schema"` | Path within repository | +| `version` | `string` | **required** | Schema version (e.g., `"2025-11-25"`) | + +## Output Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `outputDir` | `string` | `"../src"` | Output directory (relative to generator/) | +| `namespace` | `string` | `"WP\\McpSchema"` | Base PHP namespace | +| `phpVersion` | `string` | `"7.4"` | Target PHP version (`"7.4"`-`"8.3"`) | +| `indentation` | `"spaces"` \| `"tabs"` | `"spaces"` | Indentation style | +| `indentSize` | `number` | `4` | Spaces per indent (1-8) | +| `generateBuilders` | `boolean` | `false` | Generate fluent builder classes | +| `generateFactories` | `boolean` | `true` | Generate factory classes for unions | + +## CLI Overrides + +CLI options override config file values: + +```bash +# Override output directory +node dist/cli/index.js generate -c config/2025-11-25.json -o /custom/path + +# Override namespace +node dist/cli/index.js generate -c config/2025-11-25.json -n "My\\Namespace" + +# Enable builders (overrides config) +node dist/cli/index.js generate -c config/2025-11-25.json --builders + +# Disable factories (overrides config) +node dist/cli/index.js generate -c config/2025-11-25.json --no-factories +``` + +## Generation Options + +These are runtime options, not persisted in config: + +| Option | Description | +|--------|-------------| +| `--dry-run` | Preview files without writing | +| `--fresh` | Force fetch from GitHub (bypass cache) | +| `--verbose` | Show detailed progress messages | + +## Minimal Config + +Only `schema.version` is required; all other options use defaults: + +```json +{ + "schema": { + "version": "2025-11-25" + } +} +``` + +## Version-Specific Configs + +The `config/` directory contains version-specific configurations: + +- `2024-11-05.json` - MCP 2024-11-05 +- `2025-03-26.json` - MCP 2025-03-26 +- `2025-06-18.json` - MCP 2025-06-18 +- `2025-11-25.json` - MCP 2025-11-25 (latest) + +List available versions: + +```bash +node dist/cli/index.js configs +``` + +## Caching + +Fetched schemas are cached in `.cache/schemas/`: + +``` +.cache/schemas/ +└── modelcontextprotocol_modelcontextprotocol_2025-11-25_schema.ts +``` + +Clear cache: + +```bash +node dist/cli/index.js clear-cache +``` diff --git a/generator/docs/design-decisions.md b/generator/docs/design-decisions.md new file mode 100644 index 0000000..d9dfa8a --- /dev/null +++ b/generator/docs/design-decisions.md @@ -0,0 +1,235 @@ +# Design Decisions + +Key architectural decisions made during development. + +## TypeScript AST over JSON Schema + +The generator parses the TypeScript schema directly using ts-morph instead of the JSON Schema. + +**Why TypeScript:** + +- **Single source of truth** - TypeScript is the authoritative MCP schema; JSON Schema is derived from it +- **Richer type information** - Preserves `extends` relationships, JSDoc tags (`@category`, `@internal`), and inline object types +- **Inheritance chains** - TypeScript `extends` maps directly to PHP class inheritance; JSON Schema flattens these into `allOf` +- **Domain classification** - `@category` JSDoc tags enable accurate domain/subdomain organization +- **Union semantics** - TypeScript distinguishes string literal unions (enums) from object type unions (polymorphic interfaces) + +**JSON Schema limitations:** + +- Flattens inheritance into `allOf` compositions (loses hierarchy) +- No JSDoc metadata (`@category` tags unavailable) +- Inline objects become anonymous `$defs` entries +- Union types lose semantic context + +**Trade-off:** Requires ts-morph dependency for AST parsing, but produces more accurate PHP output. + +## PHP 7.4 Compatibility + +The generator targets PHP 7.4 for maximum compatibility: + +- **No native enums** - Uses class-based enums with constants +- **No union types** - Uses PHPDoc annotations instead +- **No `mixed` type** - Leaves properties untyped when needed +- **No `readonly`** - Uses `protected` properties with getters +- **Typed properties** - Leverages PHP 7.4's typed property support + +## True Class Inheritance + +PHP DTOs mirror the TypeScript `extends` hierarchy instead of flattening: + +```php +// Generated inheritance chain +class Request extends AbstractDataTransferObject { ... } +class JSONRPCRequest extends Request { ... } +class InitializeRequest extends JSONRPCRequest { ... } +``` + +**Benefits:** +- Eliminates code duplication +- Enables `instanceof` checks +- Mirrors TypeScript structure + +**Implementation details:** +- Properties are `protected` (not `private`) for child access +- `fromArray()` returns `static` for late static binding +- `toArray()` merges with `parent::toArray()` +- Constructors call `parent::__construct()` +- Topological sort ensures parents generate before children + +## Property Type Narrowing + +When child types narrow a property type, we avoid LSP violations: + +```php +// Parent +class Request { + protected ?array $params; +} + +// Child with narrowed type uses separate property +class InitializeRequest extends Request { + protected ?InitializeRequestParams $typedParams; + + public function getTypedParams(): ?InitializeRequestParams { ... } +} +``` + +## Synthetic DTOs + +Inline object types become separate DTO classes: + +```typescript +// TypeScript +interface Parent { + config: { enabled: boolean; timeout: number }; +} +``` + +```php +// Generated PHP +class Parent { + protected ParentConfig $config; +} + +class ParentConfig { + protected bool $enabled; + protected int $timeout; +} +``` + +Naming convention: `{ParentName}{PropertyName}` (PascalCase). + +## Union Type Handling + +### String Literal Unions → Enums + +```typescript +type LoggingLevel = "debug" | "info" | "warning" | "error"; +``` + +```php +class LoggingLevel extends AbstractEnum { + public const DEBUG = 'debug'; + public const INFO = 'info'; + // ... + public static function debug(): self { ... } +} +``` + +### Object Type Unions → Interfaces + Factories + +```typescript +type ContentBlock = TextContent | ImageContent | AudioContent; +``` + +```php +interface ContentBlockInterface { + public function toArray(): array; +} + +class ContentBlockFactory { + public static function fromArray(array $data): ContentBlockInterface { + switch ($data['type'] ?? null) { + case 'text': return TextContent::fromArray($data); + case 'image': return ImageContent::fromArray($data); + // ... + } + } +} +``` + +## Discriminator Detection + +Factories detect discriminator fields with this priority: + +1. `method` - For JSON-RPC requests/notifications +2. `type` - For content blocks, schemas +3. `kind` - For alternative type indicators +4. `role` - For message roles + +If no common discriminator exists, no factory is generated. + +## Auto-Hydration + +`fromArray()` automatically hydrates nested objects: + +```php +public static function fromArray(array $data): static { + return new static( + // Nested DTO auto-hydrated + isset($data['config']) + ? Config::fromArray($data['config']) + : null, + // Union type uses factory + isset($data['content']) + ? ContentBlockFactory::fromArray($data['content']) + : null + ); +} +``` + +`toArray()` recursively serializes: + +```php +public function toArray(): array { + return array_merge(parent::toArray(), [ + 'config' => $this->config?->toArray(), + 'content' => $this->content?->toArray(), + ]); +} +``` + +## Domain Classification + +Types are organized by MCP domain using `@category` JSDoc tags: + +| @category | Domain | Subdomain | +|-----------|--------|-----------| +| `Tools` | Server | Tools | +| `Resources` | Server | Resources | +| `Prompts` | Server | Prompts | +| `Sampling` | Client | Sampling | +| `Elicitation` | Client | Elicitation | +| `JSON-RPC` | Common | JsonRpc | +| `Protocol` | Common | Protocol | + +Fallback: Name-based pattern matching (e.g., `Tool*` → Server/Tools). + +## Integer vs Float + +TypeScript `number` maps to PHP `int` or `float` based on context: + +**Integer patterns:** `*Id`, `*Length`, `*Count`, `*Index`, `*Items`, `*Size` + +```php +protected int $maxTokens; // count pattern +protected float $temperature; // no pattern match +``` + +## Required Field Validation + +DTOs use the `ValidatesRequiredFields` trait: + +```php +use ValidatesRequiredFields; + +public function __construct(string $method, ?array $params = null) { + $this->validateRequired(['method' => $method]); + // ... +} +``` + +## Union Membership + +DTOs implement their union interfaces: + +```php +class TextContent extends AbstractDataTransferObject + implements ContentBlockInterface, SamplingMessageContentBlockInterface +{ + public const DISCRIMINATOR_FIELD = 'type'; + public const DISCRIMINATOR_VALUE = 'text'; +} +``` + +Constants enable runtime type inspection without reflection. diff --git a/generator/eslint.config.js b/generator/eslint.config.js new file mode 100644 index 0000000..62f1676 --- /dev/null +++ b/generator/eslint.config.js @@ -0,0 +1,36 @@ +import eslint from '@eslint/js'; +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; +import prettier from 'eslint-config-prettier'; + +export default [ + eslint.configs.recommended, + { + files: ['src/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + project: './tsconfig.json', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + ...tseslint.configs['recommended-requiring-type-checking'].rules, + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-floating-promises': 'error', + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, + }, + prettier, + { + ignores: ['dist/**', 'node_modules/**', '*.config.js'], + }, +]; diff --git a/generator/package-lock.json b/generator/package-lock.json new file mode 100644 index 0000000..1e160d2 --- /dev/null +++ b/generator/package-lock.json @@ -0,0 +1,1918 @@ +{ + "name": "@wordpress/php-mcp-schema-generator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@wordpress/php-mcp-schema-generator", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.1.0", + "fs-extra": "^11.2.0", + "ora": "^8.1.0", + "ts-morph": "^24.0.0" + }, + "bin": { + "mcp-php-generator": "dist/cli/index.js" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/node": "^22.10.1", + "@typescript-eslint/eslint-plugin": "^8.17.0", + "@typescript-eslint/parser": "^8.17.0", + "eslint": "^9.16.0", + "eslint-config-prettier": "^9.1.0", + "prettier": "^3.4.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", + "license": "MIT", + "dependencies": { + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.9" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", + "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/type-utils": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", + "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", + "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-morph": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.25.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/generator/package.json b/generator/package.json new file mode 100644 index 0000000..75ea37c --- /dev/null +++ b/generator/package.json @@ -0,0 +1,65 @@ +{ + "name": "@wordpress/php-mcp-schema-generator", + "version": "1.0.0", + "description": "Generate PHP 7.4 DTOs from MCP TypeScript schema definitions", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "mcp-php-generator": "dist/cli/index.js" + }, + "scripts": { + "build": "tsc", + "generate": "rm -rf ../src && node dist/cli/index.js generate", + "generate:check": "bash -c 'rm -rf ../src && node dist/cli/index.js generate \"$@\" && cd .. && composer analyse' --", + "lint": "eslint src --ext .ts", + "lint:fix": "eslint src --ext .ts --fix", + "format": "prettier --write \"src/**/*.ts\"", + "clean": "rm -rf dist", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "php", + "dto", + "schema", + "generator", + "typescript" + ], + "author": "Automattic", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/php-mcp-schema.git" + }, + "bugs": { + "url": "https://github.com/WordPress/php-mcp-schema/issues" + }, + "homepage": "https://github.com/WordPress/php-mcp-schema#readme", + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "dependencies": { + "ts-morph": "^24.0.0", + "commander": "^12.1.0", + "chalk": "^5.3.0", + "ora": "^8.1.0", + "fs-extra": "^11.2.0" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/node": "^22.10.1", + "@typescript-eslint/eslint-plugin": "^8.17.0", + "@typescript-eslint/parser": "^8.17.0", + "eslint": "^9.16.0", + "eslint-config-prettier": "^9.1.0", + "prettier": "^3.4.2", + "typescript": "^5.7.2" + } +} diff --git a/generator/src/cli/index.ts b/generator/src/cli/index.ts new file mode 100644 index 0000000..f4696f1 --- /dev/null +++ b/generator/src/cli/index.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * MCP PHP Schema Generator - CLI + * + * Command-line interface for generating PHP DTOs from MCP TypeScript schema. + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { generate, GenerateOptions } from '../index.js'; +import { createConfig, DEFAULT_SCHEMA_SOURCE, DEFAULT_PHP_OUTPUT, loadConfigFromFile, listConfigVersions } from '../config/index.js'; +import { clearCache } from '../fetcher/index.js'; + +const program = new Command(); + +program + .name('mcp-php-generator') + .description('Generate PHP 7.4 DTOs from MCP TypeScript schema') + .version('1.0.0'); + +// Generate command +program + .command('generate') + .description('Generate PHP DTOs from MCP schema') + .requiredOption('-c, --config ', 'Configuration file (required)') + .option('-o, --output ', 'Output directory (overrides config)') + .option('-n, --namespace ', 'PHP namespace (overrides config)') + .option('-p, --php-version ', 'PHP version (overrides config)') + .option('--builders', 'Generate builder classes') + .option('--no-factories', 'Disable factory generation') + .option('--dry-run', 'Show what would be generated without writing files') + .option('--fresh', 'Force fresh fetch from GitHub (ignore cache)') + .option('--verbose', 'Enable verbose output') + .action(async (options: Record) => { + const spinner = ora('Initializing...').start(); + + try { + const configFile = options['config'] as string; + + // Load config from file + spinner.text = `Loading config from ${configFile}...`; + const baseConfig = loadConfigFromFile(configFile); + + // CLI options override config file values + const schemaConfig = { ...baseConfig.schema }; + + const outputConfig = { ...baseConfig.output }; + if (options['output']) outputConfig.outputDir = options['output'] as string; + if (options['namespace']) outputConfig.namespace = options['namespace'] as string; + if (options['phpVersion']) outputConfig.phpVersion = options['phpVersion'] as typeof outputConfig.phpVersion; + if (options['builders']) outputConfig.generateBuilders = true; + if (options['factories'] === false) outputConfig.generateFactories = false; + + const config = createConfig({ + schema: schemaConfig, + output: outputConfig, + verbose: (options['verbose'] as boolean) ?? baseConfig.verbose, + dryRun: (options['dryRun'] as boolean) ?? baseConfig.dryRun, + }); + + const generateOptions: GenerateOptions = { + fresh: options['fresh'] as boolean, + }; + + spinner.text = 'Fetching schema...'; + const result = await generate(config, generateOptions); + + spinner.succeed('Generation complete!'); + + console.log(''); + console.log(chalk.bold('Summary:')); + console.log(` ${chalk.green('✓')} DTOs: ${result.stats.dtos}`); + console.log(` ${chalk.green('✓')} Enums: ${result.stats.enums}`); + console.log(` ${chalk.green('✓')} Unions: ${result.stats.unions}`); + console.log(` ${chalk.green('✓')} Factories: ${result.stats.factories}`); + console.log(` ${chalk.green('✓')} Builders: ${result.stats.builders}`); + console.log(` ${chalk.blue('⏱')} Duration: ${result.stats.duration}ms`); + + if (result.errors.length > 0) { + console.log(''); + console.log(chalk.yellow('Warnings:')); + for (const error of result.errors) { + console.log(` ${chalk.yellow('!')} ${error.message}`); + } + } + + if (config.dryRun) { + console.log(''); + console.log(chalk.cyan('(dry-run mode - no files were written)')); + } + } catch (error) { + spinner.fail('Generation failed'); + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red(`Error: ${message}`)); + process.exit(1); + } + }); + +// Clear cache command +program + .command('clear-cache') + .description('Clear the schema cache') + .action(async () => { + const spinner = ora('Clearing cache...').start(); + + try { + await clearCache(); + spinner.succeed('Cache cleared'); + } catch (error) { + spinner.fail('Failed to clear cache'); + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red(`Error: ${message}`)); + process.exit(1); + } + }); + +// Info command +program + .command('info') + .description('Show generator information') + .action(() => { + console.log(chalk.bold('MCP PHP Schema Generator')); + console.log(''); + console.log('Generates PHP 7.4 DTOs from the Model Context Protocol TypeScript schema.'); + console.log(''); + console.log(chalk.bold('Default Configuration:')); + console.log(` Schema Repository: ${DEFAULT_SCHEMA_SOURCE.repository}`); + console.log(` Schema Branch: ${DEFAULT_SCHEMA_SOURCE.branch}`); + console.log(` Output Directory: ${DEFAULT_PHP_OUTPUT.outputDir}`); + console.log(` PHP Namespace: ${DEFAULT_PHP_OUTPUT.namespace}`); + console.log(` PHP Version: ${DEFAULT_PHP_OUTPUT.phpVersion}`); + console.log(''); + + const versions = listConfigVersions(); + if (versions.length > 0) { + console.log(chalk.bold('Available Config Files:')); + for (const version of versions) { + console.log(` - ${version}.json`); + } + console.log(''); + } + + console.log(chalk.yellow('Note: --config is required. Schema version must be set in config file.')); + console.log(''); + console.log('For more information, see: https://github.com/WordPress/php-mcp-schema'); + }); + +// List configs command +program + .command('configs') + .description('List available configuration files') + .action(() => { + const versions = listConfigVersions(); + if (versions.length === 0) { + console.log(chalk.yellow('No configuration files found in config/ directory.')); + return; + } + + console.log(chalk.bold('Available Configuration Files:')); + console.log(''); + for (const version of versions) { + console.log(` ${chalk.green('•')} ${version}.json`); + } + console.log(''); + console.log(`Use ${chalk.cyan('--config config/.json')} to generate.`); + }); + +program.parse(); diff --git a/generator/src/config/index.ts b/generator/src/config/index.ts new file mode 100644 index 0000000..e4c2939 --- /dev/null +++ b/generator/src/config/index.ts @@ -0,0 +1,187 @@ +/** + * MCP PHP Schema Generator - Configuration + * + * Default configuration and configuration utilities. + */ + +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import type { GeneratorConfig, PhpOutputConfig, SchemaSource } from '../types/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Default schema source configuration (version must be provided via config file). + */ +export const DEFAULT_SCHEMA_SOURCE: Omit = { + type: 'github', + repository: 'modelcontextprotocol/modelcontextprotocol', + branch: 'main', + path: 'schema', +}; + +/** + * Default PHP output configuration. + */ +export const DEFAULT_PHP_OUTPUT: PhpOutputConfig = { + outputDir: '../src', + namespace: 'WP\\McpSchema', + phpVersion: '7.4', + indentation: 'spaces', + indentSize: 4, + generateBuilders: false, + generateFactories: true, +}; + +/** + * Creates a generator configuration by merging provided options with defaults. + * @param options - Configuration options (schema.version is required) + * @throws Error if schema.version is not provided + */ +export function createConfig(options: Partial & { schema: { version: string } }): GeneratorConfig { + if (!options.schema?.version) { + throw new Error('Schema version is required. Use --config to specify a config file.'); + } + + return { + schema: { + ...DEFAULT_SCHEMA_SOURCE, + ...options.schema, + } as SchemaSource, + output: { + ...DEFAULT_PHP_OUTPUT, + ...options.output, + }, + verbose: options.verbose ?? false, + dryRun: options.dryRun ?? false, + }; +} + +/** + * Validates a generator configuration. + * @throws Error if configuration is invalid + */ +export function validateConfig(config: GeneratorConfig): void { + // Validate schema source + if (config.schema.type === 'github') { + if (!config.schema.repository) { + throw new Error('GitHub repository is required when schema type is "github"'); + } + } else if (config.schema.type === 'local') { + if (!config.schema.path) { + throw new Error('Local path is required when schema type is "local"'); + } + } + + // Validate PHP version + const validPhpVersions = ['7.4', '8.0', '8.1', '8.2', '8.3']; + if (!validPhpVersions.includes(config.output.phpVersion)) { + throw new Error(`Invalid PHP version: ${config.output.phpVersion}`); + } + + // Validate indentation + if (config.output.indentSize < 1 || config.output.indentSize > 8) { + throw new Error('Indent size must be between 1 and 8'); + } +} + +/** + * Gets the full GitHub URL for the schema. + */ +export function getSchemaGitHubUrl(config: GeneratorConfig): string { + const { repository, branch, path, version } = config.schema; + return `https://raw.githubusercontent.com/${repository}/${branch}/${path}/${version}/schema.ts`; +} + +/** + * Gets the output path for a generated file. + * DTOs are placed directly in the subdomain folder, other types get their own subfolder. + */ +export function getOutputPath( + config: GeneratorConfig, + domain: string, + subdomain: string, + type: string, + filename: string +): string { + const { outputDir } = config.output; + // DTOs go directly in subdomain folder, other types get their own subfolder + if (type === 'Dto') { + return `${outputDir}/${domain}/${subdomain}/${filename}`; + } + return `${outputDir}/${domain}/${subdomain}/${type}/${filename}`; +} + +/** + * Gets the path to the config directory. + */ +export function getConfigDir(): string { + // In compiled code, __dirname is dist/config, so go up two levels to get to generator/ + return resolve(__dirname, '../../config'); +} + +/** + * Gets the path to a config file by version. + */ +export function getConfigPath(version: string): string { + return resolve(getConfigDir(), `${version}.json`); +} + +/** + * Loads configuration from a JSON file. + * @param filePath - Path to the JSON config file + * @returns Parsed configuration merged with defaults + * @throws Error if file doesn't exist, is invalid JSON, or missing version + */ +export function loadConfigFromFile(filePath: string): GeneratorConfig { + const absolutePath = resolve(filePath); + + if (!existsSync(absolutePath)) { + throw new Error(`Config file not found: ${absolutePath}`); + } + + try { + const content = readFileSync(absolutePath, 'utf-8'); + const fileConfig = JSON.parse(content) as Partial; + + if (!fileConfig.schema?.version) { + throw new Error(`Config file missing required "schema.version": ${absolutePath}`); + } + + return createConfig(fileConfig as Partial & { schema: { version: string } }); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON in config file: ${absolutePath}`); + } + throw error; + } +} + +/** + * Loads configuration from a version-named config file in the config directory. + * @param version - Schema version (e.g., "2025-11-25") + * @returns Parsed configuration merged with defaults + * @throws Error if file doesn't exist or is invalid JSON + */ +export function loadConfigByVersion(version: string): GeneratorConfig { + const configPath = getConfigPath(version); + return loadConfigFromFile(configPath); +} + +/** + * Lists available config versions in the config directory. + */ +export function listConfigVersions(): string[] { + const configDir = getConfigDir(); + if (!existsSync(configDir)) { + return []; + } + + const files = readdirSync(configDir); + return files + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace('.json', '')) + .sort(); +} diff --git a/generator/src/extractors/index.ts b/generator/src/extractors/index.ts new file mode 100644 index 0000000..16114d5 --- /dev/null +++ b/generator/src/extractors/index.ts @@ -0,0 +1,12 @@ +/** + * MCP PHP Schema Generator - Extractors + * + * Modules for extracting and transforming schema data. + */ + +export { + SyntheticDtoExtractor, + updateInterfacesWithSyntheticTypes, + type InlineObjectType, + type SyntheticExtractionResult, +} from './synthetic-dto.js'; diff --git a/generator/src/extractors/synthetic-dto.ts b/generator/src/extractors/synthetic-dto.ts new file mode 100644 index 0000000..27555da --- /dev/null +++ b/generator/src/extractors/synthetic-dto.ts @@ -0,0 +1,328 @@ +/** + * MCP PHP Schema Generator - Synthetic DTO Extractor + * + * Extracts inline object types from TypeScript interfaces and creates + * synthetic interfaces for PHP DTO generation. + */ + +import type { TsInterface, TsProperty } from '../types/index.js'; + +/** + * Extracted inline object type. + */ +export interface InlineObjectType { + readonly name: string; + readonly parentName: string; + readonly propertyName: string; + readonly properties: TsProperty[]; + readonly description?: string; + readonly depth: number; +} + +/** + * Result of synthetic DTO extraction. + */ +export interface SyntheticExtractionResult { + readonly interfaces: TsInterface[]; + readonly propertyTypeMap: Map; // "Parent.property" -> "SyntheticTypeName" +} + +/** + * Extracts synthetic DTOs from inline object types in interfaces. + */ +export class SyntheticDtoExtractor { + private readonly syntheticInterfaces: TsInterface[] = []; + private readonly propertyTypeMap = new Map(); + private readonly processedTypes = new Set(); + /** + * Tracks generated synthetic names per parent to detect collisions. + * Map: parentName -> Map originalPropertyName> + */ + private readonly generatedNamesPerParent = new Map>(); + + /** + * Extracts synthetic DTOs from all interfaces. + */ + extract(interfaces: readonly TsInterface[]): SyntheticExtractionResult { + for (const iface of interfaces) { + this.extractFromInterface(iface, iface.name, 0); + } + + return { + interfaces: this.syntheticInterfaces, + propertyTypeMap: this.propertyTypeMap, + }; + } + + /** + * Extracts inline types from an interface's properties. + */ + private extractFromInterface(iface: TsInterface, baseName: string, depth: number): void { + for (const prop of iface.properties) { + this.extractFromProperty(prop, baseName, depth); + } + } + + /** + * Extracts inline types from a property. + */ + private extractFromProperty(prop: TsProperty, parentName: string, depth: number): void { + let type = prop.type.trim(); + + // Handle nullable types: "{ ... } | undefined" -> "{ ... }" + const nullableMatch = type.match(/^(\{[\s\S]*\})\s*\|\s*undefined$/); + if (nullableMatch) { + type = nullableMatch[1]!; + } + + // Skip if not an inline object type + if (!this.isExtractableInlineType(type)) { + return; + } + + // Skip index signatures like { [key: string]: T } + if (this.isIndexSignature(type)) { + return; + } + + // Generate synthetic type name + const syntheticName = this.generateSyntheticName(parentName, prop.name); + + // Skip if already processed + const typeKey = `${parentName}.${prop.name}`; + if (this.processedTypes.has(typeKey)) { + return; + } + this.processedTypes.add(typeKey); + + // Parse inline object properties + const inlineProperties = this.parseInlineObjectProperties(type); + + // Create synthetic interface + const syntheticInterface: TsInterface = { + name: syntheticName, + description: prop.description, + extends: [], + properties: inlineProperties, + tags: [], + isSynthetic: true, + syntheticParent: parentName, + }; + + this.syntheticInterfaces.push(syntheticInterface); + this.propertyTypeMap.set(typeKey, syntheticName); + + // Recursively extract nested inline types + this.extractFromInterface(syntheticInterface, syntheticName, depth + 1); + } + + /** + * Checks if a type is an extractable inline object (not an index signature). + */ + private isExtractableInlineType(type: string): boolean { + if (!type.startsWith('{')) { + return false; + } + + // Must be balanced braces + let depth = 0; + for (const char of type) { + if (char === '{') depth++; + if (char === '}') depth--; + } + + return depth === 0 && type.endsWith('}'); + } + + /** + * Checks if a type is an index signature { [key: T]: V }. + */ + private isIndexSignature(type: string): boolean { + return /^\{\s*\[/.test(type); + } + + /** + * Generates a synthetic type name from parent and property names. + * + * Converts property names to PascalCase, handling: + * - Leading underscores: `_meta` → `Meta` + * - Underscore separators: `some_name` → `SomeName` + * - Already PascalCase: `SomeName` → `SomeName` + * + * Detects and throws on naming collisions (e.g., `_meta` and `meta` both becoming `Meta`). + * + * @throws Error if generated name collides with another property's synthetic name + */ + private generateSyntheticName(parentName: string, propertyName: string): string { + const pascalProperty = this.toPascalCase(propertyName); + const syntheticName = `${parentName}${pascalProperty}`; + + // Check for collision with previously generated names for this parent + let parentNames = this.generatedNamesPerParent.get(parentName); + if (!parentNames) { + parentNames = new Map(); + this.generatedNamesPerParent.set(parentName, parentNames); + } + + const existingPropertyName = parentNames.get(syntheticName); + if (existingPropertyName !== undefined && existingPropertyName !== propertyName) { + throw new Error( + `Synthetic name collision detected in '${parentName}':\n` + + ` Property '${existingPropertyName}' → '${syntheticName}'\n` + + ` Property '${propertyName}' → '${syntheticName}'\n` + + `Both properties would generate the same synthetic class name after PascalCase conversion.\n` + + `Please rename one of the properties to avoid collision.` + ); + } + + // Register this synthetic name + parentNames.set(syntheticName, propertyName); + + return syntheticName; + } + + /** + * Converts a string to PascalCase, handling underscores and edge cases. + * + * Examples: + * - `_meta` → `Meta` + * - `some_name` → `SomeName` + * - `someName` → `SomeName` + * - `__private` → `Private` + */ + private toPascalCase(str: string): string { + // Strip leading underscores and split by underscores + const cleanStr = str.replace(/^_+/, ''); + + // Split by underscores and capitalize each part + const parts = cleanStr.split('_').filter(part => part.length > 0); + + return parts + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + } + + /** + * Parses properties from an inline object type string. + * This is a simplified parser for common patterns. + */ + private parseInlineObjectProperties(type: string): TsProperty[] { + const properties: TsProperty[] = []; + + // Remove outer braces and trim + let content = type.slice(1, -1).trim(); + + // Split by semicolons while respecting nested braces + const propStrings = this.splitPropertyStrings(content); + + for (const propStr of propStrings) { + const prop = this.parsePropertyString(propStr.trim()); + if (prop) { + properties.push(prop); + } + } + + return properties; + } + + /** + * Splits property strings while respecting nested structures. + */ + private splitPropertyStrings(content: string): string[] { + const properties: string[] = []; + let current = ''; + let depth = 0; + let inString = false; + let stringChar = ''; + + for (let i = 0; i < content.length; i++) { + const char = content[i]!; + const prevChar = i > 0 ? content[i - 1] : ''; + + // Handle string literals + if ((char === '"' || char === "'") && prevChar !== '\\') { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + } + } + + if (!inString) { + if (char === '{' || char === '[' || char === '(') depth++; + if (char === '}' || char === ']' || char === ')') depth--; + + if (char === ';' && depth === 0) { + if (current.trim()) { + properties.push(current.trim()); + } + current = ''; + continue; + } + } + + current += char; + } + + // Add last property (may not have trailing semicolon) + if (current.trim()) { + properties.push(current.trim()); + } + + return properties; + } + + /** + * Parses a single property string like "name?: string" or "$schema?: string". + */ + private parsePropertyString(propStr: string): TsProperty | null { + if (!propStr) { + return null; + } + + // Match pattern: name?: Type (name can start with $ like $schema) + const match = propStr.match(/^(\$?\w+)(\?)?:\s*(.+)$/); + if (!match) { + return null; + } + + const [, name, optional, typeStr] = match; + if (!name || !typeStr) { + return null; + } + + return { + name, + type: typeStr.trim(), + isOptional: optional === '?', + isReadonly: false, + }; + } +} + +/** + * Updates interfaces with synthetic type references. + */ +export function updateInterfacesWithSyntheticTypes( + interfaces: TsInterface[], + propertyTypeMap: Map +): TsInterface[] { + return interfaces.map((iface) => ({ + ...iface, + properties: iface.properties.map((prop) => { + const key = `${iface.name}.${prop.name}`; + const syntheticType = propertyTypeMap.get(key); + + if (syntheticType) { + return { + ...prop, + type: prop.isOptional ? `${syntheticType} | undefined` : syntheticType, + originalInlineType: prop.type, + }; + } + + return prop; + }), + })); +} diff --git a/generator/src/fetcher/index.ts b/generator/src/fetcher/index.ts new file mode 100644 index 0000000..73c6f4f --- /dev/null +++ b/generator/src/fetcher/index.ts @@ -0,0 +1,153 @@ +/** + * MCP PHP Schema Generator - Schema Fetcher + * + * Fetches TypeScript schema files from GitHub or local filesystem. + */ + +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; +import type { GeneratorConfig, SchemaSource } from '../types/index.js'; +import { getSchemaGitHubUrl } from '../config/index.js'; + +/** + * Result of fetching the schema. + */ +export interface FetchResult { + readonly content: string; + readonly source: string; + readonly cached: boolean; +} + +/** + * Cache directory for downloaded schemas. + */ +const CACHE_DIR = '.cache/schemas'; + +/** + * Fetches the TypeScript schema from the configured source. + */ +export async function fetchSchema(config: GeneratorConfig): Promise { + if (config.schema.type === 'github') { + return fetchFromGitHub(config); + } else { + return fetchFromLocal(config.schema); + } +} + +/** + * Fetches schema from GitHub with caching support. + */ +async function fetchFromGitHub(config: GeneratorConfig): Promise { + const url = getSchemaGitHubUrl(config); + const cacheFile = getCacheFilePath(config.schema); + + // Try to use cached version first (if not forcing refresh) + try { + const cached = await readFile(cacheFile, 'utf-8'); + return { + content: cached, + source: `cached: ${cacheFile}`, + cached: true, + }; + } catch { + // Cache miss, fetch from GitHub + } + + // Fetch from GitHub + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch schema from GitHub: ${response.status} ${response.statusText}`); + } + + const content = await response.text(); + + // Cache the result + await cacheSchema(cacheFile, content); + + return { + content, + source: url, + cached: false, + }; +} + +/** + * Fetches schema from local filesystem. + */ +async function fetchFromLocal(source: SchemaSource): Promise { + // If path ends with .ts, use it directly; otherwise construct path from version + const schemaPath = source.path?.endsWith('.ts') + ? source.path + : join(source.path ?? '.', source.version, 'schema.ts'); + + try { + const content = await readFile(schemaPath, 'utf-8'); + return { + content, + source: schemaPath, + cached: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read local schema: ${message}`); + } +} + +/** + * Gets the cache file path for a schema version. + */ +function getCacheFilePath(source: SchemaSource): string { + const repoName = source.repository?.replace('/', '_') ?? 'local'; + return join(CACHE_DIR, `${repoName}_${source.version}_schema.ts`); +} + +/** + * Caches the schema content to disk. + */ +async function cacheSchema(cacheFile: string, content: string): Promise { + try { + await mkdir(dirname(cacheFile), { recursive: true }); + await writeFile(cacheFile, content, 'utf-8'); + } catch { + // Caching is best-effort, don't fail if it doesn't work + } +} + +/** + * Clears the schema cache. + */ +export async function clearCache(): Promise { + const { rm } = await import('fs/promises'); + try { + await rm(CACHE_DIR, { recursive: true, force: true }); + } catch { + // Ignore errors when clearing cache + } +} + +/** + * Forces a fresh fetch from GitHub, bypassing cache. + */ +export async function fetchSchemaFresh(config: GeneratorConfig): Promise { + if (config.schema.type === 'local') { + return fetchFromLocal(config.schema); + } + + const url = getSchemaGitHubUrl(config); + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch schema from GitHub: ${response.status} ${response.statusText}`); + } + + const content = await response.text(); + const cacheFile = getCacheFilePath(config.schema); + await cacheSchema(cacheFile, content); + + return { + content, + source: url, + cached: false, + }; +} diff --git a/generator/src/generators/builder.ts b/generator/src/generators/builder.ts new file mode 100644 index 0000000..fcebd83 --- /dev/null +++ b/generator/src/generators/builder.ts @@ -0,0 +1,315 @@ +/** + * MCP PHP Schema Generator - Builder Generator + * + * Generates PHP builder classes for fluent DTO construction. + */ + +import type { TsInterface, TsProperty, TsTypeAlias, GeneratorConfig, DomainClassification, VersionTracker } from '../types/index.js'; +import { DomainClassifier } from './domain-classifier.js'; +import { TypeMapper } from './type-mapper.js'; +import { TypeResolver } from './type-resolver.js'; +import { resolveInheritance } from '../parser/index.js'; +import { formatPhpDocDescription } from './index.js'; + +/** + * Generates PHP builder classes for DTOs. + */ +export class BuilderGenerator { + private readonly classifier: DomainClassifier; + private readonly config: GeneratorConfig; + private readonly interfaces: readonly TsInterface[]; + private readonly typeResolver: TypeResolver; + private readonly versionTracker: VersionTracker | undefined; + + constructor( + config: GeneratorConfig, + interfaces: readonly TsInterface[], + typeAliases: readonly TsTypeAlias[] = [], + classifier?: DomainClassifier, + versionTracker?: VersionTracker + ) { + this.config = config; + this.classifier = classifier ?? new DomainClassifier(); + this.interfaces = interfaces; + this.typeResolver = new TypeResolver(typeAliases, interfaces, config.output.namespace, this.classifier); + this.versionTracker = versionTracker; + } + + /** + * Generates PHP builder code for an interface. + */ + generate(iface: TsInterface): string { + const classification = this.classifier.classify(iface.name, iface.tags, iface.syntheticParent); + const properties = resolveInheritance(iface.name, this.interfaces); + const indent = this.getIndent(); + + return this.renderBuilder(iface, properties, classification, indent); + } + + /** + * Gets the indentation string. + */ + private getIndent(): string { + if (this.config.output.indentation === 'tabs') { + return '\t'; + } + return ' '.repeat(this.config.output.indentSize); + } + + /** + * Renders the PHP builder class. + */ + private renderBuilder( + iface: TsInterface, + properties: TsProperty[], + classification: DomainClassification, + indent: string + ): string { + const lines: string[] = []; + const namespace = this.getNamespace(classification); + const builderName = `${iface.name}Builder`; + const dtoName = iface.name; + + // Get the actual DTO namespace by resolving the interface through TypeResolver + // This ensures we use the same classification as the DTO generator + const resolvedDto = this.typeResolver.resolve(iface.name, undefined, iface.tags); + const dtoNamespace = resolvedDto.namespace ?? this.getDtoNamespace(classification); + + // Identify required properties + const requiredProps = properties.filter((p) => !p.isOptional); + + // Resolve all property types and collect imports + const resolvedProps = properties.map((p) => ({ + prop: p, + resolved: this.typeResolver.resolve(p.type, p.name, iface.tags), + })); + + // Collect unique imports (excluding same-namespace classes) + const imports = new Map(); // FQN -> simple name + for (const { resolved } of resolvedProps) { + if (resolved.needsImport && resolved.namespace && resolved.className) { + const fqn = `${resolved.namespace}\\${resolved.className}`; + // Don't import from same namespace + if (resolved.namespace !== namespace) { + imports.set(fqn, resolved.className); + } + } + } + + // PHP opening tag + lines.push(' 0) { + lines.push(`${indent}/**`); + lines.push(`${indent} * @var array Tracks which required properties have been set.`); + lines.push(`${indent} */`); + lines.push(`${indent}private array $_set = [];`); + lines.push(''); + } + + // Setter methods + for (const { prop, resolved } of resolvedProps) { + const phpType = resolved.phpType; + const methodName = this.getSetterName(prop.name); + const paramName = this.sanitizePropertyName(prop.name); + const isRequired = !prop.isOptional; + + lines.push(`${indent}/**`); + if (prop.description) { + lines.push(...formatPhpDocDescription(prop.description, indent)); + lines.push(`${indent} *`); + } + lines.push(`${indent} * @param ${TypeMapper.getPhpDocType(phpType)} $${paramName}`); + lines.push(`${indent} * @return self`); + lines.push(`${indent} */`); + // For untyped properties (PHP 7.4), omit parameter type hint + const setterTypeHint = TypeMapper.getTypeHint(phpType); + const setterParam = setterTypeHint ? `${setterTypeHint} $${paramName}` : `$${paramName}`; + lines.push(`${indent}public function ${methodName}(${setterParam}): self`); + lines.push(`${indent}{`); + lines.push(`${indent}${indent}$this->${paramName} = $${paramName};`); + if (isRequired) { + lines.push(`${indent}${indent}$this->_set['${prop.name}'] = true;`); + } + lines.push(`${indent}${indent}return $this;`); + lines.push(`${indent}}`); + lines.push(''); + } + + // Build method + lines.push(`${indent}/**`); + lines.push(`${indent} * Builds the ${dtoName} instance.`); + lines.push(`${indent} *`); + lines.push(`${indent} * @return ${dtoName}`); + lines.push(`${indent} * @throws \\InvalidArgumentException If required properties are not set.`); + lines.push(`${indent} */`); + lines.push(`${indent}public function build(): ${dtoName}`); + lines.push(`${indent}{`); + + // Required property validation + if (requiredProps.length > 0) { + lines.push(`${indent}${indent}$missing = [];`); + lines.push(''); + for (const prop of requiredProps) { + lines.push(`${indent}${indent}if (!isset($this->_set['${prop.name}'])) {`); + lines.push(`${indent}${indent}${indent}$missing[] = '${prop.name}';`); + lines.push(`${indent}${indent}}`); + } + lines.push(''); + lines.push(`${indent}${indent}if (count($missing) > 0) {`); + lines.push(`${indent}${indent}${indent}throw new \\InvalidArgumentException(`); + lines.push(`${indent}${indent}${indent}${indent}sprintf('Missing required properties: %s', implode(', ', $missing))`); + lines.push(`${indent}${indent}${indent});`); + lines.push(`${indent}${indent}}`); + lines.push(''); + } + + // Build the data array + lines.push(`${indent}${indent}$data = [];`); + lines.push(''); + for (const { prop } of resolvedProps) { + const paramName = this.sanitizePropertyName(prop.name); + if (prop.isOptional) { + lines.push(`${indent}${indent}if ($this->${paramName} !== null) {`); + lines.push(`${indent}${indent}${indent}$data['${prop.name}'] = $this->${paramName};`); + lines.push(`${indent}${indent}}`); + } else { + lines.push(`${indent}${indent}$data['${prop.name}'] = $this->${paramName};`); + } + } + lines.push(''); + lines.push(`${indent}${indent}return ${dtoName}::fromArray($data);`); + + lines.push(`${indent}}`); + + // Closing brace + lines.push('}'); + lines.push(''); + + return lines.join('\n'); + } + + /** + * Gets the PHP namespace for a classification (Builder namespace). + * Note: Version is used in directory structure but NOT in namespace (PHP namespaces can't start with digits) + */ + private getNamespace(classification: DomainClassification): string { + return `${this.config.output.namespace}\\${classification.domain}\\${classification.subdomain}\\Builder`; + } + + /** + * Gets the PHP namespace for the DTO. + * DTOs are placed directly in the subdomain namespace (no Dto subfolder). + */ + private getDtoNamespace(classification: DomainClassification): string { + return `${this.config.output.namespace}\\${classification.domain}\\${classification.subdomain}`; + } + + /** + * Gets the setter method name for a property. + * Handles special prefixes like _ and $ to create valid PHP method names. + */ + private getSetterName(propName: string): string { + let name = propName; + // Handle special property names like _meta -> meta + if (name.startsWith('_')) { + name = name.substring(1); + } + // Handle JSON Schema properties like $schema -> schema + if (name.startsWith('$')) { + name = name.substring(1); + } + return name; + } + + /** + * Sanitizes a property name for PHP. + * Handles special characters like $ which would create invalid variable names. + */ + private sanitizePropertyName(name: string): string { + // Remove leading $ to avoid $$name (variable variables) in PHP + if (name.startsWith('$')) { + return name.substring(1); + } + return name; + } +} diff --git a/generator/src/generators/contract.ts b/generator/src/generators/contract.ts new file mode 100644 index 0000000..649fa7c --- /dev/null +++ b/generator/src/generators/contract.ts @@ -0,0 +1,240 @@ +/** + * MCP PHP Schema Generator - Contract Generator + * + * Generates PHP interfaces (contracts) based on TypeScript extends relationships. + * These marker interfaces enable polymorphic handling of related types. + */ + +import type { TsInterface, GeneratorConfig } from '../types/index.js'; + +/** + * Contract information for a base type. + */ +export interface ContractInfo { + readonly baseName: string; + readonly interfaceName: string; + readonly implementors: string[]; + readonly description: string; +} + +/** + * Generated contract file. + */ +export interface GeneratedContract { + readonly name: string; + readonly content: string; +} + +/** + * Generates PHP interface contracts based on TypeScript extends relationships. + */ +export class ContractGenerator { + private readonly config: GeneratorConfig; + private readonly interfaces: readonly TsInterface[]; + + constructor(config: GeneratorConfig, interfaces: readonly TsInterface[]) { + this.config = config; + this.interfaces = interfaces; + } + + /** + * Generates all contracts based on extends relationships. + */ + generateAll(): GeneratedContract[] { + const contracts: GeneratedContract[] = []; + + // Core utility interfaces (always generated) + contracts.push(this.generateWithArrayTransformationInterface()); + contracts.push(this.generateWithJsonSchemaInterface()); + + // Marker interfaces based on extends relationships + const baseTypes = this.findBaseTypes(); + + for (const baseType of baseTypes) { + const implementors = this.findImplementors(baseType); + if (implementors.length > 0) { + contracts.push(this.generateMarkerInterface(baseType, implementors)); + } + } + + return contracts; + } + + /** + * Finds all base types that are extended by other interfaces. + */ + private findBaseTypes(): string[] { + const baseTypes = new Set(); + + for (const iface of this.interfaces) { + for (const ext of iface.extends) { + baseTypes.add(ext); + } + } + + // Filter to only include types that should have contracts + const contractableTypes = [ + 'Result', + 'JSONRPCRequest', + 'JSONRPCNotification', + 'NotificationParams', + 'RequestParams', + 'PaginatedRequest', + 'PaginatedResult', + 'ResourceContents', + 'ResourceRequestParams', + 'TaskAugmentedRequestParams', + 'Task', + 'BaseMetadata', + 'Icons', + ]; + + return contractableTypes.filter((t) => baseTypes.has(t)); + } + + /** + * Finds all types that implement (extend) a given base type. + */ + private findImplementors(baseType: string): string[] { + return this.interfaces + .filter((iface) => this.extendsType(iface, baseType)) + .map((iface) => iface.name); + } + + /** + * Checks if an interface extends a given type (directly or indirectly). + */ + private extendsType(iface: TsInterface, baseType: string, visited = new Set()): boolean { + if (visited.has(iface.name)) { + return false; + } + visited.add(iface.name); + + if (iface.extends.includes(baseType)) { + return true; + } + + // Check indirect inheritance + for (const parentName of iface.extends) { + const parent = this.interfaces.find((i) => i.name === parentName); + if (parent && this.extendsType(parent, baseType, visited)) { + return true; + } + } + + return false; + } + + /** + * Gets the indentation string. + */ + private getIndent(): string { + if (this.config.output.indentation === 'tabs') { + return '\t'; + } + return ' '.repeat(this.config.output.indentSize); + } + + /** + * Generates the WithArrayTransformationInterface. + */ + private generateWithArrayTransformationInterface(): GeneratedContract { + const indent = this.getIndent(); + const namespace = `${this.config.output.namespace}\\Common\\Contracts`; + + const content = ` The array representation. +${indent} */ +${indent}public function toArray(): array; + +${indent}/** +${indent} * Creates an instance from array data. +${indent} * +${indent} * @param array $data The array data. +${indent} * @return static The created instance. +${indent} */ +${indent}public static function fromArray(array $data); +} +`; + + return { name: 'WithArrayTransformationInterface', content }; + } + + /** + * Generates the WithJsonSchemaInterface. + */ + private generateWithJsonSchemaInterface(): GeneratedContract { + const indent = this.getIndent(); + const namespace = `${this.config.output.namespace}\\Common\\Contracts`; + + const content = ` The JSON Schema definition. +${indent} */ +${indent}public static function getJsonSchema(): array; +} +`; + + return { name: 'WithJsonSchemaInterface', content }; + } + + /** + * Generates a marker interface for a base type. + */ + private generateMarkerInterface(baseType: string, implementors: string[]): GeneratedContract { + const namespace = `${this.config.output.namespace}\\Common\\Contracts`; + const interfaceName = `${baseType}Interface`; + + const implementorList = implementors.join(', '); + + const content = `; + + constructor(customMapping?: Partial) { + this.categoryMapping = Object.assign( + {}, + DEFAULT_CATEGORY_MAPPING, + customMapping ?? {} + ) as CategoryMapping; + + // Fallback patterns for types without @category tags + this.fallbackPatterns = [ + // Server patterns + { pattern: /^(Tool|ListTools|CallTool)/i, classification: { domain: 'Server', subdomain: 'Tools' } }, + { pattern: /^(Resource|ListResources|ReadResource|Subscribe|Unsubscribe)/i, classification: { domain: 'Server', subdomain: 'Resources' } }, + { pattern: /^(Prompt|ListPrompts|GetPrompt)/i, classification: { domain: 'Server', subdomain: 'Prompts' } }, + { pattern: /^(Log|SetLevel|LoggingLevel)/i, classification: { domain: 'Server', subdomain: 'Logging' } }, + { pattern: /^(Complete|Completion)/i, classification: { domain: 'Server', subdomain: 'Core' } }, + { pattern: /^Server(?!Request)/i, classification: { domain: 'Server', subdomain: 'Lifecycle' } }, + + // Client patterns + { pattern: /^(Sample|CreateMessage|ModelPreferences|ModelHint)/i, classification: { domain: 'Client', subdomain: 'Sampling' } }, + { pattern: /^(Elicit|Elicitation)/i, classification: { domain: 'Client', subdomain: 'Elicitation' } }, + { pattern: /^(Root|ListRoots)/i, classification: { domain: 'Client', subdomain: 'Roots' } }, + { pattern: /^Client(?!Request|Notification)/i, classification: { domain: 'Client', subdomain: 'Lifecycle' } }, + + // Common patterns + { pattern: /^(Task)/i, classification: { domain: 'Common', subdomain: 'Tasks' } }, + { pattern: /^(Text|Image|Audio|Embedded|Resource)Content/i, classification: { domain: 'Common', subdomain: 'Content' } }, + { pattern: /^(JSONRPC|Request|Response|Notification|Error)/i, classification: { domain: 'Common', subdomain: 'JsonRpc' } }, + { pattern: /^(Initialize|Ping|Progress|Cancel)/i, classification: { domain: 'Common', subdomain: 'Protocol' } }, + { pattern: /^Implementation$/i, classification: { domain: 'Common', subdomain: 'Lifecycle' } }, + { pattern: /^Icon$/i, classification: { domain: 'Common', subdomain: 'Core' } }, + ]; + } + + /** + * Cache of previously classified types for synthetic type resolution. + */ + private readonly classificationCache = new Map(); + + /** + * Classifies a type based on its @category tag or name. + * For synthetic types, can optionally use the parent's classification. + */ + classify(typeName: string, tags: readonly JsDocTag[], syntheticParent?: string): DomainClassification { + // Check cache first + if (this.classificationCache.has(typeName)) { + return this.classificationCache.get(typeName)!; + } + + // For synthetic types, try to use parent's classification + if (syntheticParent && this.classificationCache.has(syntheticParent)) { + const parentClassification = this.classificationCache.get(syntheticParent)!; + this.classificationCache.set(typeName, parentClassification); + return parentClassification; + } + // First, try to use @category tag + const categoryTag = tags.find((tag) => tag.tagName === 'category'); + if (categoryTag?.text) { + const mapping = this.categoryMapping[categoryTag.text]; + if (mapping) { + this.classificationCache.set(typeName, mapping); + return mapping; + } + } + + // Fallback to name-based classification + for (const { pattern, classification } of this.fallbackPatterns) { + if (pattern.test(typeName)) { + this.classificationCache.set(typeName, classification); + return classification; + } + } + + // Default to Common/Protocol if no match + const defaultClassification: DomainClassification = { domain: 'Common', subdomain: 'Protocol' }; + this.classificationCache.set(typeName, defaultClassification); + return defaultClassification; + } + + /** + * Gets the PHP namespace for a domain/subdomain. + */ + getNamespace(domain: McpDomain, subdomain: McpSubdomain, version: string): string { + return `Mcp\\Schema\\${version.replace(/-/g, '_')}\\${domain}\\${subdomain}`; + } + + /** + * Gets the file path for a type. + */ + getFilePath( + domain: McpDomain, + subdomain: McpSubdomain, + typeCategory: 'Dto' | 'Enum' | 'Union' | 'Factory' | 'Builder', + className: string, + version: string + ): string { + return `Schema/${version}/${domain}/${subdomain}/${typeCategory}/${className}.php`; + } + + /** + * Adds a custom category mapping. + */ + addMapping(category: string, classification: DomainClassification): void { + this.categoryMapping[category] = classification; + } + + /** + * Gets all known categories. + */ + getKnownCategories(): string[] { + return Object.keys(this.categoryMapping); + } + + /** + * Checks if a type is internal (has @internal tag). + */ + isInternal(tags: readonly JsDocTag[]): boolean { + return tags.some((tag) => tag.tagName === 'internal'); + } + + /** + * Gets the @since version from tags. + */ + getSinceVersion(tags: readonly JsDocTag[]): string | undefined { + const sinceTag = tags.find((tag) => tag.tagName === 'since'); + return sinceTag?.text; + } + + /** + * Extracts the method/operation name from @category tag. + */ + extractMethodName(tags: readonly JsDocTag[]): string | undefined { + const categoryTag = tags.find((tag) => tag.tagName === 'category'); + if (!categoryTag?.text) { + return undefined; + } + + // Extract from backtick-wrapped value like `tools/call` + const match = categoryTag.text.match(/`([^`]+)`/); + return match?.[1]; + } +} diff --git a/generator/src/generators/dto.ts b/generator/src/generators/dto.ts new file mode 100644 index 0000000..aad9cc4 --- /dev/null +++ b/generator/src/generators/dto.ts @@ -0,0 +1,1232 @@ +/** + * MCP PHP Schema Generator - DTO Generator + * + * Generates PHP 7.4 Data Transfer Object classes from TypeScript interfaces. + */ + +import type { TsInterface, TsTypeAlias, PhpProperty, PhpClassMeta, GeneratorConfig, DomainClassification, UnionMembershipMap, PhpType, VersionTracker } from '../types/index.js'; +import { TypeMapper } from './type-mapper.js'; +import { DomainClassifier } from './domain-classifier.js'; +import { TypeResolver } from './type-resolver.js'; +import { + buildInheritanceGraph, + buildInterfaceMap, + classifyProperties, + getDirectParent, + isRoot, + type InheritanceGraph, + type NarrowedProperty, +} from './inheritance-graph.js'; +import { formatPhpDocDescription } from './index.js'; + +/** + * DTO generation options. + */ +export interface DtoGeneratorOptions { + readonly generateGetters?: boolean; + readonly generateWithMethods?: boolean; +} + +/** + * Generates PHP DTO classes from TypeScript interfaces. + */ +export class DtoGenerator { + private readonly classifier: DomainClassifier; + private readonly config: GeneratorConfig; + private readonly options: DtoGeneratorOptions; + private readonly typeResolver: TypeResolver; + private readonly unionMembershipMap: UnionMembershipMap; + private readonly inheritanceGraph: InheritanceGraph; + private readonly interfaceMap: ReadonlyMap; + private readonly versionTracker: VersionTracker | undefined; + + constructor( + interfaces: readonly TsInterface[], + config: GeneratorConfig, + options: DtoGeneratorOptions = {}, + typeAliases: readonly TsTypeAlias[] = [], + classifier?: DomainClassifier, + unionMembershipMap?: UnionMembershipMap, + versionTracker?: VersionTracker + ) { + this.config = config; + this.classifier = classifier ?? new DomainClassifier(); + this.typeResolver = new TypeResolver(typeAliases, interfaces, config.output.namespace, this.classifier); + this.unionMembershipMap = unionMembershipMap ?? new Map(); + this.inheritanceGraph = buildInheritanceGraph(interfaces); + this.interfaceMap = buildInterfaceMap(interfaces); + this.versionTracker = versionTracker; + this.options = { + generateGetters: true, + generateWithMethods: false, + ...options, + }; + } + + /** + * Generates PHP code for an interface. + */ + generate(iface: TsInterface): string { + const classification = this.classifier.classify(iface.name, iface.tags, iface.syntheticParent); + const currentNamespace = this.getNamespace(classification); + + // Classify properties into own, inherited, and narrowed + const propClassification = classifyProperties(iface, this.inheritanceGraph, this.interfaceMap); + + // Determine parent class + const parentTypeName = getDirectParent(iface.name, this.inheritanceGraph); + const isRootType = isRoot(iface.name, this.inheritanceGraph); + let extendsClass = 'AbstractDataTransferObject'; + let parentNamespace: string | undefined; + let parentIface: TsInterface | undefined; + + if (parentTypeName && !isRootType) { + parentIface = this.interfaceMap.get(parentTypeName); + if (parentIface) { + extendsClass = parentTypeName; + const parentClassification = this.classifier.classify(parentTypeName, parentIface.tags, parentIface.syntheticParent); + parentNamespace = this.getNamespace(parentClassification); + } + } + + // Track imports for cross-domain type references + const imports = new Map(); // FQN -> simple name + + // Add parent class import if in different namespace + if (parentNamespace && parentNamespace !== currentNamespace && extendsClass !== 'AbstractDataTransferObject') { + imports.set(`${parentNamespace}\\${extendsClass}`, extendsClass); + } + + // Check if this DTO is a member of any unions + const unionMemberships = this.unionMembershipMap.get(iface.name) ?? []; + const implementsList: string[] = []; + + // Add union interface imports and implements + for (const membership of unionMemberships) { + const interfaceName = `${membership.unionName}Interface`; + const fqn = `${membership.namespace}\\${interfaceName}`; + // Don't import from same namespace + if (membership.namespace !== currentNamespace) { + imports.set(fqn, interfaceName); + } + implementsList.push(interfaceName); + } + + // Classify properties: own (new in this type), inherited (from parents), narrowed (same name, specific type) + const allProperties = propClassification.allProperties; + const ownProperties = propClassification.ownProperties; + const narrowedProperties = propClassification.narrowedProperties; + + // Resolve all property types and collect imports (for constructor/fromArray/toArray) + const allPhpProperties = allProperties.map((p) => this.resolveProperty(p, currentNamespace, imports)); + + // Resolve own property types (for property declarations) + const ownPhpProperties = ownProperties.map((p) => this.resolveProperty(p, currentNamespace, imports)); + + // Resolve narrowed property types (for property declarations and special handling) + const narrowedPhpProperties = narrowedProperties.map((n) => ({ + narrowed: n, + phpProperty: this.resolveProperty(n.property, currentNamespace, imports), + })); + + const indent = this.getIndent(); + + const classMeta: PhpClassMeta = { + className: iface.name, + namespace: currentNamespace, + domain: classification.domain, + subdomain: classification.subdomain, + description: iface.description, + properties: allPhpProperties, // All for constructor/fromArray/toArray + extends: extendsClass, + implements: implementsList.length > 0 ? implementsList : undefined, + }; + + // Pass ownPhpProperties and narrowedPhpProperties separately for property declarations, + // and parent interface for constructor + return this.renderClass(classMeta, indent, imports, ownPhpProperties, narrowedPhpProperties, isRootType, parentIface, unionMemberships, extendsClass !== 'AbstractDataTransferObject' ? extendsClass : undefined, iface.name); + } + + /** + * Resolves a TypeScript property to a PHP property. + */ + private resolveProperty( + p: { name: string; type: string; isOptional: boolean; description?: string }, + currentNamespace: string, + imports: Map + ): PhpProperty { + const resolved = this.typeResolver.resolve(p.type, p.name); + let phpType = resolved.phpType; + + // Track cross-domain imports + if (resolved.needsImport && resolved.namespace && resolved.className) { + const fqn = `${resolved.namespace}\\${resolved.className}`; + // Don't import from same namespace + if (resolved.namespace !== currentNamespace) { + imports.set(fqn, resolved.className); + } + + // For union interfaces, also import the corresponding Factory class for fromArray() hydration + // Union interfaces are in Union/ subfolder, Factories are in Factory/ subfolder + if (resolved.className.endsWith('Interface')) { + const factoryClassName = resolved.className.replace(/Interface$/, 'Factory'); + const factoryNamespace = resolved.namespace.replace(/\\Union$/, '\\Factory'); + const factoryFqn = `${factoryNamespace}\\${factoryClassName}`; + if (factoryNamespace !== currentNamespace) { + imports.set(factoryFqn, factoryClassName); + } + } + } + + // For array types with union interface items, also import the Factory + if (phpType.isArray && phpType.arrayItemType && phpType.arrayItemType.endsWith('Interface')) { + const factoryClassName = phpType.arrayItemType.replace(/Interface$/, 'Factory'); + // Need to get the namespace - it should be in resolved.namespace but for array item type + if (resolved.namespace) { + const factoryNamespace = resolved.namespace.replace(/\\Union$/, '\\Factory'); + const factoryFqn = `${factoryNamespace}\\${factoryClassName}`; + if (factoryNamespace !== currentNamespace) { + imports.set(factoryFqn, factoryClassName); + } + } + } + + // Set FQN in phpDocType for array shape generation (all class references, including same namespace) + if (resolved.namespace && resolved.className) { + const fqnWithPrefix = `\\${resolved.namespace}\\${resolved.className}`; + if (phpType.isArray) { + phpType = { ...phpType, phpDocType: `array<${fqnWithPrefix}>` }; + } else { + phpType = { ...phpType, phpDocType: fqnWithPrefix }; + } + } + + // Check for const values (discriminator fields) + const constValue = this.extractConstValue(p.type); + + return { + name: p.name, + type: phpType, + description: p.description, + isRequired: !p.isOptional, + constValue, + } as PhpProperty; + } + + /** + * Extracts a const value from a literal type. + * Only extracts single literals, not union types like "a" | "b". + */ + private extractConstValue(type: string): string | undefined { + const trimmed = type.trim(); + + // Check if it's a single string literal (not a union) + // Must start and end with same quote type, and not contain unescaped quotes in middle + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + const middle = trimmed.slice(1, -1); + // Only match if no unescaped double quotes in middle (excludes union types) + if (!middle.includes('"')) { + return middle; + } + } + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + const middle = trimmed.slice(1, -1); + if (!middle.includes("'")) { + return middle; + } + } + + // Match number literals + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + return trimmed; + } + + return undefined; + } + + /** + * Gets the PHP namespace for a classification. + * DTOs are placed directly in the subdomain namespace (no Dto subfolder). + */ + private getNamespace(classification: DomainClassification): string { + return `${this.config.output.namespace}\\${classification.domain}\\${classification.subdomain}`; + } + + /** + * Gets the indentation string. + */ + private getIndent(): string { + if (this.config.output.indentation === 'tabs') { + return '\t'; + } + return ' '.repeat(this.config.output.indentSize); + } + + /** + * Renders the complete PHP class. + * + * @param meta - Class metadata + * @param indent - Indentation string + * @param imports - Import map + * @param ownProperties - Properties to declare in this class (own only, not inherited) + * @param narrowedProperties - Properties with narrower types than parent + * @param isRootType - Whether this is a root type (extends AbstractDataTransferObject) + * @param parentIface - Parent interface (for parent constructor order) + * @param unionMemberships - Union interfaces this class implements (for discriminator constants) + * @param parentClassName - Name of the parent class for @see references + * @param interfaceName - Original interface name for version tracking + */ + private renderClass( + meta: PhpClassMeta, + indent: string, + imports: Map = new Map(), + ownProperties?: readonly PhpProperty[], + narrowedProperties: readonly { narrowed: NarrowedProperty; phpProperty: PhpProperty }[] = [], + isRootType: boolean = true, + parentIface?: TsInterface, + unionMemberships: readonly import('../types/index.js').UnionMembershipInfo[] = [], + parentClassName?: string, + interfaceName?: string + ): string { + const lines: string[] = []; + + // Extract PHP properties from narrowed, with renamed property names to avoid PHP type conflict + // Parent declares `protected ?array $params;` - child can't redeclare with different type + // Solution: Use `$typedParams` in child, but keep JSON key as `params` + // + // EXCEPTION: Narrowed properties with const values (like method: 'initialize') don't need + // separate typed properties - they're handled via class constants and parent constructor + const narrowedPhpProperties = narrowedProperties + .filter((n) => n.phpProperty.constValue === undefined) // Exclude const narrowed properties + .map((n) => ({ + ...n.phpProperty, + originalName: n.phpProperty.name, // Keep original for JSON serialization + name: `typed${this.toPascalCase(n.phpProperty.name)}`, // Renamed PHP property + })); + + // Properties to declare in this class: own + narrowed (both need declarations) + const propertiesToDeclare = [...(ownProperties ?? meta.properties), ...narrowedPhpProperties]; + + // Determine if this DTO has required fields (non-const properties that are required) + const nonConstProps = meta.properties.filter((p) => p.constValue === undefined); + const requiredProps = nonConstProps.filter((p) => p.isRequired); + const hasRequiredFields = requiredProps.length > 0; + + // Detect "structural alias" - a subclass with no own properties (exists for semantic distinction) + // These are TypeScript type aliases that became separate PHP classes for type safety + const isStructuralAlias = !isRootType && + parentClassName !== undefined && + (ownProperties?.length ?? 0) === 0 && + narrowedPhpProperties.length === 0; + + // PHP opening tag + lines.push(' p.constValue !== undefined); + if (constProperties.length > 0) { + lines.push(...this.renderConstProperties(constProperties, indent)); + lines.push(''); + } + + // Discriminator constants for union members + // These provide a standardized way to introspect discriminator field and value + const discriminatorConstants = this.renderDiscriminatorConstants(unionMemberships, indent); + if (discriminatorConstants.length > 0) { + lines.push(...discriminatorConstants); + lines.push(''); + } + + // Properties (only own properties, not inherited) + if (propertiesToDeclare.length > 0) { + lines.push(...this.renderProperties(propertiesToDeclare, indent, interfaceName)); + lines.push(''); + } + + // Constructor with proper inheritance delegation + // Pass narrowed property ORIGINAL names so we pass null to parent for them + // But we'll use renamed properties (typedParams) for assignment in child + const narrowedOriginalNames = new Set(narrowedPhpProperties.map((p) => (p as any).originalName ?? p.name)); + lines.push(...this.renderConstructor(meta.properties, indent, ownProperties ?? [], isRootType, parentIface, narrowedOriginalNames, narrowedPhpProperties, interfaceName)); + lines.push(''); + + // fromArray method (uses all properties) + lines.push(...this.renderFromArray(meta.properties, indent)); + lines.push(''); + + // toArray method - for child classes, call parent::toArray() and merge own + narrowed properties + // Both own and narrowed need to be serialized by the child class + const propsToSerialize = [...(ownProperties ?? []), ...narrowedPhpProperties]; + lines.push(...this.renderToArray(meta.properties, indent, propsToSerialize, isRootType)); + + // Getters (only for own properties) + if (this.options.generateGetters && propertiesToDeclare.length > 0) { + lines.push(''); + lines.push(...this.renderGetters(propertiesToDeclare, indent)); + } + + // Closing brace + lines.push('}'); + lines.push(''); + + return lines.join('\n'); + } + + /** + * Renders use statements. + * + * @param _meta - Class metadata + * @param imports - Import map (includes parent class if cross-namespace) + * @param isRootType - Whether this is a root type (needs AbstractDataTransferObject import) + * @param hasRequiredFields - Whether this DTO has required fields (needs ValidatesRequiredFields trait) + */ + private renderUseStatements( + _meta: PhpClassMeta, + imports: Map = new Map(), + isRootType: boolean = true, + hasRequiredFields: boolean = false + ): string[] { + const uses: string[] = []; + + // Base class - only for root types that extend AbstractDataTransferObject directly + if (isRootType) { + uses.push(`use ${this.config.output.namespace}\\Common\\AbstractDataTransferObject;`); + } + + // ValidatesRequiredFields trait - only for DTOs with required fields + if (hasRequiredFields) { + uses.push(`use ${this.config.output.namespace}\\Common\\Traits\\ValidatesRequiredFields;`); + } + + // Cross-domain type imports (includes parent class if in different namespace) + for (const [fqn] of imports) { + uses.push(`use ${fqn};`); + } + + // Sort and deduplicate + return [...new Set(uses)].sort(); + } + + /** + * Renders the class docblock. + * + * For structural aliases (empty subclasses that exist for semantic distinction), + * adds explanatory comments explaining why the class exists. + * + * @param meta - Class metadata + * @param isStructuralAlias - Whether this class is a semantic alias (no own properties) + * @param parentClassName - Name of parent class for @see reference + * @param interfaceName - Original interface name for version tracking + */ + private renderClassDocblock( + meta: PhpClassMeta, + isStructuralAlias: boolean = false, + parentClassName?: string, + interfaceName?: string + ): string[] { + const lines: string[] = ['/**']; + + if (meta.description) { + lines.push(...formatPhpDocDescription(meta.description)); + lines.push(' *'); + } + + // For structural aliases, add explanatory note + if (isStructuralAlias && parentClassName) { + lines.push(` * Note: This class is structurally identical to ${parentClassName}.`); + lines.push(' * It exists as a separate type for semantic distinction per MCP specification.'); + lines.push(' *'); + } + + // Version tracking annotations + const versionInfo = interfaceName ? this.versionTracker?.getDefinitionVersion(interfaceName) : undefined; + if (versionInfo) { + lines.push(` * @since ${versionInfo.introducedIn}`); + if (versionInfo.lastModified && versionInfo.changeSummary) { + lines.push(` * @last-updated ${versionInfo.lastModified} (${versionInfo.changeSummary})`); + } + lines.push(' *'); + } + + lines.push(` * @mcp-domain ${meta.domain}`); + lines.push(` * @mcp-subdomain ${meta.subdomain}`); + lines.push(` * @mcp-version ${this.config.schema.version}`); + + // Add @see reference to parent for structural aliases + if (isStructuralAlias && parentClassName) { + lines.push(` * @see ${parentClassName}`); + } + + lines.push(' */'); + + return lines; + } + + /** + * Generates the PHPStan array shape entries for properties. + * Returns an array of "key: type" or "key?: type" strings. + * + * For DTO properties (non-primitives), the type includes `array|ClassName` + * since fromArray() accepts either raw arrays (from JSON) or pre-instantiated objects. + */ + private generateArrayShape(properties: readonly PhpProperty[]): string[] { + return properties.map((prop) => { + const { jsonKey } = this.getPropertyNames(prop.name); + let phpDocType = TypeMapper.getPhpDocType(prop.type); + + // For DTO types (non-primitives), fromArray() accepts either array or object + // Add array| prefix to indicate both are valid inputs + if (this.isHydratableType(prop.type)) { + // For array of DTOs: array becomes array + // For single DTO: ClassName becomes array|ClassName + if (prop.type.isArray && prop.type.arrayItemType && !DtoGenerator.PRIMITIVE_TYPES.has(prop.type.arrayItemType)) { + // Array of DTOs - the FQN is already in phpDocType like array<\Namespace\Class> + // We need to modify it to array + phpDocType = phpDocType.replace(/array<([^>]+)>/, 'array'); + } else if (!prop.type.isArray && !DtoGenerator.PRIMITIVE_TYPES.has(prop.type.type)) { + // Single DTO - add array| prefix + phpDocType = `array|${phpDocType}`; + } + } + + // For optional properties, also add |null to the type (not just the ? marker) + // This indicates the value can be explicitly null, not just absent + const isNullable = !prop.isRequired || prop.type.nullable; + if (isNullable && !phpDocType.includes('null')) { + phpDocType = `${phpDocType}|null`; + } + + // Optional properties use the "key?: type" syntax + const optionalMarker = !prop.isRequired ? '?' : ''; + + // Keys with special characters like $ need to be quoted in PHPStan array shapes + const formattedKey = this.needsQuotedKey(jsonKey) ? `'${jsonKey}'` : jsonKey; + + return `${formattedKey}${optionalMarker}: ${phpDocType}`; + }); + } + + /** + * Checks if a property type is a DTO that will be auto-hydrated in fromArray(). + */ + private isHydratableType(phpType: PhpType): boolean { + // Array of non-primitive items + if (phpType.isArray && phpType.arrayItemType) { + return !DtoGenerator.PRIMITIVE_TYPES.has(phpType.arrayItemType); + } + // Single non-primitive type + if (!phpType.isArray) { + return !DtoGenerator.PRIMITIVE_TYPES.has(phpType.type); + } + return false; + } + + /** + * Checks if a key needs to be quoted in PHPStan array shapes. + */ + private needsQuotedKey(key: string): boolean { + if (key.startsWith('$')) { + return true; + } + if (/[^a-zA-Z0-9_]/.test(key)) { + return true; + } + if (/^[0-9]/.test(key)) { + return true; + } + return false; + } + + /** + * Renders class constants. + */ + private renderConstants(constants: readonly { name: string; value: string }[], indent: string): string[] { + return constants.map((c) => `${indent}public const ${c.name} = ${this.formatPhpValue(c.value)};`); + } + + /** + * Renders const property values as class constants. + */ + private renderConstProperties(properties: readonly PhpProperty[], indent: string): string[] { + return properties + .filter((p) => p.constValue !== undefined) + .map((p) => { + const constName = this.toConstantName(p.name); + return `${indent}public const ${constName} = ${this.formatPhpValue(p.constValue!)};`; + }); + } + + /** + * Renders discriminator constants for union members. + * + * Generates DISCRIMINATOR_FIELD and DISCRIMINATOR_VALUE constants that provide + * a standardized way to introspect how this class participates in unions. + * + * @param unionMemberships - Union interfaces this class implements + * @param indent - Indentation string + * @returns Array of constant declaration lines + */ + private renderDiscriminatorConstants( + unionMemberships: readonly import('../types/index.js').UnionMembershipInfo[], + indent: string + ): string[] { + if (unionMemberships.length === 0) { + return []; + } + + // Find the first membership with discriminator info + // All unions for a given type should use the same discriminator field/value + const membershipWithDiscriminator = unionMemberships.find( + (m) => m.discriminatorField !== undefined && m.discriminatorValue !== undefined + ); + + if (!membershipWithDiscriminator) { + return []; + } + + const lines: string[] = []; + lines.push(`${indent}public const DISCRIMINATOR_FIELD = ${this.formatPhpValue(membershipWithDiscriminator.discriminatorField!)};`); + lines.push(`${indent}public const DISCRIMINATOR_VALUE = ${this.formatPhpValue(membershipWithDiscriminator.discriminatorValue!)};`); + return lines; + } + + /** + * Renders property declarations. + * + * @param properties - Properties to render + * @param indent - Indentation string + * @param interfaceName - Original interface name for version tracking + */ + private renderProperties(properties: readonly PhpProperty[], indent: string, interfaceName?: string): string[] { + const lines: string[] = []; + + for (const prop of properties) { + const { phpName } = this.getPropertyNames(prop.name); + + // Determine if property should be nullable (either explicitly nullable type or optional field) + const isNullable = prop.type.nullable || !prop.isRequired; + const effectiveType = { ...prop.type, nullable: isNullable }; + + // Get version info for this property + const propVersionInfo = interfaceName ? this.versionTracker?.getPropertyVersion(interfaceName, prop.name) : undefined; + + // Property docblock - use effectiveType to include nullability for optional properties + const phpDocType = TypeMapper.getPhpDocType(effectiveType); + lines.push(`${indent}/**`); + if (prop.description) { + lines.push(...formatPhpDocDescription(prop.description, indent)); + lines.push(`${indent} *`); + } + // Add @since for this property if version info is available + if (propVersionInfo) { + lines.push(`${indent} * @since ${propVersionInfo.introducedIn}`); + lines.push(`${indent} *`); + } + lines.push(`${indent} * @var ${phpDocType}`); + lines.push(`${indent} */`); + + // Property declaration (omit type hint for untyped properties in PHP 7.4) + // Use protected for inheritance support + const typeHint = TypeMapper.getTypeHint(effectiveType); + if (typeHint) { + lines.push(`${indent}protected ${typeHint} $${phpName};`); + } else { + lines.push(`${indent}protected $${phpName};`); + } + lines.push(''); + } + + // Remove trailing empty line + if (lines[lines.length - 1] === '') { + lines.pop(); + } + + return lines; + } + + /** + * Renders the constructor with individual typed parameters. + * For child classes, generates parent::__construct() call with inherited properties. + * + * @param properties - All properties (own + inherited) + * @param indent - Indentation string + * @param ownProperties - Properties defined in this class only (not inherited) + * @param isRootType - Whether this is a root type (extends AbstractDataTransferObject) + * @param parentIface - Parent interface (for determining parent constructor argument order) + * @param narrowedPropertyNames - ORIGINAL names of properties with narrowed types (pass null to parent) + * @param narrowedPhpProperties - The narrowed properties with renamed PHP names + * @param interfaceName - Original interface name for version tracking + */ + private renderConstructor( + properties: readonly PhpProperty[], + indent: string, + ownProperties: readonly PhpProperty[] = [], + isRootType: boolean = true, + parentIface?: TsInterface, + narrowedPropertyNames: ReadonlySet = new Set(), + narrowedPhpProperties: readonly (PhpProperty & { originalName?: string })[] = [], + interfaceName?: string + ): string[] { + const lines: string[] = []; + + // Separate properties: const properties don't need constructor params + const constProps = properties.filter((p) => p.constValue !== undefined); + const nonConstProps = properties.filter((p) => p.constValue === undefined); + + // Sort: required parameters first, optional parameters after + const requiredProps = nonConstProps.filter((p) => p.isRequired); + const optionalProps = nonConstProps.filter((p) => !p.isRequired); + const sortedProps = [...requiredProps, ...optionalProps]; + + // Identify own vs inherited properties (using property names for comparison) + const ownPropertyNames = new Set(ownProperties.map((p) => p.name)); + const ownConstProps = constProps.filter((p) => ownPropertyNames.has(p.name)); + + // For parent constructor call, use the PARENT's property order (not the child's) + // This ensures we match the parent's constructor signature exactly + let parentConstructorArgs: string[] = []; + if (!isRootType && parentIface) { + // Get ALL properties from the parent (including its inherited properties) + const parentClassification = classifyProperties(parentIface, this.inheritanceGraph, this.interfaceMap); + const parentAllProps = parentClassification.allProperties; + + // Apply the same sorting logic as the parent's constructor uses + const parentNonConstProps = parentAllProps.filter((p) => { + const constValue = this.extractConstValue(p.type); + return constValue === undefined; + }); + const parentRequiredProps = parentNonConstProps.filter((p) => !p.isOptional); + const parentOptionalProps = parentNonConstProps.filter((p) => p.isOptional); + const parentSortedProps = [...parentRequiredProps, ...parentOptionalProps]; + + // Build a set of const property names from the CHILD's properties + // These are properties the child overrides with const values + const childConstPropNames = new Set(constProps.map((p) => p.name)); + + // Generate parent constructor arguments in the parent's order + // For properties that are const in the child, use self::CONSTANT_NAME + // For narrowed properties, pass null (child will store typed value separately) + // For other properties, use $variableName + parentConstructorArgs = parentSortedProps.map((p) => { + const { phpName } = this.getPropertyNames(p.name); + if (childConstPropNames.has(p.name)) { + // This property is a const in the child - use the constant value + const constName = this.toConstantName(p.name); + return `self::${constName}`; + } + if (narrowedPropertyNames.has(p.name)) { + // This property has a narrowed type - pass null to parent + // The child will assign its typed value directly + return 'null'; + } + return `$${phpName}`; + }); + } + + // Generate PHPDoc + lines.push(`${indent}/**`); + for (const prop of sortedProps) { + const { phpName } = this.getPropertyNames(prop.name); + const phpDocType = TypeMapper.getPhpDocType(prop.type); + // For optional properties, ensure |null is in the type + const effectiveType = !prop.isRequired && !phpDocType.includes('null') + ? `${phpDocType}|null` + : phpDocType; + // Add @since for this parameter if version info is available + const propVersionInfo = interfaceName ? this.versionTracker?.getPropertyVersion(interfaceName, prop.name) : undefined; + const sinceTag = propVersionInfo ? ` @since ${propVersionInfo.introducedIn}` : ''; + lines.push(`${indent} * @param ${effectiveType} $${phpName}${sinceTag}`); + } + lines.push(`${indent} */`); + + // Generate constructor signature + if (sortedProps.length === 0) { + // No parameters needed (all properties are const) + lines.push(`${indent}public function __construct()`); + lines.push(`${indent}{`); + } else { + lines.push(`${indent}public function __construct(`); + + for (let i = 0; i < sortedProps.length; i++) { + const prop = sortedProps[i]!; + const { phpName } = this.getPropertyNames(prop.name); + const isLast = i === sortedProps.length - 1; + const comma = isLast ? '' : ','; + + // Determine type hint (omit for untyped properties in PHP 7.4) + const isNullable = prop.type.nullable || !prop.isRequired; + const effectiveType = { ...prop.type, nullable: isNullable }; + const typeHint = TypeMapper.getTypeHint(effectiveType); + const typePrefix = typeHint ? `${typeHint} ` : ''; + + // Add default value for optional parameters + if (!prop.isRequired) { + const defaultValue = this.getDefaultValue(prop); + lines.push(`${indent}${indent}${typePrefix}$${phpName} = ${defaultValue}${comma}`); + } else { + lines.push(`${indent}${indent}${typePrefix}$${phpName}${comma}`); + } + } + + lines.push(`${indent}) {`); + } + + // For child classes, call parent constructor with inherited properties (in parent's order) + if (!isRootType && parentConstructorArgs.length > 0) { + lines.push(`${indent}${indent}parent::__construct(${parentConstructorArgs.join(', ')});`); + } + + // Assign own const properties from class constants (only for this class, not inherited) + for (const prop of ownConstProps) { + const { phpName } = this.getPropertyNames(prop.name); + const constName = this.toConstantName(prop.name); + lines.push(`${indent}${indent}$this->${phpName} = self::${constName};`); + } + + // Assign only own non-const properties from parameters (inherited are handled by parent) + const ownNonConstProps = sortedProps.filter((p) => ownPropertyNames.has(p.name)); + for (const prop of ownNonConstProps) { + const { phpName } = this.getPropertyNames(prop.name); + lines.push(`${indent}${indent}$this->${phpName} = $${phpName};`); + } + + // Assign narrowed properties from parameters (we passed null to parent, child stores typed value) + // Use the RENAMED property name (e.g., $this->typedParams) but parameter has original name ($params) + for (const narrowedProp of narrowedPhpProperties) { + const originalName = narrowedProp.originalName ?? narrowedProp.name; + const { phpName: originalPhpName } = this.getPropertyNames(originalName); + const renamedPhpName = narrowedProp.name; // Already renamed (e.g., typedParams) + // Parameter uses original name, property uses renamed name + lines.push(`${indent}${indent}$this->${renamedPhpName} = $${originalPhpName};`); + } + + lines.push(`${indent}}`); + + return lines; + } + + /** + * Renders the fromArray static factory method. + */ + private renderFromArray(properties: readonly PhpProperty[], indent: string): string[] { + const lines: string[] = []; + + // Separate properties: const properties don't need constructor params + const nonConstProps = properties.filter((p) => p.constValue === undefined); + + // Sort: required parameters first, optional parameters after (same order as constructor) + const requiredProps = nonConstProps.filter((p) => p.isRequired); + const optionalProps = nonConstProps.filter((p) => !p.isRequired); + const sortedProps = [...requiredProps, ...optionalProps]; + + // Generate multi-line array shape for @param (provides IDE autocomplete) + // Use @phpstan-param to override with generic type (avoids strict array shape checks) + const arrayShape = this.generateArrayShape(properties); + lines.push(`${indent}/**`); + lines.push(`${indent} * Creates an instance from an array.`); + lines.push(`${indent} *`); + lines.push(`${indent} * @param array{`); + for (let i = 0; i < arrayShape.length; i++) { + const isLast = i === arrayShape.length - 1; + lines.push(`${indent} * ${arrayShape[i]}${isLast ? '' : ','}`); + } + lines.push(`${indent} * } $data`); + lines.push(`${indent} * @phpstan-param array $data`); + lines.push(`${indent} * @return self`); + lines.push(`${indent} */`); + lines.push(`${indent}public static function fromArray(array $data): self`); + lines.push(`${indent}{`); + + // Validate required fields using the ValidatesRequiredFields trait + if (requiredProps.length > 0) { + lines.push(`${indent}${indent}self::assertRequired($data, [${requiredProps.map((p) => `'${p.name}'`).join(', ')}]);`); + lines.push(''); + } + + // Generate constructor call with individual parameters + // Each class overrides fromArray(), so `new self()` is correct for each class + if (sortedProps.length === 0) { + lines.push(`${indent}${indent}return new self();`); + } else { + lines.push(`${indent}${indent}return new self(`); + + for (let i = 0; i < sortedProps.length; i++) { + const prop = sortedProps[i]!; + const { jsonKey } = this.getPropertyNames(prop.name); + const isLast = i === sortedProps.length - 1; + const comma = isLast ? '' : ','; + + // Get deserialization expression for nested DTO types + const rawExpr = `$data['${jsonKey}']`; + const deserExpr = this.getDeserializationExpression(prop.type, rawExpr, indent); + + if (prop.isRequired) { + lines.push(`${indent}${indent}${indent}${deserExpr.expression}${comma}`); + } else { + // For optional properties with DTO types, we need to handle null case + // If it's a DTO type, use isset() check with hydration + const isPrimitiveType = DtoGenerator.PRIMITIVE_TYPES.has(prop.type.type) && + (!prop.type.isArray || !prop.type.arrayItemType || DtoGenerator.PRIMITIVE_TYPES.has(prop.type.arrayItemType)); + + if (isPrimitiveType) { + // Primitive type - simple null coalescing + const defaultValue = this.getDefaultValue(prop); + lines.push(`${indent}${indent}${indent}${rawExpr} ?? ${defaultValue}${comma}`); + } else { + // DTO type - need to check isset before hydrating + const defaultValue = this.getDefaultValue(prop); + lines.push(`${indent}${indent}${indent}isset(${rawExpr}) ? ${deserExpr.expression} : ${defaultValue}${comma}`); + } + } + } + + lines.push(`${indent}${indent});`); + } + + lines.push(`${indent}}`); + + return lines; + } + + /** + * Renders the toArray method. + * + * For root types: builds result from scratch with all properties. + * For child types: calls parent::toArray() and merges only own properties. + * + * @param properties - All properties (for PHPDoc array shape) + * @param indent - Indentation string + * @param ownProperties - Properties defined in this class only (not inherited) + * @param isRootType - Whether this is a root type (extends AbstractDataTransferObject) + */ + private renderToArray( + properties: readonly PhpProperty[], + indent: string, + ownProperties: readonly PhpProperty[] = [], + isRootType: boolean = true + ): string[] { + const lines: string[] = []; + + lines.push(`${indent}/**`); + lines.push(`${indent} * Converts the instance to an array.`); + lines.push(`${indent} *`); + lines.push(`${indent} * @return array`); + lines.push(`${indent} */`); + lines.push(`${indent}public function toArray(): array`); + lines.push(`${indent}{`); + + // For root types: start with empty array + // For child types: start with parent's array (includes all inherited properties) + if (isRootType) { + lines.push(`${indent}${indent}$result = [];`); + } else { + lines.push(`${indent}${indent}$result = parent::toArray();`); + } + lines.push(''); + + // For root types, serialize ALL properties + // For child types, only serialize OWN properties (parent handles inherited ones) + const propsToSerialize = isRootType ? properties : ownProperties; + + for (const prop of propsToSerialize) { + // For narrowed properties, use originalName for JSON key but renamed name for PHP property + const propAny = prop as PhpProperty & { originalName?: string }; + const originalName = propAny.originalName ?? prop.name; + const { jsonKey } = this.getPropertyNames(originalName); // JSON key from original name + // For PHP property access, use the sanitized name (e.g., 'schema' not '$schema') + // But for narrowed properties, the name is already renamed (e.g., 'typedParams') + const isNarrowedProp = propAny.originalName !== undefined; + const { phpName: sanitizedPhpName } = this.getPropertyNames(prop.name); + const phpName = isNarrowedProp ? prop.name : sanitizedPhpName; + + // Determine serialization method based on type + const serializationExpr = this.getSerializationExpression(prop.type, `$this->${phpName}`); + + if (prop.type.nullable || !prop.isRequired) { + lines.push(`${indent}${indent}if ($this->${phpName} !== null) {`); + lines.push(`${indent}${indent}${indent}$result['${jsonKey}'] = ${serializationExpr};`); + lines.push(`${indent}${indent}}`); + } else { + lines.push(`${indent}${indent}$result['${jsonKey}'] = ${serializationExpr};`); + } + } + + // Only add blank line if we serialized properties + if (propsToSerialize.length > 0) { + lines.push(''); + } + lines.push(`${indent}${indent}return $result;`); + lines.push(`${indent}}`); + + return lines; + } + + /** + * Primitive PHP types that don't have a toArray()/fromArray() method. + */ + private static readonly PRIMITIVE_TYPES = new Set([ + 'string', + 'int', + 'float', + 'bool', + 'array', + 'object', + 'null', + '', + ]); + + /** + * Gets the PHP expression for deserializing a property value from an array. + * + * For DTO objects: checks if value is array and calls ::fromArray() + * For arrays of DTOs: maps over array and calls ::fromArray() on each item + * For union interfaces: uses the corresponding Factory class + * For primitives: returns value directly + * + * @param phpType - The PHP type information + * @param varExpr - The PHP variable expression (e.g., "$data['inputSchema']") + * @param indent - The base indentation for multi-line expressions + * @returns Object with expression and whether it needs pre-assignment + */ + private getDeserializationExpression( + phpType: PhpType, + varExpr: string, + _indent: string + ): { expression: string; needsVariable: boolean; variableCode?: string } { + // Check if it's an array of DTO objects + if (phpType.isArray && phpType.arrayItemType) { + const itemType = phpType.arrayItemType; + // If the array item is a DTO class (not a primitive), hydrate each item + if (!DtoGenerator.PRIMITIVE_TYPES.has(itemType)) { + // For union interfaces (ending with Interface), use the Factory class + const hydratorClass = this.getHydratorClass(itemType); + // For arrays, we need to map over and hydrate each item + // But we need to handle the case where the value might already be an object + return { + expression: `array_map(static fn($item) => is_array($item) ? ${hydratorClass}::fromArray($item) : $item, ${varExpr})`, + needsVariable: false, + }; + } + } + + // Check if it's a single DTO object (not a primitive) + if (!phpType.isArray && !DtoGenerator.PRIMITIVE_TYPES.has(phpType.type)) { + // For union interfaces (ending with Interface), use the Factory class + const hydratorClass = this.getHydratorClass(phpType.type); + // Need to check if value is array and hydrate, or use as-is if already object + return { + expression: `is_array(${varExpr}) ? ${hydratorClass}::fromArray(${varExpr}) : ${varExpr}`, + needsVariable: false, + }; + } + + // Primitive type or array of primitives - return as-is + return { + expression: varExpr, + needsVariable: false, + }; + } + + /** + * Gets the class name to use for hydration (fromArray). + * + * For union interfaces (ending with "Interface"), returns the corresponding Factory class. + * For regular DTOs, returns the class name as-is. + * + * @param typeName - The PHP type name (e.g., "ContentBlockInterface" or "Tool") + * @returns The class name to call fromArray() on + */ + private getHydratorClass(typeName: string): string { + // Union interfaces end with "Interface" and need Factory for hydration + if (typeName.endsWith('Interface')) { + // Replace "Interface" with "Factory" and adjust namespace reference + // The factory is in the Factory/ subfolder, interface is in Union/ subfolder + // Since imports handle namespaces, we just need to use the right class name + return typeName.replace(/Interface$/, 'Factory'); + } + return typeName; + } + + /** + * Gets the PHP expression for serializing a property value. + * + * For DTO objects: calls ->toArray() + * For arrays of DTOs: maps over array and calls ->toArray() on each item + * For primitives: returns value directly + * + * @param phpType - The PHP type information + * @param varExpr - The PHP variable expression (e.g., '$this->inputSchema') + */ + private getSerializationExpression(phpType: PhpType, varExpr: string): string { + // Check if it's an array of DTO objects + if (phpType.isArray && phpType.arrayItemType) { + const itemType = phpType.arrayItemType; + // If the array item is a DTO class (not a primitive), serialize each item + if (!DtoGenerator.PRIMITIVE_TYPES.has(itemType)) { + return `array_map(static fn($item) => $item->toArray(), ${varExpr})`; + } + } + + // Check if it's a single DTO object (not a primitive) + if (!phpType.isArray && !DtoGenerator.PRIMITIVE_TYPES.has(phpType.type)) { + return `${varExpr}->toArray()`; + } + + // Primitive type or array of primitives - return as-is + return varExpr; + } + + /** + * Renders getter methods. + */ + private renderGetters(properties: readonly PhpProperty[], indent: string): string[] { + const lines: string[] = []; + + for (const prop of properties) { + // For narrowed properties, use originalName for getter method name but renamed name for property + const propAny = prop as PhpProperty & { originalName?: string }; + const originalName = propAny.originalName ?? prop.name; + const isNarrowedProp = propAny.originalName !== undefined; + const { phpName: originalPhpName } = this.getPropertyNames(originalName); + const phpName = prop.name; // PHP property name (may be renamed like 'typedParams') + + // For PHP property access, use the sanitized name (e.g., 'schema' not '$schema') + // But for narrowed properties, the name is already renamed (e.g., 'typedParams') + const { phpName: sanitizedPhpName } = this.getPropertyNames(phpName); + const actualPhpName = isNarrowedProp ? phpName : sanitizedPhpName; + + // For narrowed properties, generate a NEW getter (getTypedParams) instead of overriding parent's getter + // This avoids Liskov Substitution violations (parent returns ?array, child can't override with SomeClass) + // User can call getTypedParams() for the specific type, or getParams() for parent's array version + const methodName = isNarrowedProp + ? `get${this.toPascalCase(actualPhpName)}` // e.g., getTypedParams (new method) + : `get${this.toPascalCase(originalPhpName)}`; // e.g., getParams (normal method) + + // Determine effective nullability for return type + const isNullable = prop.type.nullable || !prop.isRequired; + const effectiveType = { ...prop.type, nullable: isNullable }; + const typeHint = TypeMapper.getTypeHint(effectiveType); + // Now we can use proper return type since we're not overriding parent + const returnType = typeHint ? `: ${typeHint}` : ''; + + lines.push(`${indent}/**`); + lines.push(`${indent} * @return ${TypeMapper.getPhpDocType(effectiveType)}`); + lines.push(`${indent} */`); + lines.push(`${indent}public function ${methodName}()${returnType}`); + lines.push(`${indent}{`); + lines.push(`${indent}${indent}return $this->${actualPhpName};`); + lines.push(`${indent}}`); + lines.push(''); + } + + // Remove trailing empty line + if (lines[lines.length - 1] === '') { + lines.pop(); + } + + return lines; + } + + /** + * Gets the default value for an optional property. + * Always returns null for consistency with nullable type declarations. + */ + private getDefaultValue(prop: PhpProperty): string { + if (prop.defaultValue !== undefined) { + return this.formatPhpValue(prop.defaultValue); + } + + // Always use null for optional properties to match the nullable type declaration + return 'null'; + } + + /** + * Formats a value as PHP code. + */ + private formatPhpValue(value: string): string { + // Already a number + if (/^-?\d+(\.\d+)?$/.test(value)) { + return value; + } + + // Boolean + if (value === 'true' || value === 'false') { + return value; + } + + // Null + if (value === 'null') { + return 'null'; + } + + // String - add quotes + return `'${value.replace(/'/g, "\\'")}'`; + } + + /** + * Converts a property name to CONSTANT_NAME. + */ + private toConstantName(name: string): string { + return name.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(); + } + + /** + * Converts a string to PascalCase. + */ + private toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + /** + * Sanitizes a property name for PHP (strips leading $). + * Returns both the PHP property name and the original JSON key. + */ + private getPropertyNames(originalName: string): { phpName: string; jsonKey: string } { + // If name starts with $, strip it for PHP but keep it for JSON serialization + if (originalName.startsWith('$')) { + return { + phpName: originalName.slice(1), + jsonKey: originalName, + }; + } + return { + phpName: originalName, + jsonKey: originalName, + }; + } +} diff --git a/generator/src/generators/enum.ts b/generator/src/generators/enum.ts new file mode 100644 index 0000000..baae246 --- /dev/null +++ b/generator/src/generators/enum.ts @@ -0,0 +1,188 @@ +/** + * MCP PHP Schema Generator - Enum Generator + * + * Generates PHP 7.4 class-based enums from TypeScript string literal unions. + */ + +import type { TsTypeAlias, GeneratorConfig, DomainClassification } from '../types/index.js'; +import { DomainClassifier } from './domain-classifier.js'; +import { formatPhpDocDescription } from './index.js'; + +/** + * Generates PHP enum classes (class-based for PHP 7.4 compatibility). + */ +export class EnumGenerator { + private readonly classifier: DomainClassifier; + private readonly config: GeneratorConfig; + + constructor(config: GeneratorConfig) { + this.config = config; + this.classifier = new DomainClassifier(); + } + + /** + * Checks if a type alias represents an enum (string literal union). + */ + isEnum(typeAlias: TsTypeAlias): boolean { + // Check if the type is a union of string literals + const type = typeAlias.type.trim(); + // Filter out empty strings caused by leading | in multi-line unions + const parts = type.split('|').map((p) => p.trim()).filter((p) => p !== ''); + + // Strip trailing comments (// ...) for enum detection + const strippedParts = parts.map((p) => p.replace(/\s*\/\/.*$/, '').trim()); + + return strippedParts.length > 1 && strippedParts.every((p) => /^["'].*["']$/.test(p)); + } + + /** + * Extracts enum values from a string literal union type. + */ + extractValues(typeAlias: TsTypeAlias): string[] { + const type = typeAlias.type.trim(); + // Filter out empty strings caused by leading | in multi-line unions + const parts = type.split('|').map((p) => p.trim()).filter((p) => p !== ''); + + // Strip trailing comments (// ...) before extracting values + const strippedParts = parts.map((p) => p.replace(/\s*\/\/.*$/, '').trim()); + + return strippedParts + .filter((p) => /^["'].*["']$/.test(p)) + .map((p) => p.slice(1, -1)); + } + + /** + * Generates PHP code for an enum. + */ + generate(typeAlias: TsTypeAlias): string { + const classification = this.classifier.classify(typeAlias.name, typeAlias.tags); + const values = this.extractValues(typeAlias); + const indent = this.getIndent(); + + return this.renderEnum(typeAlias.name, values, classification, typeAlias.description, indent); + } + + /** + * Gets the indentation string. + */ + private getIndent(): string { + if (this.config.output.indentation === 'tabs') { + return '\t'; + } + return ' '.repeat(this.config.output.indentSize); + } + + /** + * Renders the complete PHP enum class. + */ + private renderEnum( + name: string, + values: string[], + classification: DomainClassification, + description: string | undefined, + indent: string + ): string { + const lines: string[] = []; + const namespace = this.getNamespace(classification); + + // PHP opening tag + lines.push(' (i === 0 ? p.toLowerCase() : p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())) + .join(''); + } +} diff --git a/generator/src/generators/factory.ts b/generator/src/generators/factory.ts new file mode 100644 index 0000000..78c72ba --- /dev/null +++ b/generator/src/generators/factory.ts @@ -0,0 +1,648 @@ +/** + * MCP PHP Schema Generator - Factory Generator + * + * Generates PHP factory classes for instantiating union type members. + */ + +import type { TsTypeAlias, TsInterface, GeneratorConfig, DomainClassification } from '../types/index.js'; +import { DomainClassifier } from './domain-classifier.js'; +import { formatPhpDocDescription } from './index.js'; + +/** + * Discriminator field information. + */ +interface DiscriminatorInfo { + readonly field: string; + readonly values: Map; // value -> member type name (for simple single-member cases) + /** Disambiguation rules for colliding discriminator values */ + readonly disambiguations: Map; // value -> rules to try in order +} + +/** + * Rule for disambiguating between members with the same discriminator value. + */ +interface DisambiguationRule { + readonly memberName: string; + readonly isUnion: boolean; + /** Field that must be present for this rule to match (e.g., 'oneOf', 'enumNames') */ + readonly requiredField?: string; + /** Field that must be absent for this rule to match */ + readonly absentField?: string; + /** True if this is the fallback rule (no field checks) */ + readonly isFallback?: boolean; +} + +/** + * Member routing information for factory generation. + */ +interface MemberRouting { + readonly name: string; + readonly isUnion: boolean; // true if member is a union type alias (route to factory) +} + +/** + * Generates PHP factory classes for union types. + */ +export class FactoryGenerator { + private readonly classifier: DomainClassifier; + private readonly config: GeneratorConfig; + private readonly interfaces: readonly TsInterface[]; + private readonly typeAliases: readonly TsTypeAlias[]; + private readonly unionNames: Set; // Set of type alias names that are unions + + constructor(config: GeneratorConfig, interfaces: readonly TsInterface[], typeAliases: readonly TsTypeAlias[] = []) { + this.config = config; + this.classifier = new DomainClassifier(); + this.interfaces = interfaces; + this.typeAliases = typeAliases; + // Build set of union type alias names for quick lookup + this.unionNames = new Set( + typeAliases + .filter((alias) => alias.type.includes('|')) + .map((alias) => alias.name) + ); + } + + /** + * Generates PHP factory code for a union type. + * + * Returns null if no discriminator is detected, indicating the factory + * should not be generated. This matches the PHP generator behavior where + * factories are only useful when a discriminator field exists. + */ + generate(typeAlias: TsTypeAlias, members: string[]): string | null { + const classification = this.classifier.classify(typeAlias.name, typeAlias.tags); + + // Validate members - they must be either interfaces OR union type aliases + // Type aliases like 'EmptyResult = Result' won't have DTOs or factories generated + const validMembers = members.filter((m) => { + const isInterface = this.interfaces.some((i) => i.name === m); + const isUnion = this.unionNames.has(m); + return isInterface || isUnion; + }); + + // Build member routing info + const memberRouting = this.buildMemberRouting(validMembers); + + // Detect discriminator from flattened leaf interfaces + const discriminator = this.detectDiscriminatorWithRouting(memberRouting); + + // Skip factory generation if no discriminator found + // Factories without discriminators use unreliable try/catch fallback matching + // which is order-dependent and prone to false matches + // Note: Check for values OR disambiguations (not just values) + if (!discriminator || (discriminator.values.size === 0 && discriminator.disambiguations.size === 0)) { + return null; + } + + const indent = this.getIndent(); + + return this.renderFactoryWithRouting(typeAlias.name, memberRouting, classification, typeAlias.description, discriminator, indent); + } + + /** + * Builds routing information for each member. + * Determines if member is a union (route to factory) or interface (route to DTO). + */ + private buildMemberRouting(memberNames: string[]): MemberRouting[] { + return memberNames.map((name) => ({ + name, + isUnion: this.unionNames.has(name), + })); + } + + /** + * Gets the leaf interfaces for a member. + * For interfaces, returns the interface itself. + * For union type aliases, recursively flattens to get all leaf interfaces. + */ + private getLeafInterfaces(memberName: string): TsInterface[] { + // Check if it's a direct interface + const directInterface = this.interfaces.find((i) => i.name === memberName); + if (directInterface) { + return [directInterface]; + } + + // Check if it's a union type alias + const typeAlias = this.typeAliases.find((a) => a.name === memberName); + if (typeAlias && typeAlias.type.includes('|')) { + // Extract member names from union type + const unionMembers = typeAlias.type + .split('|') + .map((m) => m.trim()) + .filter((m) => m.length > 0); + + // Recursively get leaf interfaces + const leaves: TsInterface[] = []; + for (const unionMember of unionMembers) { + leaves.push(...this.getLeafInterfaces(unionMember)); + } + return leaves; + } + + return []; + } + + /** + * Detects discriminator for union with routing info. + * Uses flattened leaf interfaces for discriminator detection. + * Handles collisions by building disambiguation rules based on field presence. + */ + private detectDiscriminatorWithRouting(routing: MemberRouting[]): DiscriminatorInfo | undefined { + // Collect all leaf interfaces from all members + const allLeafInterfaces: TsInterface[] = []; + for (const member of routing) { + allLeafInterfaces.push(...this.getLeafInterfaces(member.name)); + } + + if (allLeafInterfaces.length === 0) { + return undefined; + } + + // Find common fields across all leaf interfaces + const firstLeaf = allLeafInterfaces[0]; + if (!firstLeaf) { + return undefined; + } + + const commonFields = firstLeaf.properties + .map((p) => p.name) + .filter((name) => + allLeafInterfaces.every((m) => m.properties.some((p) => p.name === name)) + ); + + // Prioritize 'method' and 'type' as discriminator fields + const priorityFields = ['method', 'type', 'kind', 'role']; + const discriminatorField = priorityFields.find((f) => commonFields.includes(f)) ?? commonFields[0]; + + if (!discriminatorField) { + return undefined; + } + + // Build value -> members mapping (track all members for each value, not just last) + const valueToMembers = new Map>(); + + for (const member of routing) { + const leafInterfaces = this.getLeafInterfaces(member.name); + for (const leaf of leafInterfaces) { + const prop = leaf.properties.find((p) => p.name === discriminatorField); + if (prop) { + const constValues = this.extractConstValues(prop.type); + if (constValues) { + for (const value of constValues) { + const existing = valueToMembers.get(value) ?? []; + existing.push({ member, leaf }); + valueToMembers.set(value, existing); + } + } + } + } + } + + if (valueToMembers.size === 0) { + return undefined; + } + + // Build simple values map (for non-colliding cases) and disambiguation rules + const values = new Map(); + const disambiguations = new Map(); + + for (const [value, memberLeafs] of valueToMembers) { + // Get unique parent members for this discriminator value + const uniqueMembers = new Map(); + for (const { member, leaf } of memberLeafs) { + const existing = uniqueMembers.get(member.name); + if (existing) { + existing.leaves.push(leaf); + } else { + uniqueMembers.set(member.name, { member, leaves: [leaf] }); + } + } + + if (uniqueMembers.size === 1) { + // No collision - simple case + const [memberName] = [...uniqueMembers.keys()]; + if (memberName) { + values.set(value, memberName); + } + } else { + // Collision - need disambiguation rules + const rules = this.buildDisambiguationRules(uniqueMembers); + disambiguations.set(value, rules); + } + } + + // Need at least some values or disambiguations + if (values.size === 0 && disambiguations.size === 0) { + return undefined; + } + + return { + field: discriminatorField, + values, + disambiguations, + }; + } + + /** + * Builds disambiguation rules for members that share the same discriminator value. + * Uses field presence to distinguish between members. + */ + private buildDisambiguationRules( + uniqueMembers: Map + ): DisambiguationRule[] { + const rules: DisambiguationRule[] = []; + const memberEntries = [...uniqueMembers.entries()]; + + // Collect all fields from all leaves of all members + const memberFields = new Map>(); + for (const [memberName, { leaves }] of memberEntries) { + const fields = new Set(); + for (const leaf of leaves) { + for (const prop of leaf.properties) { + fields.add(prop.name); + } + } + memberFields.set(memberName, fields); + } + + // Track members that need a fallback route + // (they have leaves that can't be uniquely distinguished) + const membersNeedingFallback = new Set(); + + // Find distinguishing fields for each member + for (const [memberName, { member, leaves }] of memberEntries) { + const thisFields = memberFields.get(memberName) ?? new Set(); + + // Find fields that this member has but others don't + let uniqueField: string | undefined; + for (const field of thisFields) { + // Skip the discriminator field itself and common fields + if (field === 'type' || field === 'default' || field === 'title' || field === 'description') { + continue; + } + + const isUnique = memberEntries.every(([otherName, { leaves: otherLeaves }]) => { + if (otherName === memberName) return true; + // Check if any other member's leaves have this field + return !otherLeaves.some((leaf) => leaf.properties.some((p) => p.name === field)); + }); + + if (isUnique) { + uniqueField = field; + break; + } + } + + if (uniqueField) { + rules.push({ + memberName, + isUnion: member.isUnion, + requiredField: uniqueField, + }); + + // If this member has leaves that DON'T have the unique field, + // it also needs to be a fallback (e.g., SingleSelectEnumSchema has + // TitledSingleSelectEnumSchema with oneOf, but also UntitledSingleSelectEnumSchema without) + const hasLeavesWithoutField = leaves.some( + (leaf) => !leaf.properties.some((p) => p.name === uniqueField) + ); + if (hasLeavesWithoutField) { + membersNeedingFallback.add(memberName); + } + } else { + // No unique field found - this member needs to be fallback + membersNeedingFallback.add(memberName); + } + } + + // Add fallback rule - exactly ONE fallback is needed when disambiguating + // The fallback handles the case when NONE of the distinguishing fields match + // + // Key insight: The fallback should be the "base type" - typically a direct interface + // (non-union) rather than a union with complex variants. Even if a member has + // a distinguishing rule (like StringSchema with 'minLength'), it should still + // be the fallback when it's the simpler/base type. + // + // Priority: non-unions (direct interfaces) > unions + // Rationale: Unions have members with distinguishing fields (enum, oneOf, etc.) + // If those fields are absent, fall back to the base type. + + // ALL members are fallback candidates - we need to pick the best one + const fallbackCandidates = memberEntries.map(([memberName, { member }]) => ({ + memberName, + member, + })); + + // Sort: prefer non-unions (direct interfaces) over unions + fallbackCandidates.sort((a, b) => { + // Non-unions first (false < true when converted to number) + if (a.member.isUnion !== b.member.isUnion) { + return a.member.isUnion ? 1 : -1; + } + return 0; + }); + + const bestFallback = fallbackCandidates[0]; + if (bestFallback) { + rules.push({ + memberName: bestFallback.memberName, + isUnion: bestFallback.member.isUnion, + isFallback: true, + }); + } + + return rules; + } + + /** + * Extracts const values from a literal type or union of literal types. + * + * Handles: + * - Single literal: "string" → ["string"] + * - Union of literals: "number" | "integer" → ["number", "integer"] + * + * @returns Array of literal values, or undefined if not a literal type + */ + private extractConstValues(type: string): string[] | undefined { + // Split by | to handle union types like "number" | "integer" + const alternatives = type.split(/\s*\|\s*/); + const values: string[] = []; + + for (const alt of alternatives) { + const trimmed = alt.trim(); + // Match single or double quoted strings + const match = trimmed.match(/^["'](.+)["']$/); + if (match?.[1]) { + values.push(match[1]); + } + } + + return values.length > 0 ? values : undefined; + } + + /** + * Gets the indentation string. + */ + private getIndent(): string { + if (this.config.output.indentation === 'tabs') { + return '\t'; + } + return ' '.repeat(this.config.output.indentSize); + } + + /** + * Renders the PHP factory class with proper routing for unions vs interfaces. + * Routes union type alias members to their factories, interface members directly to DTOs. + */ + private renderFactoryWithRouting( + name: string, + routing: MemberRouting[], + classification: DomainClassification, + description: string | undefined, + discriminator: DiscriminatorInfo, + indent: string + ): string { + const lines: string[] = []; + const namespace = this.getNamespace(classification); + const factoryName = `${name}Factory`; + const interfaceName = `${name}Interface`; + + // Build a map of member name to routing info for lookup + const routingMap = new Map(routing.map((r) => [r.name, r])); + + // Determine if this factory has any disambiguation cases + const hasDisambiguation = discriminator.disambiguations.size > 0; + + // PHP opening tag + lines.push(' a.name === member.name); + if (memberAlias) { + const memberClassification = this.classifier.classify(member.name, memberAlias.tags); + const factoryNamespace = `${this.config.output.namespace}\\${memberClassification.domain}\\${memberClassification.subdomain}\\Factory`; + lines.push(`use ${factoryNamespace}\\${member.name}Factory;`); + } + } else { + // Import the DTO directly for interfaces + const memberIface = this.interfaces.find((i) => i.name === member.name); + if (memberIface) { + const memberClassification = this.classifier.classify(member.name, memberIface.tags); + const memberNamespace = `${this.config.output.namespace}\\${memberClassification.domain}\\${memberClassification.subdomain}`; + lines.push(`use ${memberNamespace}\\${member.name};`); + } + } + } + lines.push(''); + + // Class docblock + lines.push('/**'); + if (description) { + lines.push(` * Factory for creating ${name} union type instances.`); + lines.push(' *'); + lines.push(...formatPhpDocDescription(description)); + } else { + lines.push(` * Factory for creating ${name} union type instances.`); + } + lines.push(' *'); + lines.push(` * @mcp-domain ${classification.domain}`); + lines.push(` * @mcp-subdomain ${classification.subdomain}`); + lines.push(` * @mcp-version ${this.config.schema.version}`); + lines.push(' */'); + + // Class declaration + lines.push(`final class ${factoryName}`); + lines.push('{'); + + // Check if any routing goes to another factory (for PHPDoc type annotation) + const hasFactoryRouting = routing.some((r) => r.isUnion); + + // REGISTRY constant - maps discriminator values to class names + lines.push(`${indent}/**`); + lines.push(`${indent} * Registry mapping discriminator values to implementation classes.`); + if (hasFactoryRouting) { + lines.push(`${indent} * Note: Some values route to other factories for nested union resolution.`); + } + lines.push(`${indent} *`); + // Use stricter type when all routes are to DTOs, looser type when some route to factories + if (hasFactoryRouting) { + lines.push(`${indent} * @var array`); + } else { + lines.push(`${indent} * @var array>`); + } + lines.push(`${indent} */`); + lines.push(`${indent}public const REGISTRY = [`); + + // Add simple (non-colliding) cases to registry + for (const [value, memberName] of discriminator.values) { + const memberRouting = routingMap.get(memberName); + const target = memberRouting?.isUnion ? `${memberName}Factory` : memberName; + lines.push(`${indent}${indent}'${value}' => ${target}::class,`); + } + + // Add disambiguation cases to registry (use the primary/fallback target) + for (const [value, rules] of discriminator.disambiguations) { + // Use fallback as primary, or first rule if no fallback + const fallbackRule = rules.find((r) => r.isFallback); + const primaryRule = fallbackRule ?? rules[0]; + if (primaryRule) { + const target = primaryRule.isUnion ? `${primaryRule.memberName}Factory` : primaryRule.memberName; + lines.push(`${indent}${indent}'${value}' => ${target}::class,`); + } + } + + lines.push(`${indent}];`); + lines.push(''); + + const field = discriminator.field; + + // fromArray method + lines.push(`${indent}/**`); + lines.push(`${indent} * Creates an instance from an array.`); + lines.push(`${indent} *`); + lines.push(`${indent} * @param array $data`); + lines.push(`${indent} * @return ${interfaceName}`); + lines.push(`${indent} * @throws \\InvalidArgumentException`); + lines.push(`${indent} */`); + lines.push(`${indent}public static function fromArray(array $data): ${interfaceName}`); + lines.push(`${indent}{`); + + lines.push(`${indent}${indent}if (!isset($data['${field}'])) {`); + lines.push(`${indent}${indent}${indent}throw new \\InvalidArgumentException('Missing discriminator field: ${field}');`); + lines.push(`${indent}${indent}}`); + lines.push(''); + + if (hasDisambiguation) { + // Use switch for disambiguation cases + lines.push(`${indent}${indent}switch ($data['${field}']) {`); + + // Handle simple (non-colliding) cases + for (const [value, memberName] of discriminator.values) { + const memberRouting = routingMap.get(memberName); + const target = memberRouting?.isUnion ? `${memberName}Factory` : memberName; + + lines.push(`${indent}${indent}${indent}case '${value}':`); + lines.push(`${indent}${indent}${indent}${indent}return ${target}::fromArray($data);`); + } + + // Handle disambiguation cases (colliding discriminator values) + for (const [value, rules] of discriminator.disambiguations) { + lines.push(`${indent}${indent}${indent}case '${value}':`); + + // Generate if/elseif chain for disambiguation + let isFirst = true; + const fallbackRule = rules.find((r) => r.isFallback); + const nonFallbackRules = rules.filter((r) => !r.isFallback); + + for (const rule of nonFallbackRules) { + const target = rule.isUnion ? `${rule.memberName}Factory` : rule.memberName; + const keyword = isFirst ? 'if' : 'elseif'; + + if (rule.requiredField) { + lines.push(`${indent}${indent}${indent}${indent}${keyword} (isset($data['${rule.requiredField}'])) {`); + lines.push(`${indent}${indent}${indent}${indent}${indent}return ${target}::fromArray($data);`); + lines.push(`${indent}${indent}${indent}${indent}}`); + isFirst = false; + } + } + + // Handle fallback (no field check) + if (fallbackRule) { + const target = fallbackRule.isUnion ? `${fallbackRule.memberName}Factory` : fallbackRule.memberName; + if (nonFallbackRules.length > 0) { + lines.push(`${indent}${indent}${indent}${indent}else {`); + lines.push(`${indent}${indent}${indent}${indent}${indent}return ${target}::fromArray($data);`); + lines.push(`${indent}${indent}${indent}${indent}}`); + } else { + lines.push(`${indent}${indent}${indent}${indent}return ${target}::fromArray($data);`); + } + } else if (nonFallbackRules.length > 0) { + // No fallback, throw exception for unmatched cases + lines.push(`${indent}${indent}${indent}${indent}throw new \\InvalidArgumentException(`); + lines.push(`${indent}${indent}${indent}${indent}${indent}'Cannot determine type for ${field}=' . $data['${field}']`); + lines.push(`${indent}${indent}${indent}${indent});`); + } + } + + lines.push(`${indent}${indent}${indent}default:`); + lines.push(`${indent}${indent}${indent}${indent}throw new \\InvalidArgumentException(sprintf(`); + lines.push(`${indent}${indent}${indent}${indent}${indent}"Unknown ${field} value '%s'. Valid values: %s",`); + lines.push(`${indent}${indent}${indent}${indent}${indent}$data['${field}'],`); + lines.push(`${indent}${indent}${indent}${indent}${indent}implode(', ', array_keys(self::REGISTRY))`); + lines.push(`${indent}${indent}${indent}${indent}));`); + lines.push(`${indent}${indent}}`); + } else { + // Simple REGISTRY-based routing (no disambiguation needed) + lines.push(`${indent}${indent}/** @var string $${field} */`); + lines.push(`${indent}${indent}$${field} = $data['${field}'];`); + lines.push(`${indent}${indent}if (!isset(self::REGISTRY[$${field}])) {`); + lines.push(`${indent}${indent}${indent}throw new \\InvalidArgumentException(sprintf(`); + lines.push(`${indent}${indent}${indent}${indent}"Unknown ${field} value '%s'. Valid values: %s",`); + lines.push(`${indent}${indent}${indent}${indent}$${field},`); + lines.push(`${indent}${indent}${indent}${indent}implode(', ', array_keys(self::REGISTRY))`); + lines.push(`${indent}${indent}${indent}));`); + lines.push(`${indent}${indent}}`); + lines.push(''); + lines.push(`${indent}${indent}$class = self::REGISTRY[$${field}];`); + lines.push(`${indent}${indent}return $class::fromArray($data);`); + } + + lines.push(`${indent}}`); + lines.push(''); + + // supports() method + lines.push(`${indent}/**`); + lines.push(`${indent} * Checks if a ${field} value is supported by this factory.`); + lines.push(`${indent} *`); + lines.push(`${indent} * @param string $${field}`); + lines.push(`${indent} * @return bool`); + lines.push(`${indent} */`); + lines.push(`${indent}public static function supports(string $${field}): bool`); + lines.push(`${indent}{`); + lines.push(`${indent}${indent}return isset(self::REGISTRY[$${field}]);`); + lines.push(`${indent}}`); + lines.push(''); + + // methods() or types() method (use field name) + const methodName = field === 'method' ? 'methods' : `${field}s`; + lines.push(`${indent}/**`); + lines.push(`${indent} * Returns all supported ${field} values.`); + lines.push(`${indent} *`); + lines.push(`${indent} * @return array`); + lines.push(`${indent} */`); + lines.push(`${indent}public static function ${methodName}(): array`); + lines.push(`${indent}{`); + lines.push(`${indent}${indent}return array_keys(self::REGISTRY);`); + lines.push(`${indent}}`); + + // Closing brace + lines.push('}'); + lines.push(''); + + return lines.join('\n'); + } + + /** + * Gets the PHP namespace for a classification. + * Note: Version is used in directory structure but NOT in namespace (PHP namespaces can't start with digits) + */ + private getNamespace(classification: DomainClassification): string { + return `${this.config.output.namespace}\\${classification.domain}\\${classification.subdomain}\\Factory`; + } +} diff --git a/generator/src/generators/index.ts b/generator/src/generators/index.ts new file mode 100644 index 0000000..f9ccd4d --- /dev/null +++ b/generator/src/generators/index.ts @@ -0,0 +1,65 @@ +/** + * MCP PHP Schema Generator - Code Generators + * + * Generates PHP code (DTOs, Enums, Unions, Factories, Builders, Contracts) from TypeScript AST. + */ + +export { DtoGenerator } from './dto.js'; +export { EnumGenerator } from './enum.js'; +export { UnionGenerator } from './union.js'; +export { FactoryGenerator } from './factory.js'; +export { BuilderGenerator } from './builder.js'; +export { ContractGenerator } from './contract.js'; +export { TypeMapper } from './type-mapper.js'; +export { DomainClassifier } from './domain-classifier.js'; +export { TypeResolver } from './type-resolver.js'; +export type { ResolvedType } from './type-resolver.js'; +export type { ContractInfo, GeneratedContract } from './contract.js'; + +// Inheritance graph utilities +export { + buildInheritanceGraph, + getAncestors, + getDescendants, + getDirectParent, + isRoot, + getDepth, + getInheritanceStats, + printInheritanceTree, + topologicalSort, + sortInterfacesForGeneration, + classifyProperties, + getOwnProperties, + buildInterfaceMap, +} from './inheritance-graph.js'; +export type { + InheritanceGraph, + TopologicalSortResult, + PropertyClassification, + NarrowedProperty, +} from './inheritance-graph.js'; + +/** + * Formats a description for PHPDoc multiline output. + * Ensures every line has proper ` * ` prefix. + * + * @param description - The description text (may contain newlines) + * @param indent - Optional indentation prefix (e.g., ' ' for properties) + * @returns Array of formatted lines ready to be pushed to output + */ +export function formatPhpDocDescription(description: string, indent: string = ''): string[] { + const lines: string[] = []; + const descriptionLines = description.split('\n'); + + for (const line of descriptionLines) { + const trimmedLine = line.trim(); + if (trimmedLine === '') { + // Empty line in description - just add the asterisk + lines.push(`${indent} *`); + } else { + lines.push(`${indent} * ${trimmedLine}`); + } + } + + return lines; +} diff --git a/generator/src/generators/inheritance-graph.ts b/generator/src/generators/inheritance-graph.ts new file mode 100644 index 0000000..326ebdf --- /dev/null +++ b/generator/src/generators/inheritance-graph.ts @@ -0,0 +1,610 @@ +/** + * MCP PHP Schema Generator - Inheritance Graph + * + * Builds and manages inheritance relationships between TypeScript interfaces. + * Used for determining generation order and property classification. + */ + +import type { TsInterface, TsProperty } from '../types/index.js'; + +/** + * Represents the inheritance graph for all interfaces. + */ +export interface InheritanceGraph { + /** Maps type name → parent type names (direct parents only) */ + readonly parents: ReadonlyMap; + /** Maps type name → child type names (direct children only) */ + readonly children: ReadonlyMap; + /** Type names that have no MCP parents (root of inheritance chains) */ + readonly roots: readonly string[]; + /** All type names in the graph */ + readonly allTypes: ReadonlySet; +} + +/** + * TypeScript built-in types that should not be treated as MCP parents. + * These are the types that indicate a "root" in our inheritance hierarchy. + */ +const BUILTIN_TYPES: Set = new Set([ + // No built-in base types in MCP schema - all interfaces are standalone + // or extend other MCP interfaces +]); + +/** + * Builds an inheritance graph from TypeScript interfaces. + * + * @param interfaces - The parsed TypeScript interfaces + * @returns The complete inheritance graph + */ +export function buildInheritanceGraph(interfaces: readonly TsInterface[]): InheritanceGraph { + const typeNames = new Set(interfaces.map((i) => i.name)); + const parents = new Map(); + const children = new Map(); + const roots: string[] = []; + + // Initialize maps for all types + for (const iface of interfaces) { + parents.set(iface.name, []); + children.set(iface.name, []); + } + + // Build parent/child relationships + for (const iface of interfaces) { + // Filter to only include parents that are in our interface list (MCP types) + const mcpParents = iface.extends.filter((p) => typeNames.has(p) && !BUILTIN_TYPES.has(p)); + + parents.set(iface.name, mcpParents); + + // Add this type as a child of each parent + for (const parentName of mcpParents) { + const parentChildren = children.get(parentName) ?? []; + parentChildren.push(iface.name); + children.set(parentName, parentChildren); + } + + // If no MCP parents, this is a root type + if (mcpParents.length === 0) { + roots.push(iface.name); + } + } + + return { + parents: parents as ReadonlyMap, + children: children as ReadonlyMap, + roots, + allTypes: typeNames, + }; +} + +/** + * Gets all ancestors of a type (parents, grandparents, etc.). + * Returns them in order from nearest to furthest ancestor. + */ +export function getAncestors( + typeName: string, + graph: InheritanceGraph, + visited: Set = new Set() +): string[] { + if (visited.has(typeName)) { + return []; // Prevent circular inheritance + } + visited.add(typeName); + + const directParents = graph.parents.get(typeName) ?? []; + const ancestors: string[] = [...directParents]; + + for (const parent of directParents) { + ancestors.push(...getAncestors(parent, graph, visited)); + } + + return ancestors; +} + +/** + * Gets all descendants of a type (children, grandchildren, etc.). + * Returns them in order from nearest to furthest descendant. + */ +export function getDescendants( + typeName: string, + graph: InheritanceGraph, + visited: Set = new Set() +): string[] { + if (visited.has(typeName)) { + return []; // Prevent circular inheritance + } + visited.add(typeName); + + const directChildren = graph.children.get(typeName) ?? []; + const descendants: string[] = [...directChildren]; + + for (const child of directChildren) { + descendants.push(...getDescendants(child, graph, visited)); + } + + return descendants; +} + +/** + * Gets the immediate (direct) parent of a type. + * Returns undefined if the type is a root (extends AbstractDataTransferObject). + * For multiple inheritance, returns the first parent. + */ +export function getDirectParent(typeName: string, graph: InheritanceGraph): string | undefined { + const directParents = graph.parents.get(typeName) ?? []; + return directParents[0]; +} + +/** + * Checks if a type is a root type (no MCP parents). + */ +export function isRoot(typeName: string, graph: InheritanceGraph): boolean { + return graph.roots.includes(typeName); +} + +/** + * Gets the depth of a type in the inheritance hierarchy. + * Root types have depth 0, their children have depth 1, etc. + */ +export function getDepth(typeName: string, graph: InheritanceGraph): number { + const directParent = getDirectParent(typeName, graph); + if (!directParent) { + return 0; + } + return 1 + getDepth(directParent, graph); +} + +/** + * Returns inheritance chain statistics for debugging/logging. + */ +export function getInheritanceStats(graph: InheritanceGraph): { + totalTypes: number; + rootTypes: number; + maxDepth: number; + typesWithChildren: number; + inheritanceChains: { root: string; depth: number; descendants: number }[]; +} { + let maxDepth = 0; + let typesWithChildren = 0; + const inheritanceChains: { root: string; depth: number; descendants: number }[] = []; + + for (const typeName of graph.allTypes) { + const depth = getDepth(typeName, graph); + maxDepth = Math.max(maxDepth, depth); + + const childCount = (graph.children.get(typeName) ?? []).length; + if (childCount > 0) { + typesWithChildren++; + } + } + + // Build chain info for root types + for (const root of graph.roots) { + const descendants = getDescendants(root, graph); + if (descendants.length > 0) { + // Calculate max depth from this root + let chainDepth = 0; + for (const desc of descendants) { + chainDepth = Math.max(chainDepth, getDepth(desc, graph)); + } + inheritanceChains.push({ + root, + depth: chainDepth, + descendants: descendants.length, + }); + } + } + + // Sort chains by descendant count (most significant first) + inheritanceChains.sort((a, b) => b.descendants - a.descendants); + + return { + totalTypes: graph.allTypes.size, + rootTypes: graph.roots.length, + maxDepth, + typesWithChildren, + inheritanceChains, + }; +} + +/** + * Prints a tree representation of the inheritance hierarchy. + * Useful for debugging and understanding the structure. + */ +export function printInheritanceTree( + graph: InheritanceGraph, + maxRoots: number = 10 +): string { + const lines: string[] = []; + const significantRoots = graph.roots + .filter((r) => (graph.children.get(r) ?? []).length > 0) + .slice(0, maxRoots); + + for (const root of significantRoots) { + printNode(root, graph, lines, '', true); + lines.push(''); + } + + return lines.join('\n'); +} + +function printNode( + typeName: string, + graph: InheritanceGraph, + lines: string[], + prefix: string, + isLast: boolean +): void { + const connector = isLast ? '└── ' : '├── '; + lines.push(`${prefix}${connector}${typeName}`); + + const children = graph.children.get(typeName) ?? []; + const childPrefix = prefix + (isLast ? ' ' : '│ '); + + for (let i = 0; i < children.length; i++) { + printNode(children[i]!, graph, lines, childPrefix, i === children.length - 1); + } +} + +// ============================================================================ +// Topological Sort +// ============================================================================ + +/** + * Result of topological sort operation. + */ +export interface TopologicalSortResult { + /** Types in topological order (parents before children) */ + readonly sorted: readonly string[]; + /** True if sorting was successful (no cycles) */ + readonly success: boolean; + /** Types involved in a cycle, if any */ + readonly cycleTypes?: readonly string[]; +} + +/** + * Performs topological sort on the inheritance graph using Kahn's algorithm. + * + * Returns types in an order where parent classes come before their children, + * ensuring that when generating PHP files, parent classes exist before + * child classes reference them. + * + * @example + * // Returns: ['Request', 'JSONRPCRequest', 'InitializeRequest', ...] + * const result = topologicalSort(graph); + * + * @param graph - The inheritance graph + * @returns Sorted type names with success status + */ +export function topologicalSort(graph: InheritanceGraph): TopologicalSortResult { + // Calculate in-degree (number of parents) for each type + const inDegree = new Map(); + for (const typeName of graph.allTypes) { + const parentCount = (graph.parents.get(typeName) ?? []).length; + inDegree.set(typeName, parentCount); + } + + // Start with nodes that have no parents (in-degree = 0) + const queue: string[] = []; + for (const typeName of graph.allTypes) { + if (inDegree.get(typeName) === 0) { + queue.push(typeName); + } + } + + // Sort the initial queue for deterministic output + queue.sort(); + + const sorted: string[] = []; + + while (queue.length > 0) { + // Process nodes at same level in sorted order (deterministic) + const current = queue.shift()!; + sorted.push(current); + + // "Remove" edges from this node by decrementing in-degree of children + const children = graph.children.get(current) ?? []; + const newlyReady: string[] = []; + + for (const child of children) { + const currentInDegree = inDegree.get(child) ?? 0; + inDegree.set(child, currentInDegree - 1); + + // If all parents have been processed, this child is ready + if (currentInDegree - 1 === 0) { + newlyReady.push(child); + } + } + + // Sort newly ready nodes for deterministic output + newlyReady.sort(); + queue.push(...newlyReady); + } + + // Check for cycles - if we didn't process all nodes, there's a cycle + if (sorted.length < graph.allTypes.size) { + const cycleTypes = [...graph.allTypes].filter((t) => !sorted.includes(t)); + return { + sorted, + success: false, + cycleTypes, + }; + } + + return { + sorted, + success: true, + }; +} + +/** + * Sorts interfaces for generation, ensuring parents come before children. + * + * This is a convenience wrapper around topologicalSort that works directly + * with TsInterface arrays, returning the interfaces in the correct order. + * + * @param interfaces - The interfaces to sort + * @param graph - The precomputed inheritance graph (optional, will be built if not provided) + * @returns Interfaces in topological order + * @throws Error if circular dependencies are detected + */ +export function sortInterfacesForGeneration( + interfaces: readonly TsInterface[], + graph?: InheritanceGraph +): TsInterface[] { + const inheritanceGraph = graph ?? buildInheritanceGraph(interfaces); + const result = topologicalSort(inheritanceGraph); + + if (!result.success) { + throw new Error( + `Circular inheritance detected involving types: ${result.cycleTypes?.join(', ')}` + ); + } + + // Create a map for O(1) lookup + const interfaceMap = new Map(interfaces.map((i) => [i.name, i])); + + // Return interfaces in sorted order + return result.sorted + .map((name) => interfaceMap.get(name)) + .filter((i): i is TsInterface => i !== undefined); +} + +// ============================================================================ +// Property Classification +// ============================================================================ + +/** + * Classification of properties for inheritance-aware code generation. + */ +export interface PropertyClassification { + /** Properties defined directly in this type (not in any parent) */ + readonly ownProperties: readonly TsProperty[]; + /** Properties inherited from parent types (should not be redeclared) */ + readonly inheritedProperties: readonly TsProperty[]; + /** Properties with same name as parent but narrower/different type */ + readonly narrowedProperties: readonly NarrowedProperty[]; + /** All properties (own + inherited, with narrowed replacing inherited) */ + readonly allProperties: readonly TsProperty[]; +} + +/** + * A property that narrows a parent property's type. + */ +export interface NarrowedProperty { + /** The property as defined in this type */ + readonly property: TsProperty; + /** The original property from the parent */ + readonly parentProperty: TsProperty; + /** The parent type name where the original property is defined */ + readonly parentTypeName: string; +} + +/** + * Classifies properties into own, inherited, and narrowed categories. + * + * This is the core function for inheritance-aware code generation. + * It determines which properties should be declared in a class vs inherited. + * + * IMPORTANT: PHP only supports single inheritance. For TypeScript interfaces + * that extend multiple parents (e.g., `extends Result, SamplingMessage`), + * only properties from the FIRST parent chain are "inherited" in PHP. + * Properties from other TypeScript parents become "own" properties. + * + * @param iface - The interface to classify properties for + * @param graph - The inheritance graph + * @param interfaceMap - Map of all interfaces by name + * @returns Classification of properties + * + * @example + * // For CreateMessageResult extends Result, SamplingMessage: + * // - PHP extends: Result (first parent) + * // - inherited: _meta (from Result) + * // - own: role, content (from SamplingMessage - treated as own in PHP!) + * // - own: model, stopReason (defined directly) + */ +export function classifyProperties( + iface: TsInterface, + graph: InheritanceGraph, + interfaceMap: ReadonlyMap +): PropertyClassification { + // Get all direct parents from TypeScript + const allDirectParents = graph.parents.get(iface.name) ?? []; + + // PHP parent is the first parent (PHP single inheritance) + const phpParentName = allDirectParents[0]; + + // Other TypeScript parents (not the PHP parent) + const nonPhpParents = allDirectParents.slice(1); + + // Collect properties from the PHP parent chain ONLY + // These are the properties that will be truly inherited in PHP + const inheritedPropsMap = new Map(); + + if (phpParentName) { + // Get ancestors of the PHP parent chain only + const phpAncestors = [phpParentName, ...getAncestors(phpParentName, graph)]; + + for (const ancestorName of phpAncestors) { + const ancestor = interfaceMap.get(ancestorName); + if (!ancestor) continue; + + for (const prop of ancestor.properties) { + // Only add if not already present (nearer ancestors take precedence) + if (!inheritedPropsMap.has(prop.name)) { + inheritedPropsMap.set(prop.name, { prop, fromType: ancestorName }); + } + } + } + } + + // Collect properties from non-PHP TypeScript parents + // These must become OWN properties in PHP (since we can only extend one class) + const nonPhpParentProps = new Map(); + for (const parentName of nonPhpParents) { + // Include the parent itself and its entire ancestor chain + const ancestorChain = [parentName, ...getAncestors(parentName, graph)]; + + for (const ancestorName of ancestorChain) { + const ancestor = interfaceMap.get(ancestorName); + if (!ancestor) continue; + + for (const prop of ancestor.properties) { + // Only add if not already present and not in PHP parent chain + if (!nonPhpParentProps.has(prop.name) && !inheritedPropsMap.has(prop.name)) { + nonPhpParentProps.set(prop.name, prop); + } + } + } + } + + // Classify this interface's direct properties + const ownProperties: TsProperty[] = []; + const narrowedProperties: NarrowedProperty[] = []; + + for (const prop of iface.properties) { + const inheritedInfo = inheritedPropsMap.get(prop.name); + + if (!inheritedInfo) { + // Property is not in the PHP parent chain - it's own + ownProperties.push(prop); + } else if (isTypeNarrowed(prop, inheritedInfo.prop)) { + // Property exists in parent but with different/broader type - it's narrowed + narrowedProperties.push({ + property: prop, + parentProperty: inheritedInfo.prop, + parentTypeName: inheritedInfo.fromType, + }); + } + // If property exists in PHP parent with same type, it's just inherited (skip) + } + + // Add properties from non-PHP TypeScript parents as own properties + // (since PHP can't inherit from multiple classes) + for (const [name, prop] of nonPhpParentProps) { + // Only add if not already in ownProperties + if (!ownProperties.some((p) => p.name === name)) { + ownProperties.push(prop); + } + } + + // Build inherited properties list (excluding narrowed ones) + const narrowedNames = new Set(narrowedProperties.map((n) => n.property.name)); + const inheritedProperties: TsProperty[] = []; + for (const [name, info] of inheritedPropsMap) { + if (!narrowedNames.has(name)) { + inheritedProperties.push(info.prop); + } + } + + // Build allProperties (own + inherited, with narrowed replacing inherited where applicable) + const allProperties: TsProperty[] = []; + const addedNames = new Set(); + + // First add inherited properties + for (const prop of inheritedProperties) { + allProperties.push(prop); + addedNames.add(prop.name); + } + + // Then add narrowed properties (replacing any inherited) + for (const narrowed of narrowedProperties) { + allProperties.push(narrowed.property); + addedNames.add(narrowed.property.name); + } + + // Finally add own properties + for (const prop of ownProperties) { + if (!addedNames.has(prop.name)) { + allProperties.push(prop); + addedNames.add(prop.name); + } + } + + return { + ownProperties, + inheritedProperties, + narrowedProperties, + allProperties, + }; +} + +/** + * Checks if a property's type is narrowed compared to a parent property. + * + * Type narrowing occurs when: + * - The types are textually different (more specific type in child) + * - The child type is a subtype of the parent type + * + * Examples: + * - `params?: object` → `params: InitializeRequestParams` (narrowed) + * - `params?: array` → `params?: array` (not narrowed) + */ +function isTypeNarrowed(childProp: TsProperty, parentProp: TsProperty): boolean { + // If types are identical (after normalization), not narrowed + const childType = normalizeType(childProp.type); + const parentType = normalizeType(parentProp.type); + + if (childType === parentType) { + // Check optionality - if child is required but parent is optional, it's narrowed + if (!childProp.isOptional && parentProp.isOptional) { + return true; + } + return false; + } + + // Types are different - this is a narrowing + return true; +} + +/** + * Normalizes a type string for comparison. + */ +function normalizeType(type: string): string { + return type + .replace(/\s+/g, ' ') // Normalize whitespace + .replace(/\s*\|\s*/g, ' | ') // Normalize union spacing + .trim(); +} + +/** + * Gets properties that are ONLY defined in this interface (not inherited). + * This is the list of properties that should be declared in a PHP class + * that properly extends its parent. + */ +export function getOwnProperties( + iface: TsInterface, + graph: InheritanceGraph, + interfaceMap: ReadonlyMap +): readonly TsProperty[] { + const classification = classifyProperties(iface, graph, interfaceMap); + return classification.ownProperties; +} + +/** + * Convenience function to build an interface map from an array. + */ +export function buildInterfaceMap( + interfaces: readonly TsInterface[] +): ReadonlyMap { + return new Map(interfaces.map((i) => [i.name, i])); +} diff --git a/generator/src/generators/type-mapper.ts b/generator/src/generators/type-mapper.ts new file mode 100644 index 0000000..9c88152 --- /dev/null +++ b/generator/src/generators/type-mapper.ts @@ -0,0 +1,508 @@ +/** + * MCP PHP Schema Generator - Type Mapper + * + * Maps TypeScript types to PHP types. + */ + +import type { PhpType } from '../types/index.js'; + +/** + * Maps TypeScript types to PHP types. + */ +export class TypeMapper { + /** + * Primitive type mappings from TypeScript to PHP. + * Note: 'mixed' is PHP 8.0+ only, so we use empty string for untyped. + */ + private static readonly PRIMITIVE_MAP: Record = { + string: 'string', + number: 'float', + boolean: 'bool', + null: 'null', + undefined: '', // TypeScript undefined - untyped in PHP 7.4 (mixed is PHP 8.0+) + any: '', // untyped in PHP 7.4 + unknown: '', // untyped in PHP 7.4 + void: 'void', + never: 'void', + object: 'object', + }; + + /** + * Types that should be untyped in PHP 7.4 (no type hint, only PHPDoc). + */ + private static readonly UNTYPED_PRIMITIVES = new Set(['undefined', 'any', 'unknown']); + + /** + * Integer type patterns (TypeScript number that should be PHP int). + * + * TypeScript has only `number` type - no int/float distinction. + * We use semantic property names to determine PHP type: + * - Counts, lengths, sizes → int + * - Priorities, temperatures, bounds → float + */ + private static readonly INTEGER_PATTERNS = [ + /^-?\d+$/, // Literal integers + /.Id$/i, // Compound IDs like requestId, sessionId (NOT standalone "id" - JSON-RPC id is string|number) + /Length$/i, // minLength, maxLength - character counts + /Items$/i, // minItems, maxItems - array item counts + /^size$/i, // file size in bytes + /^total$/i, // total count + /^ttl$/i, // time-to-live in seconds + /Interval$/i, // pollInterval, etc. - time intervals + /Count$/i, // any count property + /Index$/i, // array indices + /Offset$/i, // byte/character offsets + /Depth$/i, // nesting depth + /Level$/i, // log level, etc. + /Port$/i, // network port numbers + ]; + + /** + * Strips TypeScript single-line comments from a type string. + * Handles inline comments like: "working" // description | "cancelled" + */ + private static stripComments(type: string): string { + // Remove single-line comments (// ...) but be careful with URLs + // Split on | first, strip comments from each part, then rejoin + const parts = type.split('|'); + const cleaned = parts.map((part) => { + // Remove // comment to end of this part + const commentIndex = part.indexOf('//'); + if (commentIndex >= 0) { + return part.slice(0, commentIndex).trim(); + } + return part.trim(); + }); + return cleaned.filter((p) => p !== '').join(' | '); + } + + /** + * Maps a TypeScript type to a PHP type. + */ + static mapType(tsType: string, propertyName?: string): PhpType { + const trimmed = this.stripComments(tsType.trim()); + + // Handle TypeScript 'typeof' expressions - map to the underlying type + // e.g., 'typeof JSONRPC_VERSION' -> string (since constants are strings) + if (trimmed.startsWith('typeof ')) { + // The constant being referenced is typically a string constant + return { + type: 'string', + nullable: false, + isArray: false, + phpDocType: "'2.0'", // JSON-RPC version is always "2.0" + }; + } + + // Handle inline object types { ... } - map to array or object + if (this.isInlineObjectType(trimmed)) { + // Check if it's an index signature { [key: string]: T } + if (/^\{\s*\[/.test(trimmed)) { + return { + type: 'array', + nullable: false, + isArray: true, + phpDocType: 'array', + }; + } + // Regular inline object + return { + type: 'object', + nullable: false, + isArray: false, + phpDocType: 'object', + }; + } + + // Handle import() types - treat as the imported type name + if (trimmed.startsWith('import(')) { + const match = trimmed.match(/import\([^)]+\)\.(\w+)/); + if (match?.[1]) { + return { + type: match[1], + nullable: false, + isArray: false, + }; + } + return { type: '', nullable: false, isArray: false, isUntyped: true, phpDocType: 'mixed' }; + } + + // Handle nullable types (Type | null, Type | undefined) + if (this.isNullableType(trimmed)) { + const baseType = this.extractNonNullType(trimmed); + const mapped = this.mapType(baseType, propertyName); + return { ...mapped, nullable: true }; + } + + // Handle array types + if (this.isArrayType(trimmed)) { + const itemType = this.extractArrayItemType(trimmed); + const mappedItem = this.mapType(itemType, propertyName); + return { + type: 'array', + nullable: false, + isArray: true, + arrayItemType: mappedItem.type, + phpDocType: `${mappedItem.type}[]`, + }; + } + + // Handle literal string types ("value") + if (this.isStringLiteral(trimmed)) { + return { + type: 'string', + nullable: false, + isArray: false, + phpDocType: `'${this.extractStringLiteralValue(trimmed)}'`, + }; + } + + // Handle literal number types + if (this.isNumberLiteral(trimmed)) { + const isInt = /^-?\d+$/.test(trimmed); + return { + type: isInt ? 'int' : 'float', + nullable: false, + isArray: false, + phpDocType: trimmed, + }; + } + + // Handle primitive types + const primitive = this.PRIMITIVE_MAP[trimmed]; + if (primitive !== undefined) { + // Check if number should be int based on property name + if (primitive === 'float' && propertyName && this.shouldBeInteger(propertyName)) { + return { + type: 'int', + nullable: false, + isArray: false, + }; + } + // Handle untyped primitives (undefined, any, unknown) + if (this.UNTYPED_PRIMITIVES.has(trimmed)) { + return { + type: '', + nullable: false, + isArray: false, + isUntyped: true, + phpDocType: 'mixed', + }; + } + return { + type: primitive, + nullable: false, + isArray: false, + }; + } + + // Handle union types (convert to mixed or first concrete type) + if (this.isUnionType(trimmed)) { + return this.mapUnionType(trimmed, propertyName); + } + + // Handle intersection types (typically interfaces) + if (this.isIntersectionType(trimmed)) { + // For intersections, we typically use the first type or a combined interface + const types = trimmed.split('&').map((t) => t.trim()); + return this.mapType(types[0] ?? 'mixed', propertyName); + } + + // Handle generic types like Record, Map + if (this.isGenericType(trimmed)) { + return this.mapGenericType(trimmed); + } + + // Default: treat as a class/interface reference + return { + type: this.toPhpClassName(trimmed), + nullable: false, + isArray: false, + }; + } + + /** + * Checks if a type is nullable (contains | null or | undefined). + */ + private static isNullableType(type: string): boolean { + return /\|\s*(null|undefined)/.test(type) || /(null|undefined)\s*\|/.test(type); + } + + /** + * Extracts the non-null type from a nullable type. + */ + private static extractNonNullType(type: string): string { + return type + .split('|') + .map((t) => t.trim()) + .filter((t) => t !== 'null' && t !== 'undefined') + .join(' | '); + } + + /** + * Checks if a type is an array type. + */ + private static isArrayType(type: string): boolean { + return ( + type.endsWith('[]') || + type.startsWith('Array<') || + type.startsWith('ReadonlyArray<') || + type.startsWith('readonly ') && type.includes('[]') + ); + } + + /** + * Extracts the item type from an array type. + */ + private static extractArrayItemType(type: string): string { + if (type.endsWith('[]')) { + return type.slice(0, -2).replace(/^readonly\s+/, ''); + } + if (type.startsWith('Array<') || type.startsWith('ReadonlyArray<')) { + const start = type.indexOf('<') + 1; + const end = type.lastIndexOf('>'); + return type.slice(start, end); + } + return type; + } + + /** + * Checks if a type is a string literal. + * Only matches single literals like "value" or 'value', not unions like "a" | "b". + */ + private static isStringLiteral(type: string): boolean { + // Must start and end with same quote type, and not contain unescaped quotes in middle + if (type.startsWith('"') && type.endsWith('"')) { + // Check for unescaped double quotes in the middle (excluding first/last) + const middle = type.slice(1, -1); + return !middle.includes('"') || /^[^"\\]*(?:\\.[^"\\]*)*$/.test(middle); + } + if (type.startsWith("'") && type.endsWith("'")) { + // Check for unescaped single quotes in the middle + const middle = type.slice(1, -1); + return !middle.includes("'") || /^[^'\\]*(?:\\.[^'\\]*)*$/.test(middle); + } + return false; + } + + /** + * Extracts the value from a string literal type. + */ + private static extractStringLiteralValue(type: string): string { + return type.slice(1, -1); + } + + /** + * Checks if a type is a number literal. + */ + private static isNumberLiteral(type: string): boolean { + return /^-?\d+(\.\d+)?$/.test(type); + } + + /** + * Checks if a type is an inline object type { ... }. + */ + private static isInlineObjectType(type: string): boolean { + if (!type.startsWith('{')) { + return false; + } + // Match balanced braces + let depth = 0; + for (const char of type) { + if (char === '{') depth++; + if (char === '}') depth--; + } + return depth === 0 && type.endsWith('}'); + } + + /** + * Checks if a property name suggests an integer type. + */ + private static shouldBeInteger(propertyName: string): boolean { + return this.INTEGER_PATTERNS.some((pattern) => pattern.test(propertyName)); + } + + /** + * Checks if a type is a union type. + */ + private static isUnionType(type: string): boolean { + // Check for | outside of generic brackets + let depth = 0; + for (const char of type) { + if (char === '<' || char === '(') depth++; + if (char === '>' || char === ')') depth--; + if (char === '|' && depth === 0) return true; + } + return false; + } + + /** + * Maps a union type to PHP. + */ + private static mapUnionType(type: string, propertyName?: string): PhpType { + const types = this.splitUnionType(type); + const nonNullTypes = types.filter((t) => t !== 'null' && t !== 'undefined'); + + if (nonNullTypes.length === 0) { + return { type: 'null', nullable: true, isArray: false }; + } + + if (nonNullTypes.length === 1) { + const mapped = this.mapType(nonNullTypes[0] ?? 'mixed', propertyName); + return { + ...mapped, + nullable: types.length !== nonNullTypes.length, + }; + } + + // Multiple non-null types - check if they share a common base + const allStrings = nonNullTypes.every((t) => this.isStringLiteral(t)); + if (allStrings) { + return { + type: 'string', + nullable: types.length !== nonNullTypes.length, + isArray: false, + phpDocType: nonNullTypes.map((t) => `'${this.extractStringLiteralValue(t)}'`).join('|'), + }; + } + + // Default to untyped for complex unions (PHP 7.4 doesn't support union type hints) + return { + type: '', + nullable: types.length !== nonNullTypes.length, + isArray: false, + isUntyped: true, + phpDocType: nonNullTypes.join('|'), + }; + } + + /** + * Splits a union type into its constituent types. + */ + private static splitUnionType(type: string): string[] { + const types: string[] = []; + let current = ''; + let depth = 0; + + for (const char of type) { + if (char === '<' || char === '(' || char === '[') depth++; + if (char === '>' || char === ')' || char === ']') depth--; + + if (char === '|' && depth === 0) { + types.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + if (current.trim()) { + types.push(current.trim()); + } + + return types; + } + + /** + * Checks if a type is an intersection type. + */ + private static isIntersectionType(type: string): boolean { + let depth = 0; + for (const char of type) { + if (char === '<' || char === '(') depth++; + if (char === '>' || char === ')') depth--; + if (char === '&' && depth === 0) return true; + } + return false; + } + + /** + * Checks if a type is a generic type. + */ + private static isGenericType(type: string): boolean { + return type.includes('<') && type.includes('>'); + } + + /** + * Maps a generic type to PHP. + */ + private static mapGenericType(type: string): PhpType { + const genericName = type.slice(0, type.indexOf('<')); + + if (genericName === 'Record' || genericName === 'Map') { + return { + type: 'array', + nullable: false, + isArray: true, + phpDocType: 'array', + }; + } + + if (genericName === 'Set') { + return { + type: 'array', + nullable: false, + isArray: true, + phpDocType: 'array', + }; + } + + if (genericName === 'Promise') { + // Extract the resolved type + const innerType = type.slice(type.indexOf('<') + 1, type.lastIndexOf('>')); + return this.mapType(innerType); + } + + // Default: treat as array + return { + type: 'array', + nullable: false, + isArray: true, + }; + } + + /** + * Converts a TypeScript type name to a PHP class name. + */ + private static toPhpClassName(typeName: string): string { + // Remove generic parameters + const baseName = typeName.includes('<') ? typeName.slice(0, typeName.indexOf('<')) : typeName; + + // Handle namespaced types + return baseName.replace(/\./g, '\\'); + } + + /** + * Gets the PHP type hint string for use in method signatures. + * Returns empty string for untyped (PHP 7.4 doesn't support mixed). + */ + static getTypeHint(phpType: PhpType): string { + // Untyped properties have no PHP type hint + if (phpType.isUntyped || phpType.type === '') { + return ''; + } + if (phpType.nullable) { + return `?${phpType.type}`; + } + return phpType.type; + } + + /** + * Gets the PHPDoc type string. + */ + static getPhpDocType(phpType: PhpType): string { + // Prefer explicit phpDocType (may contain FQN), fallback to type + let docType = phpType.phpDocType ?? phpType.type; + + // Only use arrayItemType[] format if no phpDocType was explicitly set + if (phpType.isArray && phpType.arrayItemType && !phpType.phpDocType) { + docType = `${phpType.arrayItemType}[]`; + } + + if (phpType.nullable) { + docType = `${docType}|null`; + } + + return docType; + } +} diff --git a/generator/src/generators/type-resolver.ts b/generator/src/generators/type-resolver.ts new file mode 100644 index 0000000..8bedf6c --- /dev/null +++ b/generator/src/generators/type-resolver.ts @@ -0,0 +1,414 @@ +/** + * MCP PHP Schema Generator - Type Resolver + * + * Resolves TypeScript type references to their actual definitions. + * Handles type aliases, union types, and interface references. + */ + +import type { TsTypeAlias, TsInterface, PhpType, DomainClassification } from '../types/index.js'; +import { TypeMapper } from './type-mapper.js'; +import { DomainClassifier } from './domain-classifier.js'; + +/** + * Information about a resolved type for PHP code generation. + */ +export interface ResolvedType { + /** The PHP type information */ + readonly phpType: PhpType; + /** If this is a class reference, the fully qualified namespace */ + readonly namespace?: string; + /** The simple class name (without namespace) */ + readonly className?: string; + /** Whether this needs an import statement */ + readonly needsImport: boolean; +} + +/** + * Resolves TypeScript types to PHP types with namespace information. + * Handles type alias resolution and cross-domain references. + */ +export class TypeResolver { + private readonly typeAliasMap: Map; + private readonly interfaceMap: Map; + private readonly classifier: DomainClassifier; + private readonly baseNamespace: string; + + constructor( + typeAliases: readonly TsTypeAlias[], + interfaces: readonly TsInterface[], + baseNamespace: string, + classifier?: DomainClassifier + ) { + this.typeAliasMap = new Map(typeAliases.map((a) => [a.name, a])); + this.interfaceMap = new Map(interfaces.map((i) => [i.name, i])); + this.classifier = classifier ?? new DomainClassifier(); + this.baseNamespace = baseNamespace; + } + + /** + * Resolves a type name to its PHP type with namespace information. + */ + resolve(typeName: string, propertyName?: string, contextTags?: readonly { tagName: string; text?: string }[]): ResolvedType { + const trimmed = typeName.trim(); + + // Handle union types with undefined - strip undefined and make nullable + if (trimmed.includes('|') && trimmed.includes('undefined')) { + const nonUndefinedParts = trimmed + .split('|') + .map((p) => p.trim()) + .filter((p) => p !== 'undefined' && p !== ''); + + if (nonUndefinedParts.length === 0) { + return { + phpType: { type: '', nullable: true, isArray: false, isUntyped: true, phpDocType: 'mixed' }, + needsImport: false, + }; + } + + // Resolve the non-undefined part + const resolvedPart = this.resolve(nonUndefinedParts.join(' | '), propertyName, contextTags); + return { + ...resolvedPart, + phpType: { ...resolvedPart.phpType, nullable: true }, + }; + } + + // Check if it's a type alias + const typeAlias = this.typeAliasMap.get(trimmed); + if (typeAlias) { + return this.resolveTypeAlias(typeAlias, propertyName); + } + + // Check if it's an interface reference + const iface = this.interfaceMap.get(trimmed); + if (iface) { + return this.resolveInterface(iface, contextTags); + } + + // Handle array types BEFORE complex union check - this ensures (A | B | C)[] + // is correctly identified as an array of union type, not a malformed union + if (this.isArrayType(trimmed)) { + const itemType = this.extractArrayItemType(trimmed); + const resolvedItem = this.resolve(itemType, propertyName, contextTags); + + // Use phpDocType for array item if available (contains FQN for complex types) + const itemDocType = resolvedItem.phpType.phpDocType ?? resolvedItem.phpType.type; + + return { + phpType: { + type: 'array', + nullable: false, + isArray: true, + arrayItemType: resolvedItem.phpType.type, + phpDocType: `array<${itemDocType}>`, + }, + namespace: resolvedItem.namespace, + className: resolvedItem.className, + needsImport: resolvedItem.needsImport, + }; + } + + // Check if it's a union that contains interfaces or type aliases + if (this.isComplexUnion(trimmed)) { + // Resolve each member to get FQN for PHPDoc + const members = trimmed + .split('|') + .map((p) => p.trim()) + .filter((p) => p !== '' && p !== 'null' && p !== 'undefined'); + + const primitives = new Set(['string', 'number', 'boolean', 'int', 'float']); + + // Build FQN PHPDoc type for each member + const fqnMembers = members.map((memberName) => { + // Handle array types within union (e.g., SamplingMessageContentBlock[]) + if (memberName.endsWith('[]')) { + const itemType = memberName.slice(0, -2); + const resolvedItem = this.resolveSingleType(itemType); + return `array<${resolvedItem}>`; + } + + // Check for primitives + if (primitives.has(memberName)) { + return memberName === 'number' ? 'int|float' : memberName; + } + + return this.resolveSingleType(memberName); + }); + + return { + phpType: { + type: '', + nullable: trimmed.includes('null'), + isArray: false, + isUntyped: true, + phpDocType: fqnMembers.join('|'), + }, + needsImport: false, + }; + } + + // Check if it's an intersection type (A & B) - use untyped since PHP/PHPStan + // can't resolve intersection types of unrelated classes + if (trimmed.includes('&')) { + return { + phpType: { + type: '', + nullable: false, + isArray: false, + isUntyped: true, + phpDocType: 'mixed', + }, + needsImport: false, + }; + } + + // Fall back to TypeMapper for primitives and other types + const phpType = TypeMapper.mapType(typeName, propertyName); + + // If TypeMapper returned a class-like type, check if we need an import + if (this.isClassType(phpType.type)) { + // This is an unknown type reference - use untyped to be safe + return { + phpType: { ...phpType, type: '', isUntyped: true, phpDocType: trimmed }, + needsImport: false, + }; + } + + return { + phpType, + needsImport: false, + }; + } + + /** + * Resolves a type alias to its PHP type. + * For union type aliases, returns the generated interface type with proper namespace. + */ + private resolveTypeAlias(alias: TsTypeAlias, propertyName?: string): ResolvedType { + const type = alias.type.trim(); + + // Check if it's a primitive union (like string | number) + if (this.isPrimitiveUnion(type)) { + const phpType = TypeMapper.mapType(type, propertyName); + return { + phpType, + needsImport: false, + }; + } + + // Check if it's a union of interface references + // For these, we use the generated interface (e.g., SamplingMessageContentBlockInterface) + if (this.isInterfaceUnion(type)) { + // Use the generated interface for this union type alias + const interfaceName = `${alias.name}Interface`; + const classification = this.classifier.classify(alias.name, alias.tags); + const namespace = `${this.baseNamespace}\\${classification.domain}\\${classification.subdomain}\\Union`; + + return { + phpType: { + type: interfaceName, + nullable: type.includes('null'), + isArray: false, + }, + namespace, + className: interfaceName, + needsImport: true, + }; + } + + // Single interface reference in type alias + const singleRef = this.interfaceMap.get(type); + if (singleRef) { + return this.resolveInterface(singleRef, alias.tags); + } + + // Handle intersection types (A & B) - use untyped since PHP/PHPStan + // can't resolve intersection types of unrelated classes + if (type.includes('&')) { + return { + phpType: { + type: '', + nullable: false, + isArray: false, + isUntyped: true, + phpDocType: 'mixed', + }, + needsImport: false, + }; + } + + // Fall back to TypeMapper + const phpType = TypeMapper.mapType(type, propertyName); + return { + phpType, + needsImport: false, + }; + } + + /** + * Resolves an interface reference to its PHP type with namespace. + * DTOs are placed directly in the subdomain namespace (no Dto subfolder). + */ + private resolveInterface( + iface: TsInterface, + _contextTags?: readonly { tagName: string; text?: string }[] + ): ResolvedType { + const classification = this.classifier.classify(iface.name, iface.tags, iface.syntheticParent); + const namespace = `${this.baseNamespace}\\${classification.domain}\\${classification.subdomain}`; + + return { + phpType: { + type: iface.name, + nullable: false, + isArray: false, + }, + namespace, + className: iface.name, + needsImport: true, + }; + } + + /** + * Checks if a type is an array type. + */ + private isArrayType(type: string): boolean { + return ( + type.endsWith('[]') || + type.startsWith('Array<') || + type.startsWith('ReadonlyArray<') || + (type.startsWith('readonly ') && type.includes('[]')) + ); + } + + /** + * Extracts the item type from an array type. + */ + private extractArrayItemType(type: string): string { + if (type.endsWith('[]')) { + let itemType = type.slice(0, -2).replace(/^readonly\s+/, ''); + // Handle parenthesized types like (A | B)[] + if (itemType.startsWith('(') && itemType.endsWith(')')) { + itemType = itemType.slice(1, -1); + } + return itemType; + } + if (type.startsWith('Array<') || type.startsWith('ReadonlyArray<')) { + const start = type.indexOf('<') + 1; + const end = type.lastIndexOf('>'); + return type.slice(start, end); + } + return type; + } + + /** + * Checks if a type string represents a primitive union. + */ + private isPrimitiveUnion(type: string): boolean { + const parts = type + .split('|') + .map((p) => p.trim()) + .filter((p) => p !== 'null' && p !== 'undefined'); + + const primitives = new Set(['string', 'number', 'boolean', 'any', 'unknown']); + + return parts.every( + (p) => + primitives.has(p) || + /^["']/.test(p) || // string literal + /^-?\d+(\.\d+)?$/.test(p) // number literal + ); + } + + /** + * Checks if a type string represents a union of interface references. + */ + private isInterfaceUnion(type: string): boolean { + const parts = type + .split('|') + .map((p) => p.trim()) + .filter((p) => p !== '' && p !== 'null' && p !== 'undefined'); + + // At least one part must be a known interface + return parts.some((p) => this.interfaceMap.has(p)); + } + + /** + * Checks if a type string is a union that contains interfaces or type aliases. + * This is broader than isInterfaceUnion - also handles type aliases that are unions. + */ + private isComplexUnion(type: string): boolean { + if (!type.includes('|')) { + return false; + } + + const parts = type + .split('|') + .map((p) => p.trim()) + .filter((p) => p !== '' && p !== 'null' && p !== 'undefined'); + + // Check if any part is an interface, type alias, or array of either + return parts.some((p) => { + const baseName = p.endsWith('[]') ? p.slice(0, -2) : p; + return this.interfaceMap.has(baseName) || this.typeAliasMap.has(baseName); + }); + } + + /** + * Resolves a single type name (not an array) to its FQN string. + * Used for building PHPDoc types in complex unions. + */ + private resolveSingleType(typeName: string): string { + // Check if it's an interface + const iface = this.interfaceMap.get(typeName); + if (iface) { + const classification = this.classifier.classify(typeName, iface.tags, iface.syntheticParent); + return `\\${this.baseNamespace}\\${classification.domain}\\${classification.subdomain}\\${typeName}`; + } + + // Check if it's a type alias that is a union (has generated interface) + const alias = this.typeAliasMap.get(typeName); + if (alias && this.isInterfaceUnion(alias.type)) { + const classification = this.classifier.classify(typeName, alias.tags); + return `\\${this.baseNamespace}\\${classification.domain}\\${classification.subdomain}\\Union\\${typeName}Interface`; + } + + // Unknown type - return as-is + return typeName; + } + + /** + * Checks if a type string looks like a class/interface name. + */ + private isClassType(type: string): boolean { + const primitives = new Set([ + 'string', + 'int', + 'float', + 'bool', + 'array', + 'object', + 'null', + 'void', + '', // Empty string for untyped + ]); + + return !primitives.has(type) && /^[A-Z]/.test(type); + } + + /** + * Gets the domain classification for a type name. + */ + getClassification(typeName: string): DomainClassification | undefined { + const iface = this.interfaceMap.get(typeName); + if (iface) { + return this.classifier.classify(iface.name, iface.tags, iface.syntheticParent); + } + + const alias = this.typeAliasMap.get(typeName); + if (alias) { + return this.classifier.classify(alias.name, alias.tags); + } + + return undefined; + } +} diff --git a/generator/src/generators/union.ts b/generator/src/generators/union.ts new file mode 100644 index 0000000..cd4a309 --- /dev/null +++ b/generator/src/generators/union.ts @@ -0,0 +1,219 @@ +/** + * MCP PHP Schema Generator - Union Generator + * + * Generates PHP interfaces for TypeScript union types. + */ + +import type { TsTypeAlias, GeneratorConfig, DomainClassification } from '../types/index.js'; +import { DomainClassifier } from './domain-classifier.js'; +import { formatPhpDocDescription } from './index.js'; + +/** + * Generates PHP interfaces for union types. + */ +export class UnionGenerator { + private readonly classifier: DomainClassifier; + private readonly config: GeneratorConfig; + private readonly typeAliases: readonly TsTypeAlias[]; + /** Map from union name to parent union names that contain it */ + private readonly parentUnionMap: Map; + + constructor(config: GeneratorConfig, typeAliases: readonly TsTypeAlias[] = []) { + this.config = config; + this.classifier = new DomainClassifier(); + this.typeAliases = typeAliases; + this.parentUnionMap = this.buildParentUnionMap(); + } + + /** + * Builds a map from child union names to their parent union names. + * Used to determine interface extension hierarchy. + */ + private buildParentUnionMap(): Map { + const map = new Map(); + + // First, identify all unions + const unions = this.typeAliases.filter((alias) => this.isUnion(alias)); + const unionNames = new Set(unions.map((u) => u.name)); + + // For each union, check if its members are also unions + for (const union of unions) { + const members = this.extractMembers(union); + for (const member of members) { + if (unionNames.has(member)) { + // This member is a union, so it's a child of the current union + const parents = map.get(member) ?? []; + parents.push(union.name); + map.set(member, parents); + } + } + } + + return map; + } + + /** + * Checks if a type alias represents a union of object types. + * Returns false for primitive-only unions like `string | number`. + */ + isUnion(typeAlias: TsTypeAlias): boolean { + const type = typeAlias.type.trim(); + + // Must contain | but not be a simple string literal union (enum) + if (!type.includes('|')) { + return false; + } + + // Check if it's a union of object types (interface names) + const parts = type.split('|').map((p) => p.trim()); + + // Skip string literal unions (enums) + if (parts.every((p) => p.startsWith('"') || p.startsWith("'"))) { + return false; + } + + // Skip primitive-only unions (can't implement interfaces) + const primitives = new Set(['string', 'number', 'boolean', 'null', 'undefined', 'any', 'unknown', 'never']); + const nonPrimitiveParts = parts.filter((p) => !primitives.has(p) && !p.startsWith('"') && !p.startsWith("'")); + + // Must have at least one non-primitive, non-literal type + return nonPrimitiveParts.length > 0; + } + + /** + * Extracts the member type names from a union. + * Filters out primitives and literals since they can't implement interfaces. + */ + extractMembers(typeAlias: TsTypeAlias): string[] { + const type = typeAlias.type.trim(); + const primitives = new Set(['string', 'number', 'boolean', 'null', 'undefined', 'any', 'unknown', 'never']); + + return type + .split('|') + .map((p) => p.trim()) + .filter((p) => { + // Skip empty strings + if (p === '') return false; + // Skip string/number literals + if (p.startsWith('"') || p.startsWith("'")) return false; + // Skip primitives + if (primitives.has(p)) return false; + return true; + }); + } + + /** + * Generates PHP interface code for a union type. + */ + generate(typeAlias: TsTypeAlias): string { + const classification = this.classifier.classify(typeAlias.name, typeAlias.tags); + const members = this.extractMembers(typeAlias); + const indent = this.getIndent(); + + // Get parent unions that this union should extend + const parentUnions = this.parentUnionMap.get(typeAlias.name) ?? []; + + return this.renderInterface(typeAlias.name, members, classification, typeAlias.description, indent, parentUnions); + } + + /** + * Gets the indentation string. + */ + private getIndent(): string { + if (this.config.output.indentation === 'tabs') { + return '\t'; + } + return ' '.repeat(this.config.output.indentSize); + } + + /** + * Renders the PHP interface. + */ + private renderInterface( + name: string, + members: string[], + classification: DomainClassification, + description: string | undefined, + indent: string, + parentUnions: string[] = [] + ): string { + const lines: string[] = []; + const namespace = this.getNamespace(classification); + const interfaceName = `${name}Interface`; + + // PHP opening tag + lines.push(' a.name === parent); + if (parentAlias) { + const parentClassification = this.classifier.classify(parent, parentAlias.tags); + const parentNamespace = `${this.config.output.namespace}\\${parentClassification.domain}\\${parentClassification.subdomain}\\Union`; + lines.push(`use ${parentNamespace}\\${parent}Interface;`); + } + } + if (parentUnions.length > 0) { + lines.push(''); + } + + // Class docblock + lines.push('/**'); + if (description) { + lines.push(...formatPhpDocDescription(description)); + lines.push(' *'); + } + lines.push(' * Union type members:'); + for (const member of members) { + lines.push(` * - ${member}`); + } + lines.push(' *'); + lines.push(` * @mcp-domain ${classification.domain}`); + lines.push(` * @mcp-subdomain ${classification.subdomain}`); + lines.push(` * @mcp-version ${this.config.schema.version}`); + lines.push(' */'); + + // Interface declaration with extends if there are parent unions + if (parentUnions.length > 0) { + const extendsList = parentUnions.map((p) => `${p}Interface`).join(', '); + lines.push(`interface ${interfaceName} extends ${extendsList}`); + } else { + lines.push(`interface ${interfaceName}`); + } + lines.push('{'); + + // Only add toArray method if not extending another interface (parent already has it) + if (parentUnions.length === 0) { + // toArray method - the core serialization contract + // Note: Discriminator access is via toArray()['type'] or toArray()['method'] etc. + // This keeps the interface simple and works with any discriminator field name. + lines.push(`${indent}/**`); + lines.push(`${indent} * Converts the instance to an array.`); + lines.push(`${indent} *`); + lines.push(`${indent} * @return array`); + lines.push(`${indent} */`); + lines.push(`${indent}public function toArray(): array;`); + } + + // Closing brace + lines.push('}'); + lines.push(''); + + return lines.join('\n'); + } + + /** + * Gets the PHP namespace for a classification. + * Note: Version is used in directory structure but NOT in namespace (PHP namespaces can't start with digits) + */ + private getNamespace(classification: DomainClassification): string { + return `${this.config.output.namespace}\\${classification.domain}\\${classification.subdomain}\\Union`; + } +} diff --git a/generator/src/index.ts b/generator/src/index.ts new file mode 100644 index 0000000..3fcda61 --- /dev/null +++ b/generator/src/index.ts @@ -0,0 +1,482 @@ +/** + * MCP PHP Schema Generator + * + * Generates PHP 7.4 DTOs from the Model Context Protocol TypeScript schema. + * + * @package @wordpress/php-mcp-schema-generator + */ + +import type { GeneratorConfig, GenerationResult, GeneratedFile, GenerationStats, GenerationError, TsInterface, TsTypeAlias, UnionMembershipMap, UnionMembershipInfo, VersionTracker } from './types/index.js'; +import { fetchSchema, fetchSchemaFresh } from './fetcher/index.js'; +import { parseSchema } from './parser/index.js'; +import { DtoGenerator, EnumGenerator, UnionGenerator, FactoryGenerator, BuilderGenerator, ContractGenerator, DomainClassifier } from './generators/index.js'; +import { FileWriter, generateAbstractDto, generateAbstractEnum, generateValidatesRequiredFieldsTrait } from './writers/index.js'; +import { SyntheticDtoExtractor, updateInterfacesWithSyntheticTypes } from './extractors/index.js'; +import { buildVersionTracker, createEmptyVersionTracker } from './version-tracker/index.js'; + +// Re-export types for external use +export type { + GeneratorConfig, + GenerationResult, + GeneratedFile, + GenerationStats, + GenerationError, + AstOutput, + TsInterface, + TsTypeAlias, + TsProperty, + JsDocTag, + DomainClassification, + PhpType, + PhpProperty, + PhpClassMeta, + UnionMembershipMap, + UnionMembershipInfo, + VersionTracker, + VersionInfo, + PropertyVersionInfo, +} from './types/index.js'; + +// Re-export config utilities +export { createConfig, validateConfig, DEFAULT_SCHEMA_SOURCE, DEFAULT_PHP_OUTPUT } from './config/index.js'; + +// Re-export fetcher utilities +export { fetchSchema, fetchSchemaFresh, clearCache } from './fetcher/index.js'; + +// Re-export parser utilities +export { parseSchema, parseSchemaFile, resolveInheritance, getCategoryTag } from './parser/index.js'; + +// Re-export generators +export { DtoGenerator, EnumGenerator, UnionGenerator, FactoryGenerator, BuilderGenerator, ContractGenerator, TypeMapper, DomainClassifier } from './generators/index.js'; + +// Re-export writers +export { FileWriter, generateAbstractDto, generateAbstractEnum, generateValidatesRequiredFieldsTrait } from './writers/index.js'; + +// Re-export extractors +export { SyntheticDtoExtractor, updateInterfacesWithSyntheticTypes } from './extractors/index.js'; + +// Re-export version tracker +export { buildVersionTracker, createEmptyVersionTracker, loadSchemaVersions, getVersionsUpTo } from './version-tracker/index.js'; +export type { BuildVersionTrackerOptions } from './version-tracker/index.js'; + +/** + * Generation options. + */ +export interface GenerateOptions { + /** Force fresh fetch from GitHub (ignore cache) */ + readonly fresh?: boolean; + /** Callback for progress updates */ + readonly onProgress?: (message: string) => void; +} + +// ============================================================================ +// Discriminator Detection Helpers +// ============================================================================ + +/** + * Detects the discriminator field for a union type by finding a common field + * with const values across all members. + */ +function detectDiscriminatorField( + memberNames: string[], + interfaces: readonly TsInterface[], + typeAliases: readonly TsTypeAlias[] +): string | undefined { + // Get leaf interfaces for all members (flattening nested unions) + const allLeafInterfaces: TsInterface[] = []; + for (const memberName of memberNames) { + allLeafInterfaces.push(...getLeafInterfaces(memberName, interfaces, typeAliases)); + } + + if (allLeafInterfaces.length === 0) { + return undefined; + } + + const firstLeaf = allLeafInterfaces[0]; + if (!firstLeaf) { + return undefined; + } + + // Find common fields across all leaf interfaces + const commonFields = firstLeaf.properties + .map((p) => p.name) + .filter((name) => + allLeafInterfaces.every((m) => m.properties.some((p) => p.name === name)) + ); + + // Prioritize 'method' and 'type' as discriminator fields + const priorityFields = ['method', 'type', 'kind', 'role']; + return priorityFields.find((f) => commonFields.includes(f)) ?? commonFields[0]; +} + +/** + * Gets the discriminator value for a specific member. + */ +function getDiscriminatorValue( + memberName: string, + discriminatorField: string, + interfaces: readonly TsInterface[] +): string | undefined { + const iface = interfaces.find((i) => i.name === memberName); + if (!iface) { + return undefined; + } + + const prop = iface.properties.find((p) => p.name === discriminatorField); + if (!prop) { + return undefined; + } + + return extractConstValue(prop.type); +} + +/** + * Gets the leaf interfaces for a member (handles nested unions). + */ +function getLeafInterfaces( + memberName: string, + interfaces: readonly TsInterface[], + typeAliases: readonly TsTypeAlias[] +): TsInterface[] { + // Check if it's a direct interface + const directInterface = interfaces.find((i) => i.name === memberName); + if (directInterface) { + return [directInterface]; + } + + // Check if it's a union type alias + const typeAlias = typeAliases.find((a) => a.name === memberName); + if (typeAlias && typeAlias.type.includes('|')) { + // Extract member names from union type + const unionMembers = typeAlias.type + .split('|') + .map((m) => m.trim()) + .filter((m) => m.length > 0); + + // Recursively get leaf interfaces + const leaves: TsInterface[] = []; + for (const unionMember of unionMembers) { + leaves.push(...getLeafInterfaces(unionMember, interfaces, typeAliases)); + } + return leaves; + } + + return []; +} + +/** + * Extracts a const value from a literal type. + * Only extracts single literals, not union types like "a" | "b". + */ +function extractConstValue(type: string): string | undefined { + const trimmed = type.trim(); + + // Check if it's a single string literal (not a union) + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + const middle = trimmed.slice(1, -1); + if (!middle.includes('"')) { + return middle; + } + } + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + const middle = trimmed.slice(1, -1); + if (!middle.includes("'")) { + return middle; + } + } + + return undefined; +} + +/** + * Main generation function. + * + * Fetches the MCP TypeScript schema, parses it, and generates PHP code. + */ +export async function generate( + config: GeneratorConfig, + options: GenerateOptions = {} +): Promise { + const startTime = Date.now(); + const errors: GenerationError[] = []; + const files: GeneratedFile[] = []; + + const progress = options.onProgress ?? ((): void => {}); + + // Step 1: Fetch schema + progress('Fetching schema...'); + const fetchResult = options.fresh + ? await fetchSchemaFresh(config) + : await fetchSchema(config); + + if (config.verbose) { + progress(`Schema fetched from: ${fetchResult.source}${fetchResult.cached ? ' (cached)' : ''}`); + } + + // Step 2: Parse schema + progress('Parsing TypeScript schema...'); + const ast = parseSchema(fetchResult.content, { + includeInternalTypes: true, // Include all types for complete schema + }); + + if (config.verbose) { + progress(`Parsed ${ast.interfaces.length} interfaces, ${ast.typeAliases.length} type aliases`); + } + + // Step 2.5: Extract synthetic DTOs from inline object types + progress('Extracting inline object types...'); + const syntheticExtractor = new SyntheticDtoExtractor(); + const syntheticResult = syntheticExtractor.extract(ast.interfaces); + + // Update original interfaces with synthetic type references + const updatedInterfaces = updateInterfacesWithSyntheticTypes( + [...ast.interfaces] as TsInterface[], + syntheticResult.propertyTypeMap + ); + + // Combine original and synthetic interfaces + const allInterfaces: TsInterface[] = [...updatedInterfaces, ...syntheticResult.interfaces]; + + if (config.verbose) { + progress(`Extracted ${syntheticResult.interfaces.length} synthetic DTOs from inline types`); + } + + // Step 3: Set up generators + // Create shared classifier so all generators use the same classification cache + const classifier = new DomainClassifier(); + const enumGenerator = new EnumGenerator(config); + const unionGenerator = new UnionGenerator(config, ast.typeAliases); // Pass typeAliases for parent union detection + + // Build union membership map BEFORE creating DTO generator + // This tells us which DTOs need to implement which union interfaces + // Also includes discriminator field and value for each member + progress('Building union membership map...'); + const unionMembershipMap: UnionMembershipMap = new Map(); + + for (const alias of ast.typeAliases) { + if (unionGenerator.isUnion(alias)) { + const members = unionGenerator.extractMembers(alias); + const unionClassification = classifier.classify(alias.name, alias.tags); + const unionNamespace = `${config.output.namespace}\\${unionClassification.domain}\\${unionClassification.subdomain}\\Union`; + + // Detect discriminator field for this union + const discriminatorField = detectDiscriminatorField(members, allInterfaces, ast.typeAliases); + + for (const memberName of members) { + // Get the discriminator value for this specific member + const discriminatorValue = discriminatorField + ? getDiscriminatorValue(memberName, discriminatorField, allInterfaces) + : undefined; + + const membershipInfo: UnionMembershipInfo = { + unionName: alias.name, + namespace: unionNamespace, + discriminatorField, + discriminatorValue, + }; + + const existing = unionMembershipMap.get(memberName); + if (existing) { + existing.push(membershipInfo); + } else { + unionMembershipMap.set(memberName, [membershipInfo]); + } + } + } + } + + if (config.verbose) { + progress(`Built union membership map: ${unionMembershipMap.size} DTOs implement union interfaces`); + } + + // Step 3.5: Build version tracker by fetching and comparing schema versions up to target + progress('Building version tracker from schema history...'); + let versionTracker: VersionTracker; + try { + versionTracker = await buildVersionTracker({ + targetVersion: config.schema.version, + onProgress: config.verbose ? progress : undefined, + }); + } catch (error) { + // If version tracking fails (e.g., network error), continue without it + const errorMessage = error instanceof Error ? error.message : String(error); + progress(`Warning: Version tracking unavailable (${errorMessage}). Continuing without version annotations.`); + versionTracker = createEmptyVersionTracker(); + } + + const dtoGenerator = new DtoGenerator(allInterfaces, config, {}, ast.typeAliases, classifier, unionMembershipMap, versionTracker); + const factoryGenerator = new FactoryGenerator(config, allInterfaces, ast.typeAliases); // Include typeAliases for nested union support + const builderGenerator = new BuilderGenerator(config, allInterfaces, ast.typeAliases, classifier, versionTracker); + const writer = new FileWriter(config); + + // Step 4: Create directory structure + progress('Creating directory structure...'); + await writer.createDirectoryStructure(); + + // Step 5: Generate base classes and traits + progress('Generating base classes...'); + files.push({ + path: `Common/AbstractDataTransferObject.php`, + content: generateAbstractDto(config), + type: 'dto', + }); + files.push({ + path: `Common/AbstractEnum.php`, + content: generateAbstractEnum(config), + type: 'enum', + }); + files.push({ + path: `Common/Traits/ValidatesRequiredFields.php`, + content: generateValidatesRequiredFieldsTrait(config), + type: 'dto', // Categorize as 'dto' since it's used by DTOs + }); + + // Step 6: Generate DTOs from interfaces (including synthetic ones) + // First pass: classify all non-synthetic interfaces to populate cache + progress('Generating DTOs...'); + for (const iface of allInterfaces) { + if (!iface.isSynthetic) { + classifier.classify(iface.name, iface.tags); + } + } + + // Second pass: generate all DTOs + for (const iface of allInterfaces) { + try { + const content = dtoGenerator.generate(iface); + const classification = classifier.classify(iface.name, iface.tags, iface.syntheticParent); + const path = writer.getOutputPath(classification, 'Dto', iface.name); + files.push({ path, content, type: 'dto' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push({ + type: iface.name, + message: `Failed to generate DTO: ${message}`, + source: 'interface', + }); + } + } + + // Step 7: Generate enums and unions from type aliases + progress('Generating enums and unions...'); + for (const alias of ast.typeAliases) { + try { + if (enumGenerator.isEnum(alias)) { + const content = enumGenerator.generate(alias); + const classification = classifier.classify(alias.name, alias.tags); + const path = writer.getOutputPath(classification, 'Enum', alias.name); + files.push({ path, content, type: 'enum' }); + } else if (unionGenerator.isUnion(alias)) { + // Generate union interface + const interfaceContent = unionGenerator.generate(alias); + const classification = classifier.classify(alias.name, alias.tags); + const interfacePath = writer.getOutputPath(classification, 'Union', `${alias.name}Interface`); + files.push({ path: interfacePath, content: interfaceContent, type: 'union' }); + + // Generate factory if enabled and discriminator exists + if (config.output.generateFactories) { + const members = unionGenerator.extractMembers(alias); + const factoryContent = factoryGenerator.generate(alias, members); + // Only create factory file if generator returned content (has discriminator) + if (factoryContent !== null) { + const factoryPath = writer.getOutputPath(classification, 'Factory', `${alias.name}Factory`); + files.push({ path: factoryPath, content: factoryContent, type: 'factory' }); + } + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push({ + type: alias.name, + message: `Failed to generate type: ${message}`, + source: 'typeAlias', + }); + } + } + + // Step 8: Generate builders if enabled + if (config.output.generateBuilders) { + progress('Generating builders...'); + for (const iface of allInterfaces) { + try { + const content = builderGenerator.generate(iface); + const classification = classifier.classify(iface.name, iface.tags, iface.syntheticParent); + const path = writer.getOutputPath(classification, 'Builder', `${iface.name}Builder`); + files.push({ path, content, type: 'builder' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push({ + type: iface.name, + message: `Failed to generate builder: ${message}`, + source: 'builder', + }); + } + } + } + + // Step 9: Generate contracts (PHP interfaces based on extends relationships) + progress('Generating contracts...'); + const contractGenerator = new ContractGenerator(config, allInterfaces); + const contracts = contractGenerator.generateAll(); + + for (const contract of contracts) { + const path = `Common/Contracts/${contract.name}.php`; + files.push({ path, content: contract.content, type: 'interface' }); + } + + // Step 10: Validate class names (PSR-1 compliance) + progress('Validating class names...'); + const invalidClassNames: string[] = []; + for (const iface of allInterfaces) { + if (iface.name.includes('_')) { + invalidClassNames.push(iface.name); + } + } + for (const alias of ast.typeAliases) { + if (alias.name.includes('_')) { + invalidClassNames.push(alias.name); + } + } + + if (invalidClassNames.length > 0) { + throw new Error( + `PSR-1 violation: Class names containing underscores are not allowed.\n` + + `Invalid names: ${invalidClassNames.join(', ')}\n` + + `Please fix the generator to produce valid PascalCase names.` + ); + } + + // Step 11: Write files + progress('Writing files...'); + const writeResult = await writer.writeFiles(files); + + for (const result of writeResult.results) { + if (!result.success) { + errors.push({ + type: result.path, + message: result.error ?? 'Unknown write error', + source: 'writer', + }); + } + } + + // Calculate stats + const stats: GenerationStats = { + totalTypes: allInterfaces.length + ast.typeAliases.length, + dtos: files.filter((f) => f.type === 'dto').length, + enums: files.filter((f) => f.type === 'enum').length, + unions: files.filter((f) => f.type === 'union').length, + factories: files.filter((f) => f.type === 'factory').length, + builders: files.filter((f) => f.type === 'builder').length, + interfaces: files.filter((f) => f.type === 'interface').length, // Contracts + duration: Date.now() - startTime, + }; + + progress('Done!'); + + return { + files, + stats, + errors, + }; +} diff --git a/generator/src/parser/index.ts b/generator/src/parser/index.ts new file mode 100644 index 0000000..39cae41 --- /dev/null +++ b/generator/src/parser/index.ts @@ -0,0 +1,286 @@ +/** + * MCP PHP Schema Generator - TypeScript Parser + * + * Parses TypeScript schema files using ts-morph to extract interfaces, + * type aliases, and metadata. + */ + +import { Project, SourceFile, InterfaceDeclaration, TypeAliasDeclaration, EnumDeclaration } from 'ts-morph'; +import type { AstOutput, TsInterface, TsTypeAlias, TsProperty, JsDocTag, TsEnum, TsEnumMember } from '../types/index.js'; + +/** + * Parser options. + */ +export interface ParserOptions { + readonly includeInternalTypes?: boolean; +} + +/** + * Parses TypeScript schema content and extracts type information. + */ +export function parseSchema(content: string, options: ParserOptions = {}): AstOutput { + const project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: { + strict: true, + skipLibCheck: true, + }, + }); + + const sourceFile = project.createSourceFile('schema.ts', content); + + const interfaces = extractInterfaces(sourceFile, options); + const typeAliases = extractTypeAliases(sourceFile, options); + const enums = extractEnums(sourceFile, options); + + return { + interfaces, + typeAliases, + enums, + }; +} + +/** + * Extracts all interface declarations from a source file. + */ +function extractInterfaces(sourceFile: SourceFile, options: ParserOptions): TsInterface[] { + const interfaces: TsInterface[] = []; + + for (const iface of sourceFile.getInterfaces()) { + const tags = extractJsDocTags(iface); + + // Skip internal types unless explicitly included + if (!options.includeInternalTypes && hasInternalTag(tags)) { + continue; + } + + interfaces.push({ + name: iface.getName(), + description: getJsDocDescription(iface), + extends: iface.getExtends().map((e) => { + // Extract just the base type name without generics + const text = e.getText(); + const genericStart = text.indexOf('<'); + return genericStart > 0 ? text.slice(0, genericStart) : text; + }), + properties: extractProperties(iface), + tags, + }); + } + + return interfaces; +} + +/** + * Extracts all type alias declarations from a source file. + */ +function extractTypeAliases(sourceFile: SourceFile, options: ParserOptions): TsTypeAlias[] { + const typeAliases: TsTypeAlias[] = []; + + for (const alias of sourceFile.getTypeAliases()) { + const tags = extractJsDocTags(alias); + + // Skip internal types unless explicitly included + if (!options.includeInternalTypes && hasInternalTag(tags)) { + continue; + } + + // Use getTypeNode().getText() to get the source text (preserves union syntax) + // instead of getType().getText() which returns resolved/imported types + const typeNode = alias.getTypeNode(); + const typeText = typeNode ? typeNode.getText() : alias.getType().getText(); + + typeAliases.push({ + name: alias.getName(), + type: typeText, + description: getJsDocDescription(alias), + tags, + }); + } + + return typeAliases; +} + +/** + * Extracts all enum declarations from a source file. + */ +function extractEnums(sourceFile: SourceFile, options: ParserOptions): TsEnum[] { + const enums: TsEnum[] = []; + + for (const enumDecl of sourceFile.getEnums()) { + const tags = extractJsDocTags(enumDecl); + + // Skip internal types unless explicitly included + if (!options.includeInternalTypes && hasInternalTag(tags)) { + continue; + } + + const members: TsEnumMember[] = enumDecl.getMembers().map((member) => ({ + name: member.getName(), + value: member.getValue() ?? member.getName(), + })); + + enums.push({ + name: enumDecl.getName(), + members, + description: getJsDocDescription(enumDecl), + tags, + }); + } + + return enums; +} + +/** + * Extracts properties from an interface declaration. + * Uses getTypeNode().getText() to get source type text (preserves type alias names) + * instead of getType().getText() which returns resolved/imported types. + */ +function extractProperties(iface: InterfaceDeclaration): TsProperty[] { + return iface.getProperties().map((prop) => { + // Use getTypeNode() to get the source text, preserving type alias names + const typeNode = prop.getTypeNode(); + const typeText = typeNode ? typeNode.getText() : prop.getType().getText(); + + return { + name: prop.getName(), + type: typeText, + isOptional: prop.hasQuestionToken(), + description: getPropertyDescription(prop), + isReadonly: prop.isReadonly(), + }; + }); +} + +/** + * Extracts JSDoc tags from a declaration. + */ +function extractJsDocTags( + node: InterfaceDeclaration | TypeAliasDeclaration | EnumDeclaration +): JsDocTag[] { + const tags: JsDocTag[] = []; + + for (const jsDoc of node.getJsDocs()) { + for (const tag of jsDoc.getTags()) { + tags.push({ + tagName: tag.getTagName(), + text: tag.getComment()?.toString(), + }); + } + } + + return tags; +} + +/** + * Gets the JSDoc description from a declaration. + */ +function getJsDocDescription( + node: InterfaceDeclaration | TypeAliasDeclaration | EnumDeclaration +): string | undefined { + const jsDocs = node.getJsDocs(); + if (jsDocs.length === 0) { + return undefined; + } + + const description = jsDocs[0]?.getDescription(); + return description?.trim() || undefined; +} + +/** + * Gets the description from a property's JSDoc. + */ +function getPropertyDescription(prop: ReturnType[0]): string | undefined { + const jsDocs = prop.getJsDocs(); + if (jsDocs.length === 0) { + return undefined; + } + + return jsDocs[0]?.getDescription()?.trim() || undefined; +} + +/** + * Checks if tags include @internal. + */ +function hasInternalTag(tags: JsDocTag[]): boolean { + return tags.some((tag) => tag.tagName === 'internal'); +} + +/** + * Gets the @category tag value if present. + */ +export function getCategoryTag(tags: readonly JsDocTag[]): string | undefined { + const categoryTag = tags.find((tag) => tag.tagName === 'category'); + return categoryTag?.text; +} + +/** + * Parses a TypeScript file from disk. + */ +export async function parseSchemaFile(filePath: string, options: ParserOptions = {}): Promise { + const project = new Project({ + compilerOptions: { + strict: true, + skipLibCheck: true, + }, + }); + + const sourceFile = project.addSourceFileAtPath(filePath); + + const interfaces = extractInterfaces(sourceFile, options); + const typeAliases = extractTypeAliases(sourceFile, options); + const enums = extractEnums(sourceFile, options); + + return { + interfaces, + typeAliases, + enums, + }; +} + +/** + * Resolves inheritance by collecting all properties including inherited ones. + */ +export function resolveInheritance( + interfaceName: string, + interfaces: readonly TsInterface[], + visited: Set = new Set() +): TsProperty[] { + if (visited.has(interfaceName)) { + return []; // Prevent circular inheritance + } + visited.add(interfaceName); + + const iface = interfaces.find((i) => i.name === interfaceName); + if (!iface) { + return []; + } + + const allProperties: TsProperty[] = []; + + // First, collect properties from parent interfaces (with deduplication) + for (const parentName of iface.extends) { + const parentProps = resolveInheritance(parentName, interfaces, visited); + for (const prop of parentProps) { + const existingIndex = allProperties.findIndex((p) => p.name === prop.name); + if (existingIndex >= 0) { + // Later parent overrides earlier parent + allProperties[existingIndex] = prop; + } else { + allProperties.push(prop); + } + } + } + + // Then add own properties (can override parent properties) + for (const prop of iface.properties) { + const existingIndex = allProperties.findIndex((p) => p.name === prop.name); + if (existingIndex >= 0) { + allProperties[existingIndex] = prop; // Override + } else { + allProperties.push(prop); + } + } + + return allProperties; +} diff --git a/generator/src/types/index.ts b/generator/src/types/index.ts new file mode 100644 index 0000000..aa55524 --- /dev/null +++ b/generator/src/types/index.ts @@ -0,0 +1,355 @@ +/** + * MCP PHP Schema Generator - Type Definitions + * + * Core types used throughout the generator for representing TypeScript AST + * structures and PHP code generation targets. + */ + +// ============================================================================ +// TypeScript AST Types (parsed from schema.ts) +// ============================================================================ + +/** + * Represents a JSDoc tag extracted from TypeScript source. + * Used primarily for @category and @internal tags. + */ +export interface JsDocTag { + readonly tagName: string; + readonly text?: string; +} + +/** + * Represents a property extracted from a TypeScript interface. + */ +export interface TsProperty { + readonly name: string; + readonly type: string; + readonly isOptional: boolean; + readonly description?: string; + readonly isReadonly?: boolean; + /** Original inline type before synthetic extraction */ + readonly originalInlineType?: string; +} + +/** + * Represents a TypeScript interface definition. + */ +export interface TsInterface { + readonly name: string; + readonly description?: string; + readonly extends: readonly string[]; + readonly properties: readonly TsProperty[]; + readonly tags: readonly JsDocTag[]; + /** True if this interface was generated from an inline object type */ + readonly isSynthetic?: boolean; + /** Parent interface name if this is a synthetic type */ + readonly syntheticParent?: string; +} + +/** + * Represents a TypeScript type alias (union types, mapped types, etc.). + */ +export interface TsTypeAlias { + readonly name: string; + readonly type: string; + readonly description?: string; + readonly tags: readonly JsDocTag[]; +} + +/** + * Combined AST output from ts-morph extraction. + */ +export interface AstOutput { + readonly interfaces: readonly TsInterface[]; + readonly typeAliases: readonly TsTypeAlias[]; + readonly enums?: readonly TsEnum[]; +} + +/** + * Represents a TypeScript enum definition. + */ +export interface TsEnum { + readonly name: string; + readonly members: readonly TsEnumMember[]; + readonly description?: string; + readonly tags: readonly JsDocTag[]; +} + +/** + * Represents an enum member. + */ +export interface TsEnumMember { + readonly name: string; + readonly value: string | number; +} + +// ============================================================================ +// Domain Classification Types +// ============================================================================ + +/** + * MCP Protocol domains. + */ +export type McpDomain = 'Server' | 'Client' | 'Common'; + +/** + * MCP Protocol subdomains organized by domain. + */ +export type McpSubdomain = + // Server subdomains + | 'Tools' + | 'Resources' + | 'Prompts' + | 'Logging' + | 'Lifecycle' + | 'Core' + // Client subdomains + | 'Sampling' + | 'Elicitation' + | 'Roots' + | 'Tasks' + // Common subdomains + | 'Content' + | 'Protocol' + | 'JsonRpc'; + +/** + * Domain classification result from @category tag or classifier. + */ +export interface DomainClassification { + readonly domain: McpDomain; + readonly subdomain: McpSubdomain; +} + +/** + * Maps @category tag values to domain/subdomain. + */ +export type CategoryMapping = Record; + +// ============================================================================ +// PHP Generation Types +// ============================================================================ + +/** + * PHP type representation. + */ +export interface PhpType { + readonly type: string; + readonly nullable: boolean; + readonly isArray: boolean; + readonly arrayItemType?: string; + readonly phpDocType?: string; + /** + * When true, omit PHP type hint but keep PHPDoc annotation. + * Used for PHP 7.4 compatibility (e.g., `mixed` is PHP 8.0+). + */ + readonly isUntyped?: boolean; +} + +/** + * PHP property for DTO generation. + */ +export interface PhpProperty { + readonly name: string; + readonly type: PhpType; + readonly description?: string; + readonly isRequired: boolean; + readonly defaultValue?: string; + readonly constValue?: string; +} + +/** + * PHP class metadata for generation. + */ +export interface PhpClassMeta { + readonly className: string; + readonly namespace: string; + readonly domain: McpDomain; + readonly subdomain: McpSubdomain; + readonly description?: string; + readonly properties: readonly PhpProperty[]; + readonly extends?: string; + readonly implements?: readonly string[]; + readonly traits?: readonly string[]; + readonly constants?: readonly PhpConstant[]; + readonly isAbstract?: boolean; + readonly isFinal?: boolean; +} + +/** + * PHP class constant. + */ +export interface PhpConstant { + readonly name: string; + readonly value: string; + readonly visibility?: 'public' | 'protected' | 'private'; +} + +/** + * PHP method parameter. + */ +export interface PhpParameter { + readonly name: string; + readonly type: PhpType; + readonly defaultValue?: string; + readonly isVariadic?: boolean; +} + +/** + * PHP method metadata. + */ +export interface PhpMethod { + readonly name: string; + readonly visibility: 'public' | 'protected' | 'private'; + readonly returnType: PhpType; + readonly parameters: readonly PhpParameter[]; + readonly isStatic?: boolean; + readonly isAbstract?: boolean; + readonly isFinal?: boolean; + readonly body?: string; + readonly description?: string; +} + +// ============================================================================ +// Generator Output Types +// ============================================================================ + +/** + * Represents a generated PHP file. + */ +export interface GeneratedFile { + readonly path: string; + readonly content: string; + readonly type: 'dto' | 'enum' | 'union' | 'factory' | 'builder' | 'interface'; +} + +/** + * Generation result containing all generated files. + */ +export interface GenerationResult { + readonly files: readonly GeneratedFile[]; + readonly stats: GenerationStats; + readonly errors: readonly GenerationError[]; +} + +/** + * Statistics about the generation process. + */ +export interface GenerationStats { + readonly totalTypes: number; + readonly dtos: number; + readonly enums: number; + readonly unions: number; + readonly factories: number; + readonly builders: number; + readonly interfaces: number; + readonly duration: number; +} + +/** + * Error encountered during generation. + */ +export interface GenerationError { + readonly type: string; + readonly message: string; + readonly source?: string; +} + +// ============================================================================ +// Union Membership Types +// ============================================================================ + +/** + * Information about a union interface that a DTO implements. + */ +export interface UnionMembershipInfo { + /** The union interface name (without 'Interface' suffix) */ + readonly unionName: string; + /** The full PHP namespace for the union interface */ + readonly namespace: string; + /** The discriminator field name (e.g., 'type', 'method') */ + readonly discriminatorField?: string; + /** The discriminator value for this specific member (e.g., 'text', 'tools/call') */ + readonly discriminatorValue?: string; +} + +/** + * Map from DTO name to the union interfaces it should implement. + */ +export type UnionMembershipMap = Map; + +// ============================================================================ +// Configuration Types +// ============================================================================ + +/** + * Schema source configuration. + */ +export interface SchemaSource { + readonly type: 'github' | 'local'; + readonly repository?: string; + readonly branch?: string; + readonly path?: string; + readonly version: string; +} + +/** + * PHP output configuration. + */ +export interface PhpOutputConfig { + readonly outputDir: string; + readonly namespace: string; + readonly phpVersion: '7.4' | '8.0' | '8.1' | '8.2' | '8.3'; + readonly indentation: 'spaces' | 'tabs'; + readonly indentSize: number; + readonly generateBuilders: boolean; + readonly generateFactories: boolean; +} + +/** + * Main generator configuration. + */ +export interface GeneratorConfig { + readonly schema: SchemaSource; + readonly output: PhpOutputConfig; + readonly verbose: boolean; + readonly dryRun: boolean; +} + +// ============================================================================ +// Version Tracking Types +// ============================================================================ + +/** + * Version information for a definition (interface/type). + */ +export interface VersionInfo { + /** Version when this definition was first introduced (e.g., "2024-11-05") */ + readonly introducedIn: string; + /** Version when this definition was last modified (only if different from introducedIn) */ + readonly lastModified?: string; + /** Human-readable summary of changes in the last modification */ + readonly changeSummary?: string; +} + +/** + * Version information for a property within a definition. + */ +export interface PropertyVersionInfo { + /** Version when this property was first introduced */ + readonly introducedIn: string; +} + +/** + * Version tracker providing version information for definitions and properties. + */ +export interface VersionTracker { + /** Get version info for a definition */ + getDefinitionVersion(name: string): VersionInfo | undefined; + /** Get version info for a property within a definition */ + getPropertyVersion(definitionName: string, propertyName: string): PropertyVersionInfo | undefined; + /** Get all property versions for a definition */ + getPropertyVersions(definitionName: string): ReadonlyMap; + /** Check if a definition was modified after introduction */ + wasModified(definitionName: string): boolean; +} diff --git a/generator/src/version-tracker/index.ts b/generator/src/version-tracker/index.ts new file mode 100644 index 0000000..de02628 --- /dev/null +++ b/generator/src/version-tracker/index.ts @@ -0,0 +1,598 @@ +/** + * MCP PHP Schema Generator - Version Tracker + * + * Tracks when definitions and properties were introduced and last modified + * by comparing multiple MCP schema versions from GitHub. + */ + +import { readFileSync, existsSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Version information for a definition (interface/type). + */ +export interface VersionInfo { + /** Version when this definition was first introduced (e.g., "2024-11-05") */ + readonly introducedIn: string; + /** Version when this definition was last modified (only if different from introducedIn) */ + readonly lastModified?: string; + /** Human-readable summary of changes in the last modification */ + readonly changeSummary?: string; +} + +/** + * Version information for a property within a definition. + */ +export interface PropertyVersionInfo { + /** Version when this property was first introduced */ + readonly introducedIn: string; +} + +/** + * Change information detected between two schema versions. + */ +export interface ChangeInfo { + /** Properties added in this version */ + readonly addedProperties: readonly string[]; + /** Properties removed in this version */ + readonly removedProperties: readonly string[]; + /** Properties with modified types */ + readonly modifiedProperties: readonly string[]; + /** Union members added */ + readonly addedUnionMembers: readonly string[]; + /** Union members removed */ + readonly removedUnionMembers: readonly string[]; + /** Whether the description changed */ + readonly descriptionChanged: boolean; +} + +/** + * Raw JSON Schema definition structure. + */ +interface JsonSchemaDefinition { + readonly description?: string; + readonly type?: string; + readonly properties?: Record; + readonly required?: readonly string[]; + readonly anyOf?: readonly JsonSchemaRef[]; + readonly oneOf?: readonly JsonSchemaRef[]; + readonly allOf?: readonly JsonSchemaRef[]; + readonly $ref?: string; +} + +interface JsonSchemaProperty { + readonly type?: string; + readonly $ref?: string; + readonly description?: string; + readonly items?: JsonSchemaProperty; + readonly anyOf?: readonly JsonSchemaRef[]; + readonly oneOf?: readonly JsonSchemaRef[]; + readonly const?: string | number | boolean; +} + +interface JsonSchemaRef { + readonly $ref?: string; + readonly type?: string; + readonly items?: JsonSchemaProperty; +} + +/** + * Raw JSON Schema structure. + */ +interface JsonSchema { + readonly $defs?: Record; + readonly definitions?: Record; +} + +/** + * Version tracker providing version information for definitions and properties. + */ +export interface VersionTracker { + /** Get version info for a definition */ + getDefinitionVersion(name: string): VersionInfo | undefined; + /** Get version info for a property within a definition */ + getPropertyVersion(definitionName: string, propertyName: string): PropertyVersionInfo | undefined; + /** Get all property versions for a definition */ + getPropertyVersions(definitionName: string): ReadonlyMap; + /** Check if a definition was modified after introduction */ + wasModified(definitionName: string): boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * GitHub raw content URL for MCP specification schemas. + */ +const GITHUB_RAW_URL = 'https://raw.githubusercontent.com/modelcontextprotocol/specification/main/schema'; + +// ============================================================================ +// Version Configuration +// ============================================================================ + +/** + * Interface for the versions config file structure. + */ +interface VersionsConfig { + readonly versions: readonly string[]; +} + +/** + * Gets the path to the versions config file. + */ +function getVersionsConfigPath(): string { + // In compiled code, __dirname is dist/version-tracker, so go up two levels to get to generator/ + return resolve(__dirname, '../../config/versions.json'); +} + +/** + * Loads available schema versions from the config file. + * @returns Array of version strings in chronological order + * @throws Error if config file doesn't exist or is invalid + */ +export function loadSchemaVersions(): readonly string[] { + const configPath = getVersionsConfigPath(); + + if (!existsSync(configPath)) { + throw new Error(`Versions config file not found: ${configPath}`); + } + + try { + const content = readFileSync(configPath, 'utf-8'); + const config = JSON.parse(content) as VersionsConfig; + + if (!Array.isArray(config.versions) || config.versions.length === 0) { + throw new Error('Versions config must contain a non-empty "versions" array'); + } + + return config.versions; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON in versions config: ${configPath}`); + } + throw error; + } +} + +/** + * Filters versions to only include those up to and including the target version. + * @param allVersions - All available versions in chronological order + * @param targetVersion - The version being generated + * @returns Filtered versions array + */ +export function getVersionsUpTo(allVersions: readonly string[], targetVersion: string): readonly string[] { + const targetIndex = allVersions.indexOf(targetVersion); + + if (targetIndex === -1) { + // Target version not found - return all versions (graceful fallback) + return allVersions; + } + + return allVersions.slice(0, targetIndex + 1); +} + +/** + * JSON-RPC protocol properties to exclude from change tracking. + * These are inherited from base types and don't represent MCP-specific evolution. + */ +const JSONRPC_PROTOCOL_PROPERTIES = ['id', 'jsonrpc', 'method']; + +// ============================================================================ +// Schema Fetching +// ============================================================================ + +/** + * Fetches a specific schema version from GitHub. + */ +async function fetchSchemaVersion(version: string): Promise { + const url = `${GITHUB_RAW_URL}/${version}/schema.json`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch schema version ${version}: ${response.status} ${response.statusText}`); + } + + return response.json() as Promise; +} + +/** + * Fetches specified schema versions from GitHub in parallel. + * @param versions - Array of version strings to fetch + */ +async function fetchSchemaVersions(versions: readonly string[]): Promise> { + const results = await Promise.all( + versions.map(async (version) => { + const schema = await fetchSchemaVersion(version); + return [version, schema] as const; + }) + ); + + return new Map(results); +} + +// ============================================================================ +// Schema Comparison +// ============================================================================ + +/** + * Gets definitions from a schema (handles both $defs and definitions). + */ +function getDefinitions(schema: JsonSchema): Record { + return schema.$defs ?? schema.definitions ?? {}; +} + +/** + * Gets property names from a definition. + */ +function getPropertyNames(definition: JsonSchemaDefinition): string[] { + return Object.keys(definition.properties ?? {}); +} + +/** + * Extracts union member names from anyOf/oneOf. + */ +function extractUnionMembers(schema: JsonSchemaDefinition | JsonSchemaProperty): string[] { + const members: string[] = []; + const union = schema.anyOf ?? schema.oneOf ?? []; + + for (const member of union) { + if (member.$ref) { + // Extract type name from $ref like "#/$defs/TextContent" + const parts = member.$ref.split('/'); + const name = parts[parts.length - 1]; + if (name) { + members.push(name); + } + } else if (member.type) { + if (member.type === 'array' && member.items?.$ref) { + const parts = member.items.$ref.split('/'); + const name = parts[parts.length - 1]; + if (name) { + members.push(`array<${name}>`); + } + } else { + members.push(member.type); + } + } + } + + return members.sort(); +} + +/** + * Normalizes items definition for comparison. + */ +function normalizeItems(items: JsonSchemaProperty | undefined): string { + if (!items) { + return ''; + } + // Sort keys for consistent comparison + const sorted = Object.keys(items).sort().reduce((acc, key) => { + acc[key] = items[key as keyof JsonSchemaProperty]; + return acc; + }, {} as Record); + return JSON.stringify(sorted); +} + +/** + * Checks if a property definition has changed significantly. + */ +function hasPropertyChanged( + oldProp: JsonSchemaProperty | undefined, + newProp: JsonSchemaProperty | undefined +): boolean { + if (!oldProp || !newProp) { + return oldProp !== newProp; + } + + // Check type change + if (oldProp.type !== newProp.type) { + return true; + } + + // Check $ref change + if (oldProp.$ref !== newProp.$ref) { + return true; + } + + // Check anyOf/oneOf changes + const oldUnion = extractUnionMembers(oldProp); + const newUnion = extractUnionMembers(newProp); + if (JSON.stringify(oldUnion) !== JSON.stringify(newUnion)) { + return true; + } + + // Check items change (for arrays) + return normalizeItems(oldProp.items) !== normalizeItems(newProp.items); + + +} + +/** + * Compares two schema definitions and returns detected changes. + */ +function compareDefinitions( + oldDef: JsonSchemaDefinition | undefined, + newDef: JsonSchemaDefinition | undefined +): ChangeInfo { + // New definition - no changes to report + if (!oldDef) { + return { + addedProperties: [], + removedProperties: [], + modifiedProperties: [], + addedUnionMembers: [], + removedUnionMembers: [], + descriptionChanged: false, + }; + } + + // Removed definition - shouldn't happen in practice + if (!newDef) { + return { + addedProperties: [], + removedProperties: [], + modifiedProperties: [], + addedUnionMembers: [], + removedUnionMembers: [], + descriptionChanged: false, + }; + } + + const oldProps = oldDef.properties ?? {}; + const newProps = newDef.properties ?? {}; + + const oldPropNames = Object.keys(oldProps); + const newPropNames = Object.keys(newProps); + + // Filter out JSON-RPC protocol properties + const filterProtocolProps = (names: string[]): string[] => + names.filter((n) => !JSONRPC_PROTOCOL_PROPERTIES.includes(n)); + + const addedProperties = filterProtocolProps( + newPropNames.filter((n) => !oldPropNames.includes(n)) + ); + + const removedProperties = filterProtocolProps( + oldPropNames.filter((n) => !newPropNames.includes(n)) + ); + + // Check for modified properties (type changes) + const commonProps = oldPropNames.filter( + (n) => newPropNames.includes(n) && !JSONRPC_PROTOCOL_PROPERTIES.includes(n) + ); + + const modifiedProperties = commonProps.filter((propName) => + hasPropertyChanged(oldProps[propName], newProps[propName]) + ); + + // Compare union members + const oldUnionMembers = extractUnionMembers(oldDef); + const newUnionMembers = extractUnionMembers(newDef); + + const addedUnionMembers = newUnionMembers.filter((m) => !oldUnionMembers.includes(m)); + const removedUnionMembers = oldUnionMembers.filter((m) => !newUnionMembers.includes(m)); + + // Check description change + const normalizeDesc = (s: string | undefined): string => + (s ?? '').trim().replace(/\s+/g, ' '); + const descriptionChanged = normalizeDesc(oldDef.description) !== normalizeDesc(newDef.description); + + return { + addedProperties, + removedProperties, + modifiedProperties, + addedUnionMembers, + removedUnionMembers, + descriptionChanged, + }; +} + +/** + * Checks if a ChangeInfo indicates any changes. + */ +function hasChanges(info: ChangeInfo): boolean { + return ( + info.addedProperties.length > 0 || + info.removedProperties.length > 0 || + info.modifiedProperties.length > 0 || + info.addedUnionMembers.length > 0 || + info.removedUnionMembers.length > 0 + // Note: description changes alone don't count as structural changes + ); +} + +/** + * Generates a human-readable change summary. + */ +function generateChangeSummary(info: ChangeInfo): string { + const parts: string[] = []; + + if (info.addedProperties.length > 0) { + parts.push(`added properties: ${info.addedProperties.join(', ')}`); + } + + if (info.removedProperties.length > 0) { + parts.push(`removed properties: ${info.removedProperties.join(', ')}`); + } + + if (info.modifiedProperties.length > 0) { + const propWord = info.modifiedProperties.length === 1 ? 'property' : 'properties'; + parts.push(`modified ${propWord}: ${info.modifiedProperties.join(', ')}`); + } + + if (info.addedUnionMembers.length > 0) { + parts.push(`added union members: ${info.addedUnionMembers.join(', ')}`); + } + + if (info.removedUnionMembers.length > 0) { + parts.push(`removed union members: ${info.removedUnionMembers.join(', ')}`); + } + + return parts.join('; '); +} + +// ============================================================================ +// Version Tracker Builder +// ============================================================================ + +/** + * Options for building a version tracker. + */ +export interface BuildVersionTrackerOptions { + /** The target version being generated - only versions up to this will be compared */ + readonly targetVersion: string; + /** Optional callback for progress updates */ + readonly onProgress?: (message: string) => void; +} + +/** + * Builds a version tracker by comparing schema versions up to and including the target version. + * @param options - Options including the target version and optional progress callback + */ +export async function buildVersionTracker( + options: BuildVersionTrackerOptions +): Promise { + const { targetVersion, onProgress } = options; + const progress = onProgress ?? ((): void => {}); + + // Load all available versions from config + progress('Loading schema versions from config...'); + const allVersions = loadSchemaVersions(); + + // Filter to only include versions up to and including the target + const versionsToCompare = getVersionsUpTo(allVersions, targetVersion); + progress(`Comparing ${versionsToCompare.length} version(s) up to ${targetVersion}...`); + + progress('Fetching schema versions from GitHub...'); + const schemas = await fetchSchemaVersions(versionsToCompare); + + progress('Comparing schema versions...'); + + // Maps to store version information + const definitionIntroducedIn = new Map(); + const definitionLastModified = new Map(); + const definitionChangeSummary = new Map(); + const propertyIntroducedIn = new Map>(); + + let previousSchema: JsonSchema | undefined; + + for (const version of versionsToCompare) { + const currentSchema = schemas.get(version); + if (!currentSchema) { + continue; + } + + const currentDefs = getDefinitions(currentSchema); + const previousDefs = previousSchema ? getDefinitions(previousSchema) : {}; + + for (const defName of Object.keys(currentDefs)) { + const currentDef = currentDefs[defName]; + const previousDef = previousDefs[defName]; + + // Track definition introduction + if (!definitionIntroducedIn.has(defName)) { + definitionIntroducedIn.set(defName, version); + definitionLastModified.set(defName, version); + } else if (previousDef) { + // Definition exists - check for changes + const changeInfo = compareDefinitions(previousDef, currentDef); + + if (hasChanges(changeInfo)) { + definitionLastModified.set(defName, version); + definitionChangeSummary.set(defName, generateChangeSummary(changeInfo)); + } + } + + // Track property introductions + if (!propertyIntroducedIn.has(defName)) { + propertyIntroducedIn.set(defName, new Map()); + } + const propVersions = propertyIntroducedIn.get(defName)!; + + const propNames = getPropertyNames(currentDef!); + for (const propName of propNames) { + if (!propVersions.has(propName)) { + propVersions.set(propName, version); + } + } + } + + previousSchema = currentSchema; + } + + progress(`Version tracking complete: ${definitionIntroducedIn.size} definitions tracked`); + + // Build the tracker object + return { + getDefinitionVersion(name: string): VersionInfo | undefined { + const introducedIn = definitionIntroducedIn.get(name); + if (!introducedIn) { + return undefined; + } + + const lastModified = definitionLastModified.get(name); + const changeSummary = definitionChangeSummary.get(name); + + return { + introducedIn, + lastModified: lastModified !== introducedIn ? lastModified : undefined, + changeSummary, + }; + }, + + getPropertyVersion(definitionName: string, propertyName: string): PropertyVersionInfo | undefined { + const propVersions = propertyIntroducedIn.get(definitionName); + if (!propVersions) { + return undefined; + } + + const introducedIn = propVersions.get(propertyName); + if (!introducedIn) { + return undefined; + } + + return { introducedIn }; + }, + + getPropertyVersions(definitionName: string): ReadonlyMap { + const propVersions = propertyIntroducedIn.get(definitionName); + if (!propVersions) { + return new Map(); + } + + const result = new Map(); + for (const [propName, introducedIn] of propVersions) { + result.set(propName, { introducedIn }); + } + return result; + }, + + wasModified(definitionName: string): boolean { + const introducedIn = definitionIntroducedIn.get(definitionName); + const lastModified = definitionLastModified.get(definitionName); + return introducedIn !== undefined && lastModified !== undefined && introducedIn !== lastModified; + }, + }; +} + +/** + * Creates a no-op version tracker for when version tracking is disabled. + */ +export function createEmptyVersionTracker(): VersionTracker { + return { + getDefinitionVersion: () => undefined, + getPropertyVersion: () => undefined, + getPropertyVersions: () => new Map(), + wasModified: () => false, + }; +} diff --git a/generator/src/writers/index.ts b/generator/src/writers/index.ts new file mode 100644 index 0000000..d3f0fdb --- /dev/null +++ b/generator/src/writers/index.ts @@ -0,0 +1,382 @@ +/** + * MCP PHP Schema Generator - File Writers + * + * Handles writing generated PHP files to disk with proper directory structure. + */ + +import { mkdir, writeFile, rm, access, constants } from 'fs/promises'; +import { dirname, join } from 'path'; +import type { GeneratedFile, GeneratorConfig, DomainClassification } from '../types/index.js'; + +/** + * Write result for a single file. + */ +export interface WriteResult { + readonly path: string; + readonly success: boolean; + readonly error?: string; +} + +/** + * Batch write result. + */ +export interface BatchWriteResult { + readonly total: number; + readonly successful: number; + readonly failed: number; + readonly results: readonly WriteResult[]; +} + +/** + * Writes generated PHP files to disk. + */ +export class FileWriter { + private readonly config: GeneratorConfig; + + constructor(config: GeneratorConfig) { + this.config = config; + } + + /** + * Writes a single generated file. + */ + async writeFile(file: GeneratedFile): Promise { + if (this.config.dryRun) { + return { path: file.path, success: true }; + } + + try { + const fullPath = this.getFullPath(file.path); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, file.content, 'utf-8'); + + return { path: file.path, success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { path: file.path, success: false, error: message }; + } + } + + /** + * Writes multiple generated files. + */ + async writeFiles(files: readonly GeneratedFile[]): Promise { + const results: WriteResult[] = []; + + for (const file of files) { + const result = await this.writeFile(file); + results.push(result); + } + + const successful = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + return { + total: files.length, + successful, + failed, + results, + }; + } + + /** + * Gets the full file path for a generated file. + */ + private getFullPath(relativePath: string): string { + return join(this.config.output.outputDir, relativePath); + } + + /** + * Gets the output path for a type. + * DTOs are placed directly in the subdomain folder, other types get their own subfolder. + */ + getOutputPath( + classification: DomainClassification, + typeCategory: 'Dto' | 'Enum' | 'Union' | 'Factory' | 'Builder', + className: string + ): string { + // DTOs go directly in subdomain folder, other types get their own subfolder + if (typeCategory === 'Dto') { + return `${classification.domain}/${classification.subdomain}/${className}.php`; + } + return `${classification.domain}/${classification.subdomain}/${typeCategory}/${className}.php`; + } + + /** + * Clears the output directory. + */ + async clearOutput(): Promise { + if (this.config.dryRun) { + return; + } + + const outputDir = this.config.output.outputDir; + + try { + // Check if directory exists before removing + await access(outputDir, constants.F_OK); + await rm(outputDir, { recursive: true, force: true }); + } catch { + // Directory doesn't exist, nothing to clear + } + } + + /** + * Creates the base directory structure. + * + * Note: Most directories are created on-demand when files are written. + * This method only creates directories that are needed before file writes. + */ + async createDirectoryStructure(): Promise { + if (this.config.dryRun) { + return; + } + + // Directories are created on-demand by writeFile() using mkdir({ recursive: true }) + // No pre-emptive directory creation needed - this prevents empty directories + } +} + +/** + * Generates the AbstractDataTransferObject base class. + */ +export function generateAbstractDto(config: GeneratorConfig): string { + // Version is used in directory structure but NOT in namespace (PHP namespaces can't start with digits) + const namespace = `${config.output.namespace}\\Common`; + const indent = config.output.indentation === 'tabs' ? '\t' : ' '.repeat(config.output.indentSize); + + return ` $data +${indent} * @return static +${indent} */ +${indent}abstract public static function fromArray(array $data): self; + +${indent}/** +${indent} * Converts the instance to an array. +${indent} * +${indent} * @return array +${indent} */ +${indent}abstract public function toArray(): array; + +${indent}/** +${indent} * Converts the instance to JSON. +${indent} * +${indent} * @return string +${indent} */ +${indent}public function toJson(): string +${indent}{ +${indent}${indent}return json_encode($this->toArray(), JSON_THROW_ON_ERROR); +${indent}} + +${indent}/** +${indent} * Creates an instance from JSON. +${indent} * +${indent} * @param string $json +${indent} * @return static +${indent} */ +${indent}public static function fromJson(string $json): self +${indent}{ +${indent}${indent}/** @var array $data */ +${indent}${indent}$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); +${indent}${indent}return static::fromArray($data); +${indent}} +} +`; +} + +/** + * Generates the AbstractEnum base class for PHP 7.4. + */ +export function generateAbstractEnum(config: GeneratorConfig): string { + // Version is used in directory structure but NOT in namespace (PHP namespaces can't start with digits) + const namespace = `${config.output.namespace}\\Common`; + const indent = config.output.indentation === 'tabs' ? '\t' : ' '.repeat(config.output.indentSize); + + return ` */ +${indent}private static array $instances = []; + +${indent}/** +${indent} * @param string $value +${indent} */ +${indent}private function __construct(string $value) +${indent}{ +${indent}${indent}$this->value = $value; +${indent}} + +${indent}/** +${indent} * Creates an instance from a value. +${indent} * +${indent} * @param string $value +${indent} * @return static +${indent} * @throws \\InvalidArgumentException +${indent} */ +${indent}public static function from(string $value): self +${indent}{ +${indent}${indent}$values = static::values(); +${indent}${indent}if (!in_array($value, $values, true)) { +${indent}${indent}${indent}throw new \\InvalidArgumentException( +${indent}${indent}${indent}${indent}sprintf('Invalid enum value: %s. Valid values: %s', $value, implode(', ', $values)) +${indent}${indent}${indent}); +${indent}${indent}} + +${indent}${indent}$key = static::class . '::' . $value; +${indent}${indent}if (!isset(self::$instances[$key])) { +${indent}${indent}${indent}/** @phpstan-ignore new.static (Intentional: private constructor prevents subclass override) */ +${indent}${indent}${indent}self::$instances[$key] = new static($value); +${indent}${indent}} + +${indent}${indent}return self::$instances[$key]; +${indent}} + +${indent}/** +${indent} * Creates an instance from a value, or null if invalid. +${indent} * +${indent} * @param string $value +${indent} * @return static|null +${indent} */ +${indent}public static function tryFrom(string $value): ?self +${indent}{ +${indent}${indent}try { +${indent}${indent}${indent}return static::from($value); +${indent}${indent}} catch (\\InvalidArgumentException $e) { +${indent}${indent}${indent}return null; +${indent}${indent}} +${indent}} + +${indent}/** +${indent} * Returns all valid values for this enum. +${indent} * +${indent} * @return string[] +${indent} */ +${indent}abstract public static function values(): array; + +${indent}/** +${indent} * Returns all cases as instances. +${indent} * +${indent} * @return static[] +${indent} */ +${indent}public static function cases(): array +${indent}{ +${indent}${indent}return array_map(fn(string $value) => static::from($value), static::values()); +${indent}} + +${indent}/** +${indent} * Gets the enum value. +${indent} * +${indent} * @return string +${indent} */ +${indent}public function getValue(): string +${indent}{ +${indent}${indent}return $this->value; +${indent}} + +${indent}/** +${indent} * Converts to string. +${indent} * +${indent} * @return string +${indent} */ +${indent}public function __toString(): string +${indent}{ +${indent}${indent}return $this->value; +${indent}} + +${indent}/** +${indent} * Compares with another instance. +${indent} * +${indent} * @param self $other +${indent} * @return bool +${indent} */ +${indent}public function equals(self $other): bool +${indent}{ +${indent}${indent}return $this->value === $other->value && static::class === get_class($other); +${indent}} +} +`; +} + +/** + * Generates the ValidatesRequiredFields trait for PHP 7.4. + * + * This trait provides a reusable method for validating required fields in fromArray(). + * Benefits: + * - DRY: Removes ~1800 lines of repeated validation code + * - Better errors: Reports class name and ALL missing fields at once + * - Consistent: Same validation pattern across all DTOs + */ +export function generateValidatesRequiredFieldsTrait(config: GeneratorConfig): string { + const namespace = `${config.output.namespace}\\Common\\Traits`; + const indent = config.output.indentation === 'tabs' ? '\t' : ' '.repeat(config.output.indentSize); + + return ` $data The input data array +${indent} * @param string[] $requiredFields List of required field names +${indent} * @return void +${indent} * @throws \\InvalidArgumentException If any required fields are missing +${indent} */ +${indent}protected static function assertRequired(array $data, array $requiredFields): void +${indent}{ +${indent}${indent}$missing = array_filter( +${indent}${indent}${indent}$requiredFields, +${indent}${indent}${indent}static fn(string $field): bool => !array_key_exists($field, $data) +${indent}${indent}); +${indent}${indent} +${indent}${indent}if (count($missing) > 0) { +${indent}${indent}${indent}throw new \\InvalidArgumentException(sprintf( +${indent}${indent}${indent}${indent}'%s: missing required field(s): %s', +${indent}${indent}${indent}${indent}static::class, +${indent}${indent}${indent}${indent}implode(', ', $missing) +${indent}${indent}${indent})); +${indent}${indent}} +${indent}} +} +`; +} diff --git a/generator/tsconfig.json b/generator/tsconfig.json new file mode 100644 index 0000000..4f88c71 --- /dev/null +++ b/generator/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..6f25862 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + phpVersion: 70400 + level: 8 + paths: + - src \ No newline at end of file