From 0dbd2758f4326dd94fa52877aba6977f31a5a06e Mon Sep 17 00:00:00 2001 From: Safwan Parkar Date: Fri, 13 Mar 2026 00:07:03 +0100 Subject: [PATCH 1/6] test(massimo-cli): add fixtures for testing Signed-off-by: Safwan Parkar --- .../json-schema/deep-nesting/schema.json | 580 ++++++++++++++++++ .../json-schema/deep-nesting/types.d.ts | 280 +++++++++ .../fixtures/json-schema/flat/schema.json | 94 +++ .../test/fixtures/json-schema/flat/types.d.ts | 70 +++ .../json-schema/some-nesting/schema.json | 119 ++++ .../json-schema/some-nesting/types.d.ts | 91 +++ 6 files changed, 1234 insertions(+) create mode 100644 packages/massimo-cli/test/fixtures/json-schema/deep-nesting/schema.json create mode 100644 packages/massimo-cli/test/fixtures/json-schema/deep-nesting/types.d.ts create mode 100644 packages/massimo-cli/test/fixtures/json-schema/flat/schema.json create mode 100644 packages/massimo-cli/test/fixtures/json-schema/flat/types.d.ts create mode 100644 packages/massimo-cli/test/fixtures/json-schema/some-nesting/schema.json create mode 100644 packages/massimo-cli/test/fixtures/json-schema/some-nesting/types.d.ts diff --git a/packages/massimo-cli/test/fixtures/json-schema/deep-nesting/schema.json b/packages/massimo-cli/test/fixtures/json-schema/deep-nesting/schema.json new file mode 100644 index 0000000..d27014e --- /dev/null +++ b/packages/massimo-cli/test/fixtures/json-schema/deep-nesting/schema.json @@ -0,0 +1,580 @@ +{ + "$id": "https://example.com/schemas/catalog/v1/catalog.schema.json", + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Catalog", + "type": "object", + "required": [ + "id", + "code", + "version", + "regionCode", + "type", + "owner", + "name", + "summary", + "status", + "schedule", + "locales", + "entries", + "timezone" + ], + "properties": { + "id": { + "type": "string", + "description": "Identifier of the catalog" + }, + "code": { + "type": "string", + "description": "Human-readable identifier of the catalog" + }, + "version": { + "type": "string", + "description": "Revision identifier of the catalog" + }, + "regionCode": { + "type": "string", + "title": "The region", + "enum": ["NA", "EMEA", "APAC", "LATAM"] + }, + "type": { + "$ref": "#/definitions/CatalogType" + }, + "owner": { + "$ref": "#/definitions/Owner", + "description": "Details of the owner that manages this catalog" + }, + "name": { + "type": "string" + }, + "summary": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z]{2}-[A-Z]{2}$": { + "type": "string", + "minLength": 1 + } + } + }, + "status": { + "$ref": "#/definitions/Status" + }, + "schedule": { + "type": "object", + "additionalProperties": false, + "required": ["startAt", "endAt"], + "properties": { + "startAt": { + "type": "string", + "format": "date-time" + }, + "endAt": { + "type": "string", + "format": "date-time" + } + } + }, + "locales": { + "$ref": "#/definitions/LocaleCollection" + }, + "entries": { + "type": "array", + "items": { + "title": "CatalogEntry", + "type": "object", + "required": [ + "id", + "code", + "version", + "kind", + "name", + "summary", + "schedule", + "status", + "cost", + "contentRef", + "category", + "delivery", + "value", + "notes", + "provider", + "lifecycle" + ], + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/properties/entries/items/definitions/EntryId" + }, + "code": { + "$ref": "#/properties/entries/items/definitions/EntryCode" + }, + "version": { + "$ref": "#/properties/entries/items/definitions/EntryVersion" + }, + "kind": { + "$ref": "#/properties/entries/items/definitions/EntryKind" + }, + "name": { + "$ref": "#/properties/summary" + }, + "summary": { + "$ref": "#/properties/summary" + }, + "schedule": { + "$ref": "#/properties/schedule" + }, + "status": { + "$ref": "#/properties/entries/items/definitions/EntryStatus" + }, + "cost": { + "$ref": "#/properties/entries/items/definitions/Cost" + }, + "contentRef": { + "$ref": "#/properties/entries/items/definitions/EntryContentRef" + }, + "category": { + "$ref": "#/properties/entries/items/definitions/Category" + }, + "delivery": { + "$ref": "#/properties/entries/items/definitions/Delivery" + }, + "value": { + "$ref": "#/properties/entries/items/definitions/Value" + }, + "notes": { + "$ref": "#/properties/entries/items/definitions/Notes" + }, + "provider": { + "$ref": "#/properties/entries/items/definitions/Provider" + }, + "lifecycle": { + "$ref": "#/properties/entries/items/definitions/Lifecycle" + } + }, + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "CREDIT" + }, + "value": { + "$ref": "#/properties/entries/items/definitions/CreditValue" + } + } + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "FIXED_REDUCTION" + }, + "value": { + "$ref": "#/properties/entries/items/definitions/FixedReductionValue" + } + } + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "RATE_REDUCTION" + }, + "value": { + "$ref": "#/properties/entries/items/definitions/RateReductionValue" + } + } + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "FIXED_PRICE" + }, + "value": { + "$ref": "#/properties/entries/items/definitions/FixedPriceValue" + } + } + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "SERVICE" + }, + "value": { + "$ref": "#/properties/entries/items/definitions/ServiceValue" + } + } + } + ], + "definitions": { + "EntryId": { + "type": "string", + "description": "Identifier of the catalog entry" + }, + "EntryCode": { + "type": "string", + "description": "Human-readable identifier of the catalog entry" + }, + "EntryVersion": { + "type": "string", + "description": "Revision identifier of the catalog entry" + }, + "EntryKind": { + "type": "string", + "enum": ["CREDIT", "FIXED_REDUCTION", "RATE_REDUCTION", "FIXED_PRICE", "SERVICE"] + }, + "EntryStatus": { + "type": "string", + "enum": ["DRAFT", "ACTIVE", "ARCHIVED"] + }, + "Cost": { + "type": "object", + "required": ["currencyCode", "amount", "taxAmount"], + "properties": { + "currencyCode": { + "type": "string", + "enum": ["USD", "EUR", "SEK"] + }, + "amount": { + "type": "string", + "description": "String representation of a number with 2 decimal points" + }, + "taxAmount": { + "type": "string", + "description": "String representation of a number with 2 decimal points" + } + } + }, + "EntryContentRef": { + "type": "string", + "description": "Opaque key for associating extended content with this catalog entry" + }, + "Category": { + "type": "string", + "enum": ["DIGITAL", "PHYSICAL", "SERVICE"] + }, + "Delivery": { + "type": "object", + "additionalProperties": true + }, + "Value": { + "type": "object" + }, + "CreditValue": { + "type": "object", + "required": ["amount", "currencyCode"], + "properties": { + "amount": { + "type": "string" + }, + "currencyCode": { + "type": "string", + "enum": ["USD", "EUR", "SEK"] + } + } + }, + "FixedReductionValue": { + "type": "object", + "required": ["amount", "currencyCode"], + "properties": { + "amount": { + "type": "string" + }, + "currencyCode": { + "type": "string", + "enum": ["USD", "EUR", "SEK"] + } + } + }, + "RateReductionValue": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "string" + } + } + }, + "FixedPriceValue": { + "type": "object", + "required": ["amount", "currencyCode"], + "properties": { + "amount": { + "type": "string" + }, + "currencyCode": { + "type": "string", + "enum": ["USD", "EUR", "SEK"] + } + } + }, + "ServiceValue": { + "type": "object", + "additionalProperties": true + }, + "Notes": { + "type": "object", + "additionalProperties": true + }, + "Provider": { + "type": "object", + "required": ["type", "config", "code"], + "properties": { + "type": { + "$ref": "#/properties/entries/items/definitions/ProviderType" + }, + "config": { + "$ref": "#/properties/entries/items/definitions/ProviderConfig" + }, + "code": { + "type": "string" + }, + "pin": { + "type": "string" + } + }, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "CODE" + } + } + }, + { + "type": "object", + "required": ["pin"], + "properties": { + "type": { + "type": "string", + "const": "CODE_WITH_PIN" + } + } + } + ] + }, + "ProviderType": { + "type": "string", + "enum": ["CODE", "CODE_WITH_PIN"] + }, + "ProviderConfig": { + "type": "object", + "required": ["sourceSystem", "campaignId"], + "properties": { + "sourceSystem": { + "type": "string", + "enum": ["INTERNAL", "PARTNER"] + }, + "campaignId": { + "type": "integer" + } + } + }, + "Lifecycle": { + "type": "object", + "required": [ + "redeemContext", + "redeemConstraints", + "expiration", + "internalBufferDays", + "communicationBufferDays" + ], + "properties": { + "redeemContext": { + "type": "string", + "enum": ["CHECKOUT", "PORTAL"] + }, + "redeemConstraints": { + "type": "array", + "items": { + "title": "RedeemConstraint", + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["type", "minimumPurchase"], + "properties": { + "type": { + "type": "string", + "const": "MINIMUM_PURCHASE" + }, + "minimumPurchase": { + "type": "object", + "required": ["currencyCode", "amount"], + "properties": { + "currencyCode": { + "type": "string", + "enum": ["USD", "EUR", "SEK"] + }, + "amount": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "required": ["type", "channels"], + "properties": { + "type": { + "type": "string", + "const": "CHANNELS" + }, + "channels": { + "type": "array", + "items": { + "type": "string", + "enum": ["ONLINE", "STORE"] + } + } + } + }, + { + "type": "object", + "required": ["type", "limit"], + "properties": { + "type": { + "type": "string", + "const": "LIMIT" + }, + "limit": { + "type": "integer" + } + } + } + ] + } + }, + "expiration": { + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["type", "date"], + "properties": { + "type": { + "type": "string", + "const": "DATE" + }, + "date": { + "type": "string", + "format": "date-time" + } + } + }, + { + "type": "object", + "required": ["type", "duration"], + "properties": { + "type": { + "type": "string", + "const": "DURATION" + }, + "duration": { + "type": "object", + "required": ["length", "unit"], + "properties": { + "length": { + "type": "string" + }, + "unit": { + "type": "string", + "enum": ["DAY", "WEEK", "MONTH", "YEAR"] + } + } + } + } + } + ] + }, + "internalBufferDays": { + "type": "integer" + }, + "communicationBufferDays": { + "type": "integer" + } + } + } + } + } + }, + "timezone": { + "type": "string" + } + }, + "definitions": { + "CatalogType": { + "type": "string", + "enum": ["STANDARD", "SEGMENTED"] + }, + "Owner": { + "type": "object", + "required": ["displayName", "legalName", "region"], + "properties": { + "displayName": { + "type": "string" + }, + "legalName": { + "type": "string" + }, + "region": { + "type": "object", + "required": ["code"], + "properties": { + "code": { + "$ref": "#/properties/regionCode" + } + } + } + } + }, + "Status": { + "type": "string", + "enum": ["DRAFT", "ACTIVE", "ARCHIVED"] + }, + "LocaleCollection": { + "type": "array", + "items": { + "title": "Locale", + "type": "object", + "required": ["locale", "language", "primary", "fallbacks", "aliases"], + "properties": { + "locale": { + "type": "string", + "pattern": "^[a-z]{2}-[A-Z]{2}$" + }, + "language": { + "type": "string" + }, + "primary": { + "type": "boolean" + }, + "fallbacks": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z]{2}-[A-Z]{2}$" + } + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z]{2}-[A-Z]{2}$" + } + } + } + } + } + } +} diff --git a/packages/massimo-cli/test/fixtures/json-schema/deep-nesting/types.d.ts b/packages/massimo-cli/test/fixtures/json-schema/deep-nesting/types.d.ts new file mode 100644 index 0000000..aa81281 --- /dev/null +++ b/packages/massimo-cli/test/fixtures/json-schema/deep-nesting/types.d.ts @@ -0,0 +1,280 @@ +interface Catalog { + id: Id; + code: Code; + version: Version; + regionCode: RegionCode; + type: Type; + owner: Owner; + name: Name; + summary: Summary; + status: Status; + schedule: Schedule; + locales: Locales; + entries: Array; + timezone: Timezone; +} +/** + * Identifier of the catalog + */ +type Id = string; +/** + * Human-readable identifier of the catalog + */ +type Code = string; +/** + * Revision identifier of the catalog + */ +type Version = string; +/** + * The region + */ +type RegionCode = 'NA' | 'EMEA' | 'APAC' | 'LATAM'; +type Type = 'STANDARD' | 'SEGMENTED'; +interface Owner { + displayName: OwnerDisplayName; + legalName: OwnerLegalName; + region: OwnerRegion; +} + +type OwnerDisplayName = string; +type OwnerLegalName = string; +interface OwnerRegion { + code: RegionCode; +} + +type Name = string; +type Summary = Record; +/** + * Opaque key for associating extended content with this catalog + */ +type Status = 'DRAFT' | 'ACTIVE' | 'ARCHIVED'; +interface Schedule { + startAt: ScheduleStartAt; + endAt: ScheduleEndAt; +} +/** + * Expected format: JSON Schema date-time + */ +type ScheduleStartAt = string; +/** + * Expected format: JSON Schema date-time + */ +type ScheduleEndAt = string; +type Locales = Array; +interface Locale { + locale: LocaleLocale; + language: LocaleLanguage; + primary: LocalePrimary; + fallbacks: LocaleFallbacks; + aliases: LocaleAliases; +} +/** + * Expected pattern: ^[a-z]{2}-[A-Z]{2}$ + */ +type LocaleLocale = string; +type LocaleLanguage = string; +type LocalePrimary = boolean; +type LocaleFallbacks = Array; +type LocaleAliases = Array; +/** + * CatalogEntry + */ +type Entry = CreditEntry | FixedReductionEntry | RateReductionEntry | FixedPriceEntry | ServiceEntry; +/** + * Identifier of the catalog entry + */ +type EntryId = string; +/** + * Human-readable identifier of the catalog entry + */ +type EntryCode = string; +/** + * Revision identifier of the catalog entry + */ +type EntryVersion = string; +type EntryKind = 'CREDIT' | 'FIXED_REDUCTION' | 'RATE_REDUCTION' | 'FIXED_PRICE' | 'SERVICE'; +type EntryName = Summary; +type EntrySummary = Summary; +type EntrySchedule = Schedule; +type EntryStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED'; +interface EntryCost { + currencyCode: EntryCostCurrencyCode; + amount: EntryCostAmount; + taxAmount: EntryCostTaxAmount; +} + +type EntryCostCurrencyCode = 'USD' | 'EUR' | 'SEK'; +/** + * String representation of a number with 2 decimal points + */ +type EntryCostAmount = string; +/** + * String representation of a number with 2 decimal points + */ +type EntryCostTaxAmount = string; +/** + * Opaque key for associating extended content with this catalog entry + */ +type EntryContentRef = string; +type EntryCategory = 'DIGITAL' | 'PHYSICAL' | 'SERVICE'; +type EntryDelivery = Record; +type EntryValue = Record; +type EntryNotes = Record; +interface EntryProviderConfig { + sourceSystem: EntryProviderConfigSourceSystem; + campaignId: EntryProviderConfigCampaignId; +} + +type EntryProviderConfigSourceSystem = 'INTERNAL' | 'PARTNER'; +type EntryProviderConfigCampaignId = number; +type EntryProviderCode = string; +type EntryProviderPin = string; +interface BaseEntryProvider { + config: EntryProviderConfig; + code: EntryProviderCode; + pin?: EntryProviderPin; +} + +interface EntryProviderCodeEntryProvider extends BaseEntryProvider { + type: 'CODE'; +} + +interface EntryProviderCodeWithPinEntryProvider extends BaseEntryProvider { + type: 'CODE_WITH_PIN'; +} + +type EntryProvider = EntryProviderCodeEntryProvider | EntryProviderCodeWithPinEntryProvider; +interface EntryLifecycle { + redeemContext: EntryLifecycleRedeemContext; + redeemConstraints: EntryLifecycleRedeemConstraints; + expiration: EntryLifecycleExpiration; + internalBufferDays: EntryLifecycleInternalBufferDays; + communicationBufferDays: EntryLifecycleCommunicationBufferDays; +} + +type EntryLifecycleRedeemContext = 'CHECKOUT' | 'PORTAL'; +type EntryLifecycleRedeemConstraints = Array; +/** + * RedeemConstraint + */ +type EntryLifecycleRedeemConstraint = EntryLifecycleRedeemConstraintMinimumPurchaseRedeemConstraint | EntryLifecycleRedeemConstraintChannelsRedeemConstraint | EntryLifecycleRedeemConstraintLimitRedeemConstraint; +interface EntryLifecycleRedeemConstraintMinimumPurchaseRedeemConstraint { + type: 'MINIMUM_PURCHASE'; + minimumPurchase: EntryLifecycleRedeemConstraintMinimumPurchaseRedeemConstraintMinimumPurchase; +} + +interface EntryLifecycleRedeemConstraintMinimumPurchaseRedeemConstraintMinimumPurchase { + currencyCode: EntryLifecycleRedeemConstraintMinimumPurchaseRedeemConstraintMinimumPurchaseCurrencyCode; + amount: EntryLifecycleRedeemConstraintMinimumPurchaseRedeemConstraintMinimumPurchaseAmount; +} + +type EntryLifecycleRedeemConstraintMinimumPurchaseRedeemConstraintMinimumPurchaseCurrencyCode = 'USD' | 'EUR' | 'SEK'; +type EntryLifecycleRedeemConstraintMinimumPurchaseRedeemConstraintMinimumPurchaseAmount = string; +interface EntryLifecycleRedeemConstraintChannelsRedeemConstraint { + type: 'CHANNELS'; + channels: Array; +} + +type EntryLifecycleRedeemConstraintChannelsRedeemConstraintChannel = 'ONLINE' | 'STORE'; +interface EntryLifecycleRedeemConstraintLimitRedeemConstraint { + type: 'LIMIT'; + limit: EntryLifecycleRedeemConstraintLimitRedeemConstraintLimit; +} + +type EntryLifecycleRedeemConstraintLimitRedeemConstraintLimit = number; +type EntryLifecycleExpiration = EntryLifecycleExpirationDateEntryLifecycleExpiration | EntryLifecycleExpirationDurationEntryLifecycleExpiration; +interface EntryLifecycleExpirationDateEntryLifecycleExpiration { + type: 'DATE'; + date: EntryLifecycleExpirationDateEntryLifecycleExpirationDate; +} +/** + * Expected format: JSON Schema date-time + */ +type EntryLifecycleExpirationDateEntryLifecycleExpirationDate = string; +interface EntryLifecycleExpirationDurationEntryLifecycleExpiration { + type: 'DURATION'; + duration: EntryLifecycleExpirationDurationEntryLifecycleExpirationDuration; +} + +interface EntryLifecycleExpirationDurationEntryLifecycleExpirationDuration { + length: EntryLifecycleExpirationDurationEntryLifecycleExpirationDurationLength; + unit: EntryLifecycleExpirationDurationEntryLifecycleExpirationDurationUnit; +} + +type EntryLifecycleExpirationDurationEntryLifecycleExpirationDurationLength = string; +type EntryLifecycleExpirationDurationEntryLifecycleExpirationDurationUnit = 'DAY' | 'WEEK' | 'MONTH' | 'YEAR'; +type EntryLifecycleInternalBufferDays = number; +type EntryLifecycleCommunicationBufferDays = number; +interface CreditEntry extends BaseEntry { + kind: 'CREDIT'; + value: EntryCreditValue; +} + +interface BaseEntry { + id: EntryId; + code: EntryCode; + version: EntryVersion; + name: EntryName; + summary: EntrySummary; + schedule: EntrySchedule; + status: EntryStatus; + cost: EntryCost; + contentRef: EntryContentRef; + category: EntryCategory; + delivery: EntryDelivery; + value: EntryValue; + notes: EntryNotes; + provider: EntryProvider; + lifecycle: EntryLifecycle; +} + +interface EntryCreditValue { + amount: EntryCreditValueAmount; + currencyCode: EntryCreditValueCurrencyCode; +} + +type EntryCreditValueAmount = string; +type EntryCreditValueCurrencyCode = EntryFixedReductionValueCurrencyCode; +interface FixedReductionEntry extends BaseEntry { + kind: 'FIXED_REDUCTION'; + value: EntryFixedReductionValue; +} + +interface EntryFixedReductionValue { + amount: EntryFixedReductionValueAmount; + currencyCode: EntryFixedReductionValueCurrencyCode; +} + +type EntryFixedReductionValueAmount = string; +type EntryFixedReductionValueCurrencyCode = EntryCreditValueCurrencyCode; +interface RateReductionEntry extends BaseEntry { + kind: 'RATE_REDUCTION'; + value: EntryRateReductionValue; +} + +interface EntryRateReductionValue { + value: EntryRateReductionValueValue; +} + +type EntryRateReductionValueValue = string; +interface FixedPriceEntry extends BaseEntry { + kind: 'FIXED_PRICE'; + value: EntryFixedPriceValue; +} + +interface EntryFixedPriceValue { + amount: EntryFixedPriceValueAmount; + currencyCode: EntryFixedPriceValueCurrencyCode; +} + +type EntryFixedPriceValueAmount = string; +type EntryFixedPriceValueCurrencyCode = EntryCreditValueCurrencyCode; +interface ServiceEntry extends BaseEntry { + kind: 'SERVICE'; + value: EntryServiceValue; +} + +type EntryServiceValue = Record; +type Timezone = string; + +export { Catalog }; diff --git a/packages/massimo-cli/test/fixtures/json-schema/flat/schema.json b/packages/massimo-cli/test/fixtures/json-schema/flat/schema.json new file mode 100644 index 0000000..03f3c49 --- /dev/null +++ b/packages/massimo-cli/test/fixtures/json-schema/flat/schema.json @@ -0,0 +1,94 @@ +{ + "$id": "https://example.com/schemas/catalog/commands/activate-catalog.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ActivateCatalog", + "type": "object", + "required": [ + "specversion", + "id", + "idempotencykey", + "source", + "type", + "subject", + "datacontenttype", + "dataschema", + "sequencenumber", + "data" + ], + "properties": { + "specversion": { + "type": "string", + "const": "1.0" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "idempotencykey": { + "type": "string" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "format": "uri-reference" + }, + "type": { + "$ref": "#/definitions/CommandType" + }, + "subject": { + "type": "string", + "pattern": "^.+$" + }, + "datacontenttype": { + "const": "application/json" + }, + "dataschema": { + "type": "string", + "format": "uri" + }, + "sequencenumber": { + "type": "integer", + "minimum": -1 + }, + "correlationid": { + "type": "string", + "format": "uuid" + }, + "data": { + "$ref": "#/definitions/CommandData" + } + }, + "definitions": { + "CommandType": { + "const": "example.catalog.commands.activate-catalog" + }, + "CommandData": { + "type": "object", + "required": ["catalog"], + "properties": { + "catalog": { + "type": "object", + "required": ["regionCode", "slug", "revision"], + "properties": { + "regionCode": { + "type": "string", + "title": "The region", + "enum": ["NA", "EMEA", "APAC", "LATAM"] + }, + "slug": { + "description": "Human-readable identifier for the catalog", + "type": "string" + }, + "revision": { + "description": "Revision identifier for the catalog", + "type": "string" + } + } + } + } + } + } +} diff --git a/packages/massimo-cli/test/fixtures/json-schema/flat/types.d.ts b/packages/massimo-cli/test/fixtures/json-schema/flat/types.d.ts new file mode 100644 index 0000000..7b91c87 --- /dev/null +++ b/packages/massimo-cli/test/fixtures/json-schema/flat/types.d.ts @@ -0,0 +1,70 @@ +interface ActivateCatalog { + specversion: Specversion; + id: Id; + idempotencykey: Idempotencykey; + time?: Time; + source: Source; + type: Type; + subject: Subject; + datacontentyype: Datacontenttype; + dataschema: Dataschema; + sequencenumber: Sequencenumber; + correlationid?: Correlationid; + data: Data; +} + +type Specversion = "1.0"; +/** + * Expected format: JSON Schema uuid + */ +type Id = string; +type Idempotencykey = string; +/** + * Expected format: JSON Schema date-time + */ +type Time = string; +/** + * Expected format: JSON Schema uri-reference + */ +type Source = string; +type Type = "example.catalog.commands.activate-catalog"; +/** + * Expected pattern: ^.+$ + */ +type Subject = string; +type Datacontenttype = "application/json"; +/** + * Expected format: JSON Schema uri + */ +type Dataschema = string; +/** + * Expected minimum: -1 + */ +type Sequencenumber = number; +/** + * Expected format: JSON Schema uuid + */ +type Correlationid = string; +interface Data { + catalog: DataCatalog; +} + +interface DataCatalog { + regionCode: DataCatalogRegionCode; + slug: DataCatalogSlug; + revision: DataCatalogRevision; +} +/** + * The region + */ +type DataCatalogRegionCode = 'NA' | 'EMEA' | 'APAC' | 'LATAM'; +/** + * Human-readable identifier for the catalog + */ +type DataCatalogSlug = string; +/** + * Revision identifier for the catalog + */ +type DataCatalogRevision = string; + +export { ActivateCatalog }; diff --git a/packages/massimo-cli/test/fixtures/json-schema/some-nesting/schema.json b/packages/massimo-cli/test/fixtures/json-schema/some-nesting/schema.json new file mode 100644 index 0000000..f489e73 --- /dev/null +++ b/packages/massimo-cli/test/fixtures/json-schema/some-nesting/schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://example.com/schemas/reservations/commands/create-reservation.schema.json", + "title": "CreateReservation", + "type": "object", + "required": [ + "specversion", + "id", + "source", + "type", + "datacontenttype", + "dataschema", + "data" + ], + "properties": { + "specversion": { + "type": "string", + "const": "1.0" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "source": { + "type": "string", + "format": "uri-reference" + }, + "type": { + "const": "example.reservations.commands.create-reservation" + }, + "datacontenttype": { + "const": "application/json" + }, + "dataschema": { + "type": "string", + "format": "uri" + }, + "subject": { + "type": "string", + "pattern": "^.+$" + }, + "correlationid": { + "type": "string", + "format": "uuid" + }, + "callbackurl": { + "type": "string", + "format": "uri" + }, + "data": { + "type": "object", + "required": [ + "customerId", + "siteCode", + "planId", + "reservationId", + "requestContext" + ], + "properties": { + "customerId": { + "type": "string" + }, + "siteCode": { + "type": "string", + "title": "The site code", + "enum": ["NORTH", "SOUTH", "EAST", "WEST"] + }, + "planId": { + "type": "string" + }, + "reservationId": { + "type": "string" + }, + "requestContext": { + "type": "object", + "required": ["clientId", "actor"], + "properties": { + "sourceSystemId": { + "type": "string", + "title": "The system identifier that initiated the request", + "description": "This is the stable identifier of the system or application that sent the command." + }, + "clientId": { + "type": "string", + "title": "The client identifier used for the request", + "description": "For HTTP requests this is usually derived from the authenticated client or token audience." + }, + "policyId": { + "type": "string", + "title": "The authorization policy that accepted the request", + "description": "Useful for audit logs and troubleshooting access decisions." + }, + "actor": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "description": "Actors can be service processes, team members, or external users.", + "enum": [ + "SYSTEM", + "TEAM_MEMBER", + "INDIVIDUAL", + "BUSINESS" + ] + }, + "id": { + "type": "string", + "title": "The actor identifier", + "description": "Its meaning depends on the actor kind and the upstream identity provider." + } + } + } + } + } + } + } + } +} diff --git a/packages/massimo-cli/test/fixtures/json-schema/some-nesting/types.d.ts b/packages/massimo-cli/test/fixtures/json-schema/some-nesting/types.d.ts new file mode 100644 index 0000000..5deed4e --- /dev/null +++ b/packages/massimo-cli/test/fixtures/json-schema/some-nesting/types.d.ts @@ -0,0 +1,91 @@ +interface CreateReservation { + specversion: Specversion; + id: Id; + source: Source; + type: Type; + datacontenttype: Datacontenttype; + dataschema: Dataschema; + subject?: Subject; + correlationid?: Correlationid; + callbackurl?: Callbackurl; + data: Data; +} + +type Specversion = "1.0"; +/** + * Expected format: JSON Schema uuid + */ +type Id = string; +/** + * Expected format: JSON Schema uri-reference + */ +type Source = string; +type Type = "example.reservations.commands.create-reservation"; +type Datacontenttype = "application/json"; +/** + * Expected format: JSON Schema uri + */ +type Dataschema = string; +/** + * Expected pattern: ^.+$ + */ +type Subject = string; +/** + * Expected format: JSON Schema uuid + */ +type Correlationid = string; +/** + * Expected format: JSON Schema uri + */ +type Callbackurl = string; +interface Data { + customerId: DataCustomerId; + siteCode: DataSiteCode; + planId: DataPlanId; + reservationId: DataReservationId; + requestContext: DataRequestContext; +} + +type DataCustomerId = string; +/** + * The site code + */ +type DataSiteCode = 'NORTH' | 'SOUTH' | 'EAST' | 'WEST'; +type DataPlanId = string; +type DataReservationId = string; +interface DataRequestContext { + sourceSystemId?: DataRequestContextSourceSystemId; + clientId: DataRequestContextClientId; + policyId?: DataRequestContextPolicyId; + actor: DataRequestContextActor; +} +/** + * The system identifier that initiated the request + * This is the stable identifier of the system or application that sent the command. + */ +type DataRequestContextSourceSystemId = string; +/** + * The client identifier used for the request + * For HTTP requests this is usually derived from the authenticated client or token audience. + */ +type DataRequestContextClientId = string; +/** + * The authorization policy that accepted the request + * Useful for audit logs and troubleshooting access decisions. + */ +type DataRequestContextPolicyId = string; +interface DataRequestContextActor { + kind: DataRequestContextActorKind; + id: DataRequestContextActorId; +} +/** + * Actors can be service processes, team members, or external users. + */ +type DataRequestContextActorKind = 'SYSTEM' | 'TEAM_MEMBER' | 'INDIVIDUAL' | 'BUSINESS'; +/** + * The actor identifier + * Its meaning depends on the actor kind and the upstream identity provider. + */ +type DataRequestContextActorId = string; + +export { CreateReservation }; From bbe9a6e713528f15c182c21fd2cde59f5bfaa8c8 Mon Sep 17 00:00:00 2001 From: Safwan Parkar Date: Sat, 7 Mar 2026 19:10:55 +0100 Subject: [PATCH 2/6] feat(massimo-cli): add json schema scanning and type rendering pipeline Signed-off-by: Safwan Parkar --- .../massimo-cli/lib/json-schema-generator.js | 5 + packages/massimo-cli/lib/json-schema/array.js | 39 +++ .../massimo-cli/lib/json-schema/comments.js | 35 +++ .../lib/json-schema/declarations.js | 91 +++++++ .../massimo-cli/lib/json-schema/generator.js | 14 + .../lib/json-schema/name-registry.js | 60 +++++ .../massimo-cli/lib/json-schema/naming.js | 13 + .../massimo-cli/lib/json-schema/object.js | 98 +++++++ .../massimo-cli/lib/json-schema/pointer.js | 13 + .../massimo-cli/lib/json-schema/primitive.js | 51 ++++ .../massimo-cli/lib/json-schema/reference.js | 24 ++ .../lib/json-schema/render-context.js | 21 ++ .../lib/json-schema/render-type.js | 74 ++++++ .../massimo-cli/lib/json-schema/scanner.js | 241 ++++++++++++++++++ packages/massimo-cli/lib/json-schema/union.js | 52 ++++ .../test/json-schema-declarations.test.js | 51 ++++ .../test/json-schema-reference.test.js | 115 +++++++++ .../test/json-schema-render-type.test.js | 207 +++++++++++++++ .../test/json-schema-scanner.test.js | 56 ++++ 19 files changed, 1260 insertions(+) create mode 100644 packages/massimo-cli/lib/json-schema-generator.js create mode 100644 packages/massimo-cli/lib/json-schema/array.js create mode 100644 packages/massimo-cli/lib/json-schema/comments.js create mode 100644 packages/massimo-cli/lib/json-schema/declarations.js create mode 100644 packages/massimo-cli/lib/json-schema/generator.js create mode 100644 packages/massimo-cli/lib/json-schema/name-registry.js create mode 100644 packages/massimo-cli/lib/json-schema/naming.js create mode 100644 packages/massimo-cli/lib/json-schema/object.js create mode 100644 packages/massimo-cli/lib/json-schema/pointer.js create mode 100644 packages/massimo-cli/lib/json-schema/primitive.js create mode 100644 packages/massimo-cli/lib/json-schema/reference.js create mode 100644 packages/massimo-cli/lib/json-schema/render-context.js create mode 100644 packages/massimo-cli/lib/json-schema/render-type.js create mode 100644 packages/massimo-cli/lib/json-schema/scanner.js create mode 100644 packages/massimo-cli/lib/json-schema/union.js create mode 100644 packages/massimo-cli/test/json-schema-declarations.test.js create mode 100644 packages/massimo-cli/test/json-schema-reference.test.js create mode 100644 packages/massimo-cli/test/json-schema-render-type.test.js create mode 100644 packages/massimo-cli/test/json-schema-scanner.test.js diff --git a/packages/massimo-cli/lib/json-schema-generator.js b/packages/massimo-cli/lib/json-schema-generator.js new file mode 100644 index 0000000..9866671 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema-generator.js @@ -0,0 +1,5 @@ +import { generateJSONSchemaTypes } from './json-schema/generator.js' + +export function processJSONSchema ({ schema, rootName }) { + return generateJSONSchemaTypes({ schema, rootName }) +} diff --git a/packages/massimo-cli/lib/json-schema/array.js b/packages/massimo-cli/lib/json-schema/array.js new file mode 100644 index 0000000..28fe7c1 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/array.js @@ -0,0 +1,39 @@ +import { createChildRenderContext } from './render-context.js' + +export function renderArrayType ({ context, renderType }) { + const { schema } = context + + if (!schema.items) { + return 'Array' + } + + if (Array.isArray(schema.items)) { + const itemTypes = schema.items.map((itemSchema, index) => { + return renderType({ + context: createChildRenderContext({ + context, + schema: itemSchema, + pathSuffix: `items/${index}`, + lookupPathName: false + }) + }) + }) + + return `[${itemTypes.join(', ')}]` + } + + const itemType = renderType({ + context: createChildRenderContext({ + context, + schema: schema.items, + pathSuffix: 'items', + lookupPathName: false + }) + }) + + if (itemType.includes(' | ') || itemType.includes(' & ')) { + return `Array<(${itemType})>` + } + + return `Array<${itemType}>` +} diff --git a/packages/massimo-cli/lib/json-schema/comments.js b/packages/massimo-cli/lib/json-schema/comments.js new file mode 100644 index 0000000..14974a2 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/comments.js @@ -0,0 +1,35 @@ +import { normalizeTypeName } from './naming.js' + +export function getCommentLines ({ schema, name }) { + const lines = [] + + if (schema.title && normalizeTypeName(schema.title) !== name) { + lines.push(schema.title) + } + + if (schema.description) { + lines.push(...schema.description.split('\n').filter(Boolean)) + } + + if (schema.format) { + lines.push(`Expected format: JSON Schema ${schema.format}`) + } + + if (schema.pattern) { + lines.push(`Expected pattern: ${schema.pattern}`) + } + + if (schema.minimum !== undefined) { + lines.push(`Expected minimum: ${schema.minimum}`) + } + + return lines.length > 0 ? lines : null +} + +export function renderCommentBlock ({ lines, indent = '' }) { + return [ + `${indent}/**`, + ...lines.map(line => `${indent} * ${line}`), + `${indent} */` + ] +} diff --git a/packages/massimo-cli/lib/json-schema/declarations.js b/packages/massimo-cli/lib/json-schema/declarations.js new file mode 100644 index 0000000..16413cc --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/declarations.js @@ -0,0 +1,91 @@ +import { getCommentLines, renderCommentBlock } from './comments.js' +import { createRenderContext } from './render-context.js' +import { buildObjectTypeLines } from './object.js' +import { getSchemaAtPath } from './pointer.js' +import { renderType } from './render-type.js' + +export function buildDeclarations ({ state }) { + const declarations = [] + + for (const [path, name] of state.nameRegistry.getPathEntries()) { + const schema = getSchemaAtPath({ schema: state.rootSchema, path }) + if (!schema) { + continue + } + + declarations.push(buildDeclaration({ + path, + name, + schema, + state + })) + } + + return declarations +} + +export function renderDeclarations ({ declarations, rootName }) { + const lines = [] + + for (const [index, declaration] of declarations.entries()) { + if (index > 0) { + lines.push('') + } + + if (declaration.comment) { + lines.push(...renderCommentBlock({ lines: declaration.comment })) + } + + if (declaration.kind === 'interface') { + lines.push(`interface ${declaration.name} {`) + lines.push(...declaration.bodyLines) + lines.push('}') + continue + } + + lines.push(`type ${declaration.name} = ${declaration.value};`) + } + + lines.push('') + lines.push(`export { ${rootName} };`) + + return `${lines.join('\n')}\n` +} + +function buildDeclaration ({ path, name, schema, state }) { + const context = createRenderContext({ + schema, + state, + path, + lookupPathName: false, + lookupChildPathNames: true + }) + + if (shouldUseInterface({ schema })) { + return { + kind: 'interface', + name, + comment: getCommentLines({ schema, name }), + bodyLines: buildObjectTypeLines({ context, renderType }) + } + } + + return { + kind: 'type', + name, + comment: getCommentLines({ schema, name }), + value: renderType({ context }) + } +} + +function shouldUseInterface ({ schema }) { + return hasObjectMembers({ schema }) && !hasCombinator({ schema }) +} + +function hasObjectMembers ({ schema }) { + return schema.type === 'object' || schema.properties || schema.additionalProperties !== undefined || schema.patternProperties +} + +function hasCombinator ({ schema }) { + return Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.allOf) +} diff --git a/packages/massimo-cli/lib/json-schema/generator.js b/packages/massimo-cli/lib/json-schema/generator.js new file mode 100644 index 0000000..aab507e --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/generator.js @@ -0,0 +1,14 @@ +import { buildDeclarations, renderDeclarations } from './declarations.js' +import { scanJSONSchema } from './scanner.js' + +export function generateJSONSchemaTypes ({ schema, rootName }) { + const state = scanJSONSchema({ schema, rootName }) + const declarations = buildDeclarations({ state }) + + return { + types: renderDeclarations({ + declarations, + rootName: state.rootName + }) + } +} diff --git a/packages/massimo-cli/lib/json-schema/name-registry.js b/packages/massimo-cli/lib/json-schema/name-registry.js new file mode 100644 index 0000000..81c9240 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/name-registry.js @@ -0,0 +1,60 @@ +export function createNameRegistry () { + const namesByPath = new Map() + const namesByStructure = new Map() + const countsByBaseName = new Map() + + function getUniqueName ({ baseName }) { + const currentCount = countsByBaseName.get(baseName) || 0 + countsByBaseName.set(baseName, currentCount + 1) + + if (currentCount === 0) { + return baseName + } + + return `${baseName}_${currentCount}` + } + + return { + registerPathName ({ path, name }) { + if (namesByPath.has(path)) { + return namesByPath.get(path).name + } + + const uniqueName = getUniqueName({ baseName: name }) + namesByPath.set(path, { + name: uniqueName, + baseName: name + }) + + return uniqueName + }, + + getPathName ({ path }) { + return namesByPath.get(path)?.name + }, + + hasPathName ({ path }) { + return namesByPath.has(path) + }, + + getPathBaseName ({ path }) { + return namesByPath.get(path)?.baseName + }, + + getPathEntries () { + return new Map(Array.from(namesByPath.entries(), ([path, entry]) => [path, entry.name])) + }, + + hasStructureName ({ key }) { + return namesByStructure.has(key) + }, + + getStructureName ({ key }) { + return namesByStructure.get(key) + }, + + setStructureName ({ key, name }) { + namesByStructure.set(key, name) + } + } +} diff --git a/packages/massimo-cli/lib/json-schema/naming.js b/packages/massimo-cli/lib/json-schema/naming.js new file mode 100644 index 0000000..7b29630 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/naming.js @@ -0,0 +1,13 @@ +import { capitalize, toJavaScriptName } from '../utils.js' + +export function getDefaultRootName ({ schema, rootName }) { + return normalizeTypeName(rootName || schema?.title || 'Schema') +} + +export function normalizeTypeName (value) { + return capitalize(toJavaScriptName(String(value || 'Schema'))) +} + +export function joinTypeName ({ prefix = '', suffix = '' }) { + return normalizeTypeName(`${prefix}${normalizeTypeName(suffix)}`) +} diff --git a/packages/massimo-cli/lib/json-schema/object.js b/packages/massimo-cli/lib/json-schema/object.js new file mode 100644 index 0000000..17ca44d --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/object.js @@ -0,0 +1,98 @@ +import { createChildRenderContext } from './render-context.js' + +export function renderObjectType ({ context, renderType }) { + const lines = buildObjectTypeLines({ context, renderType }) + + if (lines.length === 0) { + return '{}' + } + + return `{ +${lines.join('\n')} +}` +} + +export function buildObjectTypeLines ({ context, renderType }) { + const propertyLines = renderPropertyLines({ context, renderType }) + const indexLines = renderIndexSignatureLines({ context, renderType }) + return [...propertyLines, ...indexLines] +} + +function renderPropertyLines ({ context, renderType }) { + if (!isSchemaObject(context.schema.properties)) { + return [] + } + + const requiredProperties = new Set(context.schema.required || []) + + return Object.entries(context.schema.properties).map(([propertyName, propertySchema]) => { + const propertyType = renderType({ + context: createChildRenderContext({ + context, + schema: propertySchema, + pathSuffix: `properties/${propertyName}` + }) + }) + + return ` ${renderPropertyKey({ propertyName })}${requiredProperties.has(propertyName) ? '' : '?'}: ${propertyType};` + }) +} + +function renderIndexSignatureLines ({ context, renderType }) { + const lines = [] + + if (context.schema.additionalProperties === true) { + lines.push(' [key: string]: unknown;') + } + + if (isSchemaObject(context.schema.additionalProperties)) { + const valueType = renderType({ + context: createChildRenderContext({ + context, + schema: context.schema.additionalProperties, + pathSuffix: 'additionalProperties' + }) + }) + + lines.push(` [key: string]: ${valueType};`) + } + + if (isSchemaObject(context.schema.patternProperties)) { + const patternTypes = Object.entries(context.schema.patternProperties).map(([pattern, patternSchema]) => { + return renderType({ + context: createChildRenderContext({ + context, + schema: patternSchema, + pathSuffix: `patternProperties/${pattern}`, + lookupPathName: false + }) + }) + }) + + if (patternTypes.length > 0) { + lines.push(` [key: string]: ${joinUniqueTypes({ types: patternTypes })};`) + } + } + + return dedupeLines({ lines }) +} + +function joinUniqueTypes ({ types }) { + return [...new Set(types)].join(' | ') +} + +function dedupeLines ({ lines }) { + return [...new Set(lines)] +} + +function renderPropertyKey ({ propertyName }) { + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propertyName)) { + return propertyName + } + + return JSON.stringify(propertyName) +} + +function isSchemaObject (value) { + return value !== null && typeof value === 'object' +} diff --git a/packages/massimo-cli/lib/json-schema/pointer.js b/packages/massimo-cli/lib/json-schema/pointer.js new file mode 100644 index 0000000..0d25409 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/pointer.js @@ -0,0 +1,13 @@ +import jsonpointer from 'jsonpointer' + +export function toRefPath (ref) { + return typeof ref === 'string' && ref.startsWith('#') ? ref : '#' +} + +export function getSchemaAtPath ({ schema, path }) { + if (path === '#') { + return schema + } + + return jsonpointer.get(schema, path.slice(1)) +} diff --git a/packages/massimo-cli/lib/json-schema/primitive.js b/packages/massimo-cli/lib/json-schema/primitive.js new file mode 100644 index 0000000..fbf3124 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/primitive.js @@ -0,0 +1,51 @@ +export function renderConstLiteralType ({ value }) { + if (typeof value === 'string') { + return JSON.stringify(value) + } + + return String(value) +} + +export function renderEnumLiteralType ({ value }) { + if (typeof value === 'string') { + return `'${value.replace(/'/g, "\\'")}'` + } + + return String(value) +} + +export function renderEnumType ({ values }) { + return values.map(value => renderEnumLiteralType({ value })).join(' | ') +} + +export function renderPrimitiveType ({ schema }) { + if (schema.const !== undefined) { + return renderConstLiteralType({ value: schema.const }) + } + + if (Array.isArray(schema.enum)) { + return renderEnumType({ values: schema.enum }) + } + + if (Array.isArray(schema.type)) { + return schema.type.map(typeName => mapJSONSchemaType({ typeName })).join(' | ') + } + + return mapJSONSchemaType({ typeName: schema.type }) +} + +export function mapJSONSchemaType ({ typeName }) { + switch (typeName) { + case 'string': + return 'string' + case 'integer': + case 'number': + return 'number' + case 'boolean': + return 'boolean' + case 'null': + return 'null' + default: + return 'unknown' + } +} diff --git a/packages/massimo-cli/lib/json-schema/reference.js b/packages/massimo-cli/lib/json-schema/reference.js new file mode 100644 index 0000000..dcbb10e --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/reference.js @@ -0,0 +1,24 @@ +import { getSchemaAtPath, toRefPath } from './pointer.js' +import { createRenderContext } from './render-context.js' + +export function renderReferenceType ({ ref, context, renderType }) { + const path = toRefPath(ref) + const registeredName = context.nameRegistry.getPathName({ path }) + if (registeredName) { + return registeredName + } + + const targetSchema = getSchemaAtPath({ schema: context.rootSchema, path }) + if (!targetSchema) { + return 'unknown' + } + + return renderType({ + context: createRenderContext({ + schema: targetSchema, + state: context, + path, + lookupPathName: false + }) + }) +} diff --git a/packages/massimo-cli/lib/json-schema/render-context.js b/packages/massimo-cli/lib/json-schema/render-context.js new file mode 100644 index 0000000..a58a7f3 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/render-context.js @@ -0,0 +1,21 @@ +export function createRenderContext ({ schema, state, path = '#', lookupPathName = true, lookupChildPathNames = lookupPathName }) { + return { + schema, + rootSchema: state.rootSchema, + nameRegistry: state.nameRegistry, + path, + lookupPathName, + lookupChildPathNames + } +} + +export function createChildRenderContext ({ context, schema, pathSuffix, lookupPathName = context.lookupChildPathNames }) { + return { + schema, + rootSchema: context.rootSchema, + nameRegistry: context.nameRegistry, + path: `${context.path}/${pathSuffix}`, + lookupPathName, + lookupChildPathNames: context.lookupChildPathNames + } +} diff --git a/packages/massimo-cli/lib/json-schema/render-type.js b/packages/massimo-cli/lib/json-schema/render-type.js new file mode 100644 index 0000000..527341c --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/render-type.js @@ -0,0 +1,74 @@ +import { renderArrayType } from './array.js' +import { renderObjectType } from './object.js' +import { renderPrimitiveType } from './primitive.js' +import { renderReferenceType } from './reference.js' +import { renderIntersectionType, renderUnionType } from './union.js' + +export function renderType ({ context }) { + const { schema, nameRegistry, path, lookupPathName } = context + + if (lookupPathName) { + const registeredName = nameRegistry.getPathName({ path }) + if (registeredName) { + return registeredName + } + } + + if (schema.$ref) { + return renderReferenceType({ + ref: schema.$ref, + context, + renderType + }) + } + + const combinatorType = renderCombinatorType({ context, renderType }) + const objectType = hasObjectShape({ schema }) + ? renderObjectType({ context, renderType }) + : null + + if (objectType && combinatorType) { + return `${objectType} & (${combinatorType})` + } + + if (combinatorType) { + return combinatorType + } + + if (schema.type === 'array' || Array.isArray(schema.items)) { + return renderArrayType({ + context, + renderType + }) + } + + if (objectType) { + return objectType + } + + if (schema.const !== undefined || Array.isArray(schema.enum) || Array.isArray(schema.type) || isPrimitiveSchemaType({ schema })) { + return renderPrimitiveType({ schema }) + } + + return 'unknown' +} + +function isPrimitiveSchemaType ({ schema }) { + return ['string', 'integer', 'number', 'boolean', 'null'].includes(schema.type) +} + +function hasObjectShape ({ schema }) { + return schema.type === 'object' || schema.properties || schema.additionalProperties !== undefined || schema.patternProperties +} + +function renderCombinatorType ({ context, renderType }) { + if (Array.isArray(context.schema.oneOf) || Array.isArray(context.schema.anyOf)) { + return renderUnionType({ context, renderType }) + } + + if (Array.isArray(context.schema.allOf)) { + return renderIntersectionType({ context, renderType }) + } + + return null +} diff --git a/packages/massimo-cli/lib/json-schema/scanner.js b/packages/massimo-cli/lib/json-schema/scanner.js new file mode 100644 index 0000000..2d7c287 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/scanner.js @@ -0,0 +1,241 @@ +import { createNameRegistry } from './name-registry.js' +import { getDefaultRootName, joinTypeName, normalizeTypeName } from './naming.js' +import { getSchemaAtPath, toRefPath } from './pointer.js' + +export function scanJSONSchema ({ schema, rootName = undefined }) { + const state = createScanState({ schema, rootName }) + + traverseSchema({ + schema, + path: '#', + suggestedName: state.rootName, + state + }) + + registerReferencedSchemas({ state }) + + return state +} + +export function createScanState ({ schema, rootName = undefined }) { + return { + rootSchema: schema, + rootName: getDefaultRootName({ schema, rootName }), + nameRegistry: createNameRegistry(), + references: new Set() + } +} + +function traverseSchema ({ schema, path, suggestedName, state }) { + if (!isSchemaObject(schema)) { + return + } + + if (schema.$ref) { + state.references.add(schema.$ref) + } + + const name = getRegisteredName({ schema, path, suggestedName, state }) + + traverseDefinitions({ schema, path, state }) + traverseProperties({ schema, path, parentName: name || suggestedName, state }) + traverseItems({ schema, path, parentName: name || suggestedName, state }) + traverseAdditionalProperties({ schema, path, parentName: name || suggestedName, state }) + traverseCombinators({ schema, path, parentName: name || suggestedName, state }) +} + +function traverseDefinitions ({ schema, path, state }) { + for (const [containerName, definitions] of Object.entries({ + definitions: schema.definitions, + $defs: schema.$defs + })) { + if (!isSchemaObject(definitions)) { + continue + } + + for (const [key, definitionSchema] of Object.entries(definitions)) { + traverseSchema({ + schema: definitionSchema, + path: `${path}/${containerName}/${key}`, + suggestedName: normalizeTypeName(key), + state + }) + } + } +} + +function traverseProperties ({ schema, path, parentName, state }) { + if (!isSchemaObject(schema.properties)) { + return + } + + for (const [propertyName, propertySchema] of Object.entries(schema.properties)) { + traverseSchema({ + schema: propertySchema, + path: `${path}/properties/${propertyName}`, + suggestedName: joinTypeName({ prefix: parentName, suffix: propertyName }), + state + }) + } +} + +function traverseItems ({ schema, path, parentName, state }) { + if (!schema.items) { + return + } + + if (Array.isArray(schema.items)) { + for (const [index, itemSchema] of schema.items.entries()) { + traverseSchema({ + schema: itemSchema, + path: `${path}/items/${index}`, + suggestedName: joinTypeName({ prefix: parentName, suffix: `Item${index + 1}` }), + state + }) + } + + return + } + + traverseSchema({ + schema: schema.items, + path: `${path}/items`, + suggestedName: joinTypeName({ prefix: parentName, suffix: 'Item' }), + state + }) +} + +function traverseAdditionalProperties ({ schema, path, parentName, state }) { + if (!isSchemaObject(schema.additionalProperties)) { + return + } + + traverseSchema({ + schema: schema.additionalProperties, + path: `${path}/additionalProperties`, + suggestedName: joinTypeName({ prefix: parentName, suffix: 'Value' }), + state + }) +} + +function traverseCombinators ({ schema, path, parentName, state }) { + traverseCombinatorMembers({ + members: schema.oneOf, + path, + parentName, + kind: 'Option', + keyword: 'oneOf', + state + }) + + traverseCombinatorMembers({ + members: schema.anyOf, + path, + parentName, + kind: 'Option', + keyword: 'anyOf', + state + }) + + traverseCombinatorMembers({ + members: schema.allOf, + path, + parentName, + kind: 'Part', + keyword: 'allOf', + state + }) +} + +function traverseCombinatorMembers ({ members, path, parentName, kind, keyword, state }) { + if (!Array.isArray(members)) { + return + } + + for (const [index, memberSchema] of members.entries()) { + traverseSchema({ + schema: memberSchema, + path: `${path}/${keyword}/${index}`, + suggestedName: memberSchema?.$ref + ? '' + : joinTypeName({ prefix: parentName, suffix: `${kind}${index + 1}` }), + state + }) + } +} + +function registerReferencedSchemas ({ state }) { + for (const reference of state.references) { + const path = toRefPath(reference) + if (!path.startsWith('#') || state.nameRegistry.hasPathName({ path })) { + continue + } + + const targetSchema = getSchemaAtPath({ schema: state.rootSchema, path }) + if (!isSchemaObject(targetSchema)) { + continue + } + + const fallbackName = getFallbackNameFromPath({ path }) + getRegisteredName({ + schema: targetSchema, + path, + suggestedName: fallbackName, + state + }) + } +} + +function getRegisteredName ({ schema, path, suggestedName, state }) { + if (state.nameRegistry.hasPathName({ path })) { + return state.nameRegistry.getPathName({ path }) + } + + const resolvedName = resolveScanName({ schema, path, suggestedName, state }) + if (!resolvedName) { + return null + } + + return state.nameRegistry.registerPathName({ + path, + name: resolvedName + }) +} + +function resolveScanName ({ schema, path, suggestedName, state }) { + if (path === '#') { + return state.rootName + } + + if (suggestedName) { + return normalizeTypeName(suggestedName) + } + + if (isDefinitionPath(path)) { + return schema.title + ? normalizeTypeName(schema.title) + : getFallbackNameFromPath({ path }) + } + + if (isItemPath(path) && schema.title) { + return normalizeTypeName(schema.title) + } + + return null +} + +function getFallbackNameFromPath ({ path }) { + return normalizeTypeName(path.split('/').at(-1) || 'Schema') +} + +function isDefinitionPath (path) { + return /\/(definitions|\$defs)\/[^/]+$/.test(path) +} + +function isItemPath (path) { + return /\/items(\/\d+)?$/.test(path) +} + +function isSchemaObject (value) { + return value !== null && typeof value === 'object' +} diff --git a/packages/massimo-cli/lib/json-schema/union.js b/packages/massimo-cli/lib/json-schema/union.js new file mode 100644 index 0000000..91ab38b --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/union.js @@ -0,0 +1,52 @@ +import { createChildRenderContext } from './render-context.js' + +export function renderUnionType ({ context, renderType }) { + return renderCombinatorMembers({ + context, + members: context.schema.oneOf || context.schema.anyOf, + separator: ' | ', + keyword: context.schema.oneOf ? 'oneOf' : 'anyOf', + renderType + }) +} + +export function renderIntersectionType ({ context, renderType }) { + return renderCombinatorMembers({ + context, + members: context.schema.allOf, + separator: ' & ', + keyword: 'allOf', + renderType + }) +} + +function renderCombinatorMembers ({ context, members, separator, keyword, renderType }) { + if (!Array.isArray(members) || members.length === 0) { + return 'unknown' + } + + return members.map((schema, index) => { + const memberType = renderType({ + context: createChildRenderContext({ + context, + schema, + pathSuffix: `${keyword}/${index}`, + lookupPathName: false + }) + }) + + return wrapCombinatorMember({ memberType, separator }) + }).join(separator) +} + +function wrapCombinatorMember ({ memberType, separator }) { + if (separator === ' & ' && memberType.includes(' | ')) { + return `(${memberType})` + } + + if (separator === ' | ' && memberType.includes(' & ')) { + return `(${memberType})` + } + + return memberType +} diff --git a/packages/massimo-cli/test/json-schema-declarations.test.js b/packages/massimo-cli/test/json-schema-declarations.test.js new file mode 100644 index 0000000..1b19b17 --- /dev/null +++ b/packages/massimo-cli/test/json-schema-declarations.test.js @@ -0,0 +1,51 @@ +import { equal } from 'node:assert/strict' +import { test } from 'node:test' +import { buildDeclarations, renderDeclarations } from '../lib/json-schema/declarations.js' +import { scanJSONSchema } from '../lib/json-schema/scanner.js' + +test('buildDeclarations emits interfaces and type aliases for named paths', () => { + const state = scanJSONSchema({ + schema: { + title: 'Claim', + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + format: 'uuid' + }, + data: { + type: 'object', + properties: { + status: { + enum: ['ACTIVE', 'INACTIVE'] + } + } + } + } + } + }) + + const declarations = buildDeclarations({ state }) + + equal(renderDeclarations({ declarations, rootName: state.rootName }), [ + 'interface Claim {', + ' id: ClaimId;', + ' data?: ClaimData;', + '}', + '', + '/**', + ' * Expected format: JSON Schema uuid', + ' */', + 'type ClaimId = string;', + '', + 'interface ClaimData {', + ' status?: ClaimDataStatus;', + '}', + '', + 'type ClaimDataStatus = \'ACTIVE\' | \'INACTIVE\';', + '', + 'export { Claim };', + '' + ].join('\n')) +}) diff --git a/packages/massimo-cli/test/json-schema-reference.test.js b/packages/massimo-cli/test/json-schema-reference.test.js new file mode 100644 index 0000000..fa34333 --- /dev/null +++ b/packages/massimo-cli/test/json-schema-reference.test.js @@ -0,0 +1,115 @@ +import { equal } from 'node:assert/strict' +import { test } from 'node:test' +import { renderReferenceType } from '../lib/json-schema/reference.js' +import { createRenderContext, createChildRenderContext } from '../lib/json-schema/render-context.js' +import { scanJSONSchema } from '../lib/json-schema/scanner.js' + +test('createChildRenderContext preserves shared render state', () => { + const state = scanJSONSchema({ + schema: { + title: 'User', + type: 'object', + properties: { + profile: { + type: 'object' + } + } + } + }) + + const rootContext = createRenderContext({ + schema: state.rootSchema, + state, + lookupPathName: false + }) + + const childContext = createChildRenderContext({ + context: rootContext, + schema: state.rootSchema.properties.profile, + pathSuffix: 'properties/profile' + }) + + equal(childContext.rootSchema, rootContext.rootSchema) + equal(childContext.nameRegistry, rootContext.nameRegistry) + equal(childContext.path, '#/properties/profile') + equal(childContext.lookupPathName, rootContext.lookupChildPathNames) + equal(childContext.lookupChildPathNames, rootContext.lookupChildPathNames) +}) + +test('renderReferenceType uses scanned registry names for internal refs', () => { + const state = scanJSONSchema({ + schema: { + title: 'Envelope', + type: 'object', + properties: { + payload: { + $ref: '#/definitions/Payload' + } + }, + definitions: { + Payload: { + type: 'object', + properties: { + id: { type: 'string' } + } + } + } + } + }) + + const context = createRenderContext({ + schema: state.rootSchema.properties.payload, + state, + path: '#/properties/payload' + }) + + equal(renderReferenceType({ + ref: '#/definitions/Payload', + context, + renderType () { + return 'should-not-inline' + } + }), 'Payload') +}) + +test('renderReferenceType falls back to inline rendering when registry has no name', () => { + const state = scanJSONSchema({ + schema: { + title: 'Envelope', + definitions: { + Flag: { + type: 'boolean' + } + } + } + }) + + const context = createRenderContext({ + schema: state.rootSchema, + state, + lookupPathName: false + }) + + equal(renderReferenceType({ + ref: '#/definitions/Missing', + context, + renderType () { + return 'boolean' + } + }), 'unknown') + + equal(renderReferenceType({ + ref: '#/definitions/Flag', + context: { + ...context, + nameRegistry: { + getPathName () { + return undefined + } + } + }, + renderType ({ context: inlineContext }) { + return inlineContext.schema.type + } + }), 'boolean') +}) diff --git a/packages/massimo-cli/test/json-schema-render-type.test.js b/packages/massimo-cli/test/json-schema-render-type.test.js new file mode 100644 index 0000000..b2e55d3 --- /dev/null +++ b/packages/massimo-cli/test/json-schema-render-type.test.js @@ -0,0 +1,207 @@ +import { equal } from 'node:assert/strict' +import { test } from 'node:test' +import { createRenderContext } from '../lib/json-schema/render-context.js' +import { renderType } from '../lib/json-schema/render-type.js' +import { scanJSONSchema } from '../lib/json-schema/scanner.js' + +test('renderType handles primitive schema types', () => { + equal(renderInlineType({ schema: { type: 'string' } }), 'string') + equal(renderInlineType({ schema: { type: 'integer' } }), 'number') + equal(renderInlineType({ schema: { type: 'boolean' } }), 'boolean') + equal(renderInlineType({ schema: { type: 'null' } }), 'null') +}) + +test('renderType handles consts, enums, and union type arrays', () => { + equal(renderInlineType({ schema: { const: 'ACTIVE' } }), '"ACTIVE"') + equal(renderInlineType({ schema: { enum: ['A', 'B', 2] } }), "'A' | 'B' | 2") + equal(renderInlineType({ schema: { type: ['string', 'null'] } }), 'string | null') +}) + +test('renderType handles refs through the scanned registry', () => { + const state = scanJSONSchema({ + schema: { + title: 'Envelope', + type: 'object', + properties: { + payload: { + $ref: '#/definitions/Payload' + } + }, + definitions: { + Payload: { + type: 'string' + } + } + } + }) + + const context = createRenderContext({ + schema: state.rootSchema.properties.payload, + state, + path: '#/properties/payload', + lookupPathName: false + }) + + equal(renderType({ context }), 'Payload') +}) + +test('renderType handles arrays and tuples', () => { + equal(renderInlineType({ + schema: { + type: 'array', + items: { + type: 'string' + } + } + }), 'Array') + + equal(renderInlineType({ + schema: { + type: 'array', + items: [ + { type: 'string' }, + { type: 'integer' } + ] + } + }), '[string, number]') +}) + +test('renderType parenthesizes complex array item types', () => { + equal(renderInlineType({ + schema: { + type: 'array', + items: { + enum: ['A', 'B'] + } + } + }), "Array<('A' | 'B')>") +}) + +test('renderType handles object properties and required fields', () => { + equal(renderInlineType({ + schema: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + active: { type: 'boolean' } + } + } + }), '{\n id: string;\n active?: boolean;\n}') +}) + +test('renderType handles nested inline objects when child lookup is disabled', () => { + equal(renderInlineType({ + schema: { + type: 'object', + properties: { + profile: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' } + } + } + } + } + }), '{\n profile?: {\n id: string;\n};\n}') +}) + +test('renderType handles object index signatures', () => { + equal(renderInlineType({ + schema: { + type: 'object', + additionalProperties: { + enum: ['A', 'B'] + } + } + }), "{\n [key: string]: 'A' | 'B';\n}") + + equal(renderInlineType({ + schema: { + type: 'object', + patternProperties: { + '^[a-z]+$': { + type: 'string' + } + } + } + }), '{\n [key: string]: string;\n}') +}) + +test('renderType handles oneOf and anyOf unions', () => { + equal(renderInlineType({ + schema: { + oneOf: [ + { type: 'string' }, + { type: 'number' } + ] + } + }), 'string | number') + + equal(renderInlineType({ + schema: { + anyOf: [ + { enum: ['A', 'B'] }, + { type: 'null' } + ] + } + }), "'A' | 'B' | null") +}) + +test('renderType handles allOf intersections', () => { + equal(renderInlineType({ + schema: { + allOf: [ + { type: 'string' }, + { enum: ['A', 'B'] } + ] + } + }), "string & ('A' | 'B')") +}) + +test('renderType combines object properties with combinators', () => { + equal(renderInlineType({ + schema: { + type: 'object', + required: ['type'], + properties: { + type: { type: 'string' } + }, + oneOf: [ + { + type: 'object', + required: ['value'], + properties: { + value: { type: 'number' } + } + }, + { + type: 'object', + required: ['value'], + properties: { + value: { type: 'string' } + } + } + ] + } + }), '{\n type: string;\n} & ({\n value: number;\n} | {\n value: string;\n})') +}) + +function renderInlineType ({ schema }) { + const state = scanJSONSchema({ + schema: { + title: 'Inline', + ...schema + } + }) + + return renderType({ + context: createRenderContext({ + schema, + state, + lookupPathName: false, + lookupChildPathNames: false + }) + }) +} diff --git a/packages/massimo-cli/test/json-schema-scanner.test.js b/packages/massimo-cli/test/json-schema-scanner.test.js new file mode 100644 index 0000000..9ec88ff --- /dev/null +++ b/packages/massimo-cli/test/json-schema-scanner.test.js @@ -0,0 +1,56 @@ +import { readFile } from 'fs/promises' +import { deepEqual, equal } from 'node:assert/strict' +import { test } from 'node:test' +import { join } from 'path' +import { scanJSONSchema } from '../lib/json-schema/scanner.js' + +const fixturesDir = join(import.meta.dirname, 'fixtures', 'json-schema') + +test('scan registers root and nested property names', async () => { + const schema = await readFixtureSchema({ example: 'some-nesting' }) + const state = scanJSONSchema({ schema }) + + equal(state.rootName, 'CreateReservation') + equal(state.nameRegistry.getPathName({ path: '#' }), 'CreateReservation') + equal(state.nameRegistry.getPathName({ path: '#/properties/data' }), 'Data') + equal(state.nameRegistry.getPathName({ path: '#/properties/data/properties/requestContext' }), 'DataRequestContext') + equal(state.nameRegistry.getPathName({ path: '#/properties/data/properties/requestContext/properties/actor' }), 'DataRequestContextActor') +}) + +test('scan collects refs and names referenced definitions', async () => { + const schema = await readFixtureSchema({ example: 'flat' }) + const state = scanJSONSchema({ schema }) + + deepEqual([...state.references].sort(), [ + '#/definitions/CommandData', + '#/definitions/CommandType' + ]) + + equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandData' }), 'CommandData') + equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandType' }), 'CommandType') +}) + +test('scan keeps generated names unique', () => { + const state = scanJSONSchema({ + schema: { + title: 'User', + type: 'object', + definitions: { + User: { + type: 'object', + properties: { + id: { type: 'string' } + } + } + } + } + }) + + equal(state.nameRegistry.getPathName({ path: '#' }), 'User') + equal(state.nameRegistry.getPathName({ path: '#/definitions/User' }), 'User_1') +}) + +async function readFixtureSchema ({ example }) { + const schemaPath = join(fixturesDir, example, 'schema.json') + return JSON.parse(await readFile(schemaPath, 'utf8')) +} From 34a7a7edac6a940bbf68fa2275362e00c02feff6 Mon Sep 17 00:00:00 2001 From: Safwan Parkar Date: Sun, 8 Mar 2026 03:25:18 +0100 Subject: [PATCH 3/6] feat(massimo-cli): emit deterministic d.ts declarations from json schema Signed-off-by: Safwan Parkar --- .../massimo-cli/lib/json-schema/comments.js | 2 +- .../massimo-cli/lib/json-schema/core/index.js | 4 + .../json-schema/{ => core}/name-registry.js | 9 + .../lib/json-schema/core/naming.js | 31 + .../lib/json-schema/{ => core}/pointer.js | 0 .../lib/json-schema/{ => core}/scanner.js | 198 ++++- .../lib/json-schema/declarations.js | 91 --- .../json-schema/declarations/canonicalize.js | 595 +++++++++++++++ .../lib/json-schema/declarations/graph.js | 700 ++++++++++++++++++ .../lib/json-schema/declarations/helpers.js | 345 +++++++++ .../lib/json-schema/declarations/index.js | 48 ++ .../lib/json-schema/declarations/validate.js | 68 ++ .../massimo-cli/lib/json-schema/generator.js | 15 +- .../massimo-cli/lib/json-schema/naming.js | 13 - .../massimo-cli/lib/json-schema/object.js | 98 --- .../lib/json-schema/{ => render}/array.js | 0 .../lib/json-schema/render/index.js | 7 + .../lib/json-schema/render/object.js | 173 +++++ .../lib/json-schema/{ => render}/primitive.js | 0 .../lib/json-schema/{ => render}/reference.js | 10 +- .../{ => render}/render-context.js | 0 .../json-schema/{ => render}/render-type.js | 18 +- .../lib/json-schema/{ => render}/union.js | 0 .../test/json-schema-declarations.test.js | 529 ++++++++++++- .../test/json-schema-reference.test.js | 5 +- .../test/json-schema-render-type.test.js | 5 +- .../test/json-schema-scanner.test.js | 43 +- 27 files changed, 2761 insertions(+), 246 deletions(-) create mode 100644 packages/massimo-cli/lib/json-schema/core/index.js rename packages/massimo-cli/lib/json-schema/{ => core}/name-registry.js (89%) create mode 100644 packages/massimo-cli/lib/json-schema/core/naming.js rename packages/massimo-cli/lib/json-schema/{ => core}/pointer.js (100%) rename packages/massimo-cli/lib/json-schema/{ => core}/scanner.js (50%) delete mode 100644 packages/massimo-cli/lib/json-schema/declarations.js create mode 100644 packages/massimo-cli/lib/json-schema/declarations/canonicalize.js create mode 100644 packages/massimo-cli/lib/json-schema/declarations/graph.js create mode 100644 packages/massimo-cli/lib/json-schema/declarations/helpers.js create mode 100644 packages/massimo-cli/lib/json-schema/declarations/index.js create mode 100644 packages/massimo-cli/lib/json-schema/declarations/validate.js delete mode 100644 packages/massimo-cli/lib/json-schema/naming.js delete mode 100644 packages/massimo-cli/lib/json-schema/object.js rename packages/massimo-cli/lib/json-schema/{ => render}/array.js (100%) create mode 100644 packages/massimo-cli/lib/json-schema/render/index.js create mode 100644 packages/massimo-cli/lib/json-schema/render/object.js rename packages/massimo-cli/lib/json-schema/{ => render}/primitive.js (100%) rename packages/massimo-cli/lib/json-schema/{ => render}/reference.js (58%) rename packages/massimo-cli/lib/json-schema/{ => render}/render-context.js (100%) rename packages/massimo-cli/lib/json-schema/{ => render}/render-type.js (72%) rename packages/massimo-cli/lib/json-schema/{ => render}/union.js (100%) diff --git a/packages/massimo-cli/lib/json-schema/comments.js b/packages/massimo-cli/lib/json-schema/comments.js index 14974a2..dc3bdce 100644 --- a/packages/massimo-cli/lib/json-schema/comments.js +++ b/packages/massimo-cli/lib/json-schema/comments.js @@ -1,4 +1,4 @@ -import { normalizeTypeName } from './naming.js' +import { normalizeTypeName } from './core/naming.js' export function getCommentLines ({ schema, name }) { const lines = [] diff --git a/packages/massimo-cli/lib/json-schema/core/index.js b/packages/massimo-cli/lib/json-schema/core/index.js new file mode 100644 index 0000000..86a29e1 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/core/index.js @@ -0,0 +1,4 @@ +export * from './name-registry.js' +export * from './naming.js' +export * from './pointer.js' +export * from './scanner.js' diff --git a/packages/massimo-cli/lib/json-schema/name-registry.js b/packages/massimo-cli/lib/json-schema/core/name-registry.js similarity index 89% rename from packages/massimo-cli/lib/json-schema/name-registry.js rename to packages/massimo-cli/lib/json-schema/core/name-registry.js index 81c9240..0e36835 100644 --- a/packages/massimo-cli/lib/json-schema/name-registry.js +++ b/packages/massimo-cli/lib/json-schema/core/name-registry.js @@ -33,6 +33,15 @@ export function createNameRegistry () { return namesByPath.get(path)?.name }, + linkPathName ({ path, name, baseName = name }) { + namesByPath.set(path, { + name, + baseName + }) + + return name + }, + hasPathName ({ path }) { return namesByPath.has(path) }, diff --git a/packages/massimo-cli/lib/json-schema/core/naming.js b/packages/massimo-cli/lib/json-schema/core/naming.js new file mode 100644 index 0000000..e33f153 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/core/naming.js @@ -0,0 +1,31 @@ +import { capitalize, toJavaScriptName } from '../../utils.js' + +export function getDefaultRootName ({ schema, rootName }) { + return normalizeTypeName(rootName || schema?.title || 'Schema') +} + +export function normalizeTypeName (value) { + return capitalize(toJavaScriptName(String(value || 'Schema'))) +} + +export function joinTypeName ({ prefix = '', suffix = '' }) { + return normalizeTypeName(`${prefix}${normalizeTypeName(suffix)}`) +} + +export function singularizeTypeName (value) { + const normalized = normalizeTypeName(value) + + if (normalized.endsWith('ies')) { + return `${normalized.slice(0, -3)}y` + } + + if (normalized.endsWith('ses')) { + return normalized.slice(0, -2) + } + + if (normalized.endsWith('s') && normalized.length > 1) { + return normalized.slice(0, -1) + } + + return normalized +} diff --git a/packages/massimo-cli/lib/json-schema/pointer.js b/packages/massimo-cli/lib/json-schema/core/pointer.js similarity index 100% rename from packages/massimo-cli/lib/json-schema/pointer.js rename to packages/massimo-cli/lib/json-schema/core/pointer.js diff --git a/packages/massimo-cli/lib/json-schema/scanner.js b/packages/massimo-cli/lib/json-schema/core/scanner.js similarity index 50% rename from packages/massimo-cli/lib/json-schema/scanner.js rename to packages/massimo-cli/lib/json-schema/core/scanner.js index 2d7c287..7338598 100644 --- a/packages/massimo-cli/lib/json-schema/scanner.js +++ b/packages/massimo-cli/lib/json-schema/core/scanner.js @@ -1,5 +1,5 @@ import { createNameRegistry } from './name-registry.js' -import { getDefaultRootName, joinTypeName, normalizeTypeName } from './naming.js' +import { getDefaultRootName, joinTypeName, normalizeTypeName, singularizeTypeName } from './naming.js' import { getSchemaAtPath, toRefPath } from './pointer.js' export function scanJSONSchema ({ schema, rootName = undefined }) { @@ -22,7 +22,9 @@ export function createScanState ({ schema, rootName = undefined }) { rootSchema: schema, rootName: getDefaultRootName({ schema, rootName }), nameRegistry: createNameRegistry(), - references: new Set() + schemasByPath: new Map([['#', schema]]), + references: new Set(), + expandedReferencePaths: new Set() } } @@ -31,17 +33,30 @@ function traverseSchema ({ schema, path, suggestedName, state }) { return } + state.schemasByPath.set(path, schema) + + const name = getRegisteredName({ schema, path, suggestedName, state }) + if (schema.$ref) { state.references.add(schema.$ref) + expandReferenceSchema({ + ref: schema.$ref, + path, + suggestedName: name || suggestedName, + state + }) + return } - const name = getRegisteredName({ schema, path, suggestedName, state }) - - traverseDefinitions({ schema, path, state }) traverseProperties({ schema, path, parentName: name || suggestedName, state }) traverseItems({ schema, path, parentName: name || suggestedName, state }) traverseAdditionalProperties({ schema, path, parentName: name || suggestedName, state }) traverseCombinators({ schema, path, parentName: name || suggestedName, state }) + traverseDefinitions({ schema, path, state }) +} + +export function getScannedSchemaAtPath ({ path, state }) { + return state.schemasByPath.get(path) || getSchemaAtPath({ schema: state.rootSchema, path }) } function traverseDefinitions ({ schema, path, state }) { @@ -70,10 +85,14 @@ function traverseProperties ({ schema, path, parentName, state }) { } for (const [propertyName, propertySchema] of Object.entries(schema.properties)) { + const suggestedName = path === '#' + ? normalizeTypeName(propertyName) + : joinTypeName({ prefix: parentName, suffix: propertyName }) + traverseSchema({ schema: propertySchema, path: `${path}/properties/${propertyName}`, - suggestedName: joinTypeName({ prefix: parentName, suffix: propertyName }), + suggestedName, state }) } @@ -97,10 +116,12 @@ function traverseItems ({ schema, path, parentName, state }) { return } + const suggestedName = getArrayItemSuggestedName({ schema: schema.items, path, parentName }) + traverseSchema({ schema: schema.items, path: `${path}/items`, - suggestedName: joinTypeName({ prefix: parentName, suffix: 'Item' }), + suggestedName, state }) } @@ -156,9 +177,7 @@ function traverseCombinatorMembers ({ members, path, parentName, kind, keyword, traverseSchema({ schema: memberSchema, path: `${path}/${keyword}/${index}`, - suggestedName: memberSchema?.$ref - ? '' - : joinTypeName({ prefix: parentName, suffix: `${kind}${index + 1}` }), + suggestedName: getCombinatorSuggestedName({ memberSchema, parentName, kind, index }), state }) } @@ -186,9 +205,33 @@ function registerReferencedSchemas ({ state }) { } } +function expandReferenceSchema ({ ref, path, suggestedName, state }) { + const refPath = toRefPath(ref) + const expansionKey = `${path}=>${refPath}` + if (state.expandedReferencePaths.has(expansionKey)) { + return + } + + state.expandedReferencePaths.add(expansionKey) + + const targetSchema = getSchemaAtPath({ schema: state.rootSchema, path: refPath }) + if (!isSchemaObject(targetSchema)) { + return + } + + traverseSchema({ + schema: targetSchema, + path, + suggestedName, + state + }) +} + function getRegisteredName ({ schema, path, suggestedName, state }) { if (state.nameRegistry.hasPathName({ path })) { - return state.nameRegistry.getPathName({ path }) + const existingName = state.nameRegistry.getPathName({ path }) + registerStructureName({ schema, name: existingName, state }) + return existingName } const resolvedName = resolveScanName({ schema, path, suggestedName, state }) @@ -196,10 +239,23 @@ function getRegisteredName ({ schema, path, suggestedName, state }) { return null } - return state.nameRegistry.registerPathName({ + const reuseKey = getSchemaReuseKey({ schema }) + if (reuseKey && isDefinitionPath(path) && state.nameRegistry.hasStructureName({ key: reuseKey })) { + return state.nameRegistry.linkPathName({ + path, + name: state.nameRegistry.getStructureName({ key: reuseKey }), + baseName: resolvedName + }) + } + + const name = state.nameRegistry.registerPathName({ path, name: resolvedName }) + + registerStructureName({ schema, name, state }) + + return name } function resolveScanName ({ schema, path, suggestedName, state }) { @@ -228,6 +284,51 @@ function getFallbackNameFromPath ({ path }) { return normalizeTypeName(path.split('/').at(-1) || 'Schema') } +function getArrayItemSuggestedName ({ schema, path, parentName }) { + const propertyName = getPropertyNameFromPath({ path }) + if (propertyName) { + return singularizeTypeName(propertyName) + } + + if (schema?.title) { + return normalizeTypeName(schema.title) + } + + return joinTypeName({ prefix: singularizeTypeName(parentName), suffix: 'Item' }) +} + +function getCombinatorSuggestedName ({ memberSchema, parentName, kind, index }) { + if (memberSchema?.$ref) { + return getFallbackNameFromPath({ path: toRefPath(memberSchema.$ref) }) + } + + const discriminatorValue = getDiscriminatorConstValue({ schema: memberSchema }) + if (discriminatorValue) { + return `${normalizeTypeName(discriminatorValue.toLowerCase())}${singularizeTypeName(parentName)}` + } + + return joinTypeName({ prefix: parentName, suffix: `${kind}${index + 1}` }) +} + +function getPropertyNameFromPath ({ path }) { + const match = path.match(/\/properties\/([^/]+)$/) + if (!match) { + return null + } + + return match[1] +} + +function getDiscriminatorConstValue ({ schema }) { + for (const propertySchema of Object.values(schema?.properties || {})) { + if (propertySchema?.const !== undefined && typeof propertySchema.const === 'string') { + return propertySchema.const + } + } + + return null +} + function isDefinitionPath (path) { return /\/(definitions|\$defs)\/[^/]+$/.test(path) } @@ -239,3 +340,76 @@ function isItemPath (path) { function isSchemaObject (value) { return value !== null && typeof value === 'object' } + +function registerStructureName ({ schema, name, state }) { + const reuseKey = getSchemaReuseKey({ schema }) + if (!reuseKey || state.nameRegistry.hasStructureName({ key: reuseKey })) { + return + } + + state.nameRegistry.setStructureName({ key: reuseKey, name }) +} + +function getSchemaReuseKey ({ schema }) { + if (!isSchemaObject(schema) || schema.$ref) { + return null + } + + return JSON.stringify(simplifySchema({ schema })) +} + +function simplifySchema ({ schema }) { + if (!isSchemaObject(schema)) { + return schema + } + + const simplified = {} + + for (const key of ['title', 'description', 'type', 'format', 'pattern', 'minimum', 'const']) { + if (schema[key] !== undefined) { + simplified[key] = schema[key] + } + } + + if (Array.isArray(schema.enum)) { + simplified.enum = [...schema.enum] + } + + if (Array.isArray(schema.required)) { + simplified.required = [...schema.required] + } + + if (isSchemaObject(schema.properties)) { + simplified.properties = Object.keys(schema.properties).sort().reduce((acc, key) => { + acc[key] = simplifySchema({ schema: schema.properties[key] }) + return acc + }, {}) + } + + if (schema.items) { + simplified.items = Array.isArray(schema.items) + ? schema.items.map(item => simplifySchema({ schema: item })) + : simplifySchema({ schema: schema.items }) + } + + if (isSchemaObject(schema.additionalProperties)) { + simplified.additionalProperties = simplifySchema({ schema: schema.additionalProperties }) + } else if (schema.additionalProperties !== undefined) { + simplified.additionalProperties = schema.additionalProperties + } + + if (isSchemaObject(schema.patternProperties)) { + simplified.patternProperties = Object.keys(schema.patternProperties).sort().reduce((acc, key) => { + acc[key] = simplifySchema({ schema: schema.patternProperties[key] }) + return acc + }, {}) + } + + for (const key of ['oneOf', 'anyOf', 'allOf']) { + if (Array.isArray(schema[key])) { + simplified[key] = schema[key].map(member => simplifySchema({ schema: member })) + } + } + + return simplified +} diff --git a/packages/massimo-cli/lib/json-schema/declarations.js b/packages/massimo-cli/lib/json-schema/declarations.js deleted file mode 100644 index 16413cc..0000000 --- a/packages/massimo-cli/lib/json-schema/declarations.js +++ /dev/null @@ -1,91 +0,0 @@ -import { getCommentLines, renderCommentBlock } from './comments.js' -import { createRenderContext } from './render-context.js' -import { buildObjectTypeLines } from './object.js' -import { getSchemaAtPath } from './pointer.js' -import { renderType } from './render-type.js' - -export function buildDeclarations ({ state }) { - const declarations = [] - - for (const [path, name] of state.nameRegistry.getPathEntries()) { - const schema = getSchemaAtPath({ schema: state.rootSchema, path }) - if (!schema) { - continue - } - - declarations.push(buildDeclaration({ - path, - name, - schema, - state - })) - } - - return declarations -} - -export function renderDeclarations ({ declarations, rootName }) { - const lines = [] - - for (const [index, declaration] of declarations.entries()) { - if (index > 0) { - lines.push('') - } - - if (declaration.comment) { - lines.push(...renderCommentBlock({ lines: declaration.comment })) - } - - if (declaration.kind === 'interface') { - lines.push(`interface ${declaration.name} {`) - lines.push(...declaration.bodyLines) - lines.push('}') - continue - } - - lines.push(`type ${declaration.name} = ${declaration.value};`) - } - - lines.push('') - lines.push(`export { ${rootName} };`) - - return `${lines.join('\n')}\n` -} - -function buildDeclaration ({ path, name, schema, state }) { - const context = createRenderContext({ - schema, - state, - path, - lookupPathName: false, - lookupChildPathNames: true - }) - - if (shouldUseInterface({ schema })) { - return { - kind: 'interface', - name, - comment: getCommentLines({ schema, name }), - bodyLines: buildObjectTypeLines({ context, renderType }) - } - } - - return { - kind: 'type', - name, - comment: getCommentLines({ schema, name }), - value: renderType({ context }) - } -} - -function shouldUseInterface ({ schema }) { - return hasObjectMembers({ schema }) && !hasCombinator({ schema }) -} - -function hasObjectMembers ({ schema }) { - return schema.type === 'object' || schema.properties || schema.additionalProperties !== undefined || schema.patternProperties -} - -function hasCombinator ({ schema }) { - return Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.allOf) -} diff --git a/packages/massimo-cli/lib/json-schema/declarations/canonicalize.js b/packages/massimo-cli/lib/json-schema/declarations/canonicalize.js new file mode 100644 index 0000000..f3506f3 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/declarations/canonicalize.js @@ -0,0 +1,595 @@ +import { normalizeTypeName } from '../core/naming.js' +import { getScannedSchemaAtPath } from '../core/scanner.js' +import { + buildCollapsedChildName, + compareCanonicalPaths, + getCollapsedOwnerPrefix, + getDeclarationOwnerScopePath, + getDeclarationSchemaReuseKey, + getNestedArrayItemName, + getObjectUnionInfo, + getPreferredPathName, + getUnionLocalPrefix, + isNamedArrayPath, + isNamedScalarArraySchema, + isReusableScalarSchema, + isTopLevelArrayItemPath, + isValueBranchScalarPath, + resolveDeclarationSchema, + shouldCollapseDirectContainerChildren, + shouldUseRootUnionPropertyName, + singularizeName +} from './helpers.js' + +export function canonicalizeDeclarationState ({ state }) { + const nameOverrides = buildDeclarationNameOverrides({ state }) + const stateWithOverrides = { + ...state, + nameOverrides + } + + enforceStableArrayItemNames({ state: stateWithOverrides, nameOverrides }) + enforceStableNameConflicts({ state: stateWithOverrides, nameOverrides }) + + return { + ...state, + nameOverrides, + structuralAliasTargets: buildStructuralAliasTargets({ + state: { + ...state, + nameOverrides + } + }) + } +} + +function buildDeclarationNameOverrides ({ state }) { + const overrides = new Map() + const paths = [...state.nameRegistry.getPathEntries().keys()].sort((left, right) => left.length - right.length) + + for (const path of paths) { + const schema = getScannedSchemaAtPath({ path, state }) + + if (isNamedArrayPath({ path, state, schema })) { + const itemPath = `${path}/items` + applySubtreeNameOverride({ + rootPath: itemPath, + nextRootName: singularizeName({ name: getPreferredPathName({ path, state: { ...state, nameOverrides: overrides } }) }), + overrides, + state + }) + } + + const unionInfo = getObjectUnionInfo({ path, schema, state: { ...state, nameOverrides: overrides } }) + if (!unionInfo) { + continue + } + + applyRootUnionBranchPropertyOverrides({ path, unionInfo, overrides, state }) + applyRootUnionBranchChildOverrides({ path, unionInfo, overrides, state }) + applySharedUnionBranchPropertyOverrides({ path, unionInfo, overrides, state }) + applyPropertyUnionBranchOverrides({ path, unionInfo, overrides, state }) + applyNestedUnionBranchOverrides({ path, unionInfo, overrides, state }) + applyBranchLeafOverrides({ path, unionInfo, overrides, state }) + } + + return overrides +} + +function enforceStableArrayItemNames ({ state, nameOverrides }) { + const paths = [...state.nameRegistry.getPathEntries().keys()].sort(compareCanonicalPaths) + + for (const path of paths) { + const schema = getScannedSchemaAtPath({ path, state }) + if (!isNamedArrayPath({ path, state: { ...state, nameOverrides }, schema })) { + continue + } + + const itemPath = `${path}/items` + const arrayName = getPreferredPathName({ path, state: { ...state, nameOverrides } }) + const itemName = getPreferredPathName({ path: itemPath, state: { ...state, nameOverrides } }) + if (!arrayName || !itemName || arrayName !== itemName) { + continue + } + + const singularName = singularizeName({ name: arrayName }) + applySubtreeNameOverride({ + rootPath: itemPath, + nextRootName: singularName === arrayName ? `${arrayName}Item` : singularName, + overrides: nameOverrides, + state + }) + } +} + +function enforceStableNameConflicts ({ state, nameOverrides }) { + const countsByName = new Map() + const paths = [...state.nameRegistry.getPathEntries().keys()].sort(compareCanonicalPaths) + + for (const path of paths) { + const name = getPreferredPathName({ path, state: { ...state, nameOverrides } }) + if (!name) { + continue + } + + const count = countsByName.get(name) || 0 + countsByName.set(name, count + 1) + + if (count === 0) { + continue + } + + applySubtreeNameOverride({ + rootPath: path, + nextRootName: `${name}_${count}`, + overrides: nameOverrides, + state + }) + } +} + +function buildStructuralAliasTargets ({ state }) { + const targets = new Map() + const candidatePaths = [...state.nameRegistry.getPathEntries().keys()].sort(compareCanonicalPaths) + + for (const path of candidatePaths) { + const schema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path, state }), + state + }) + if (!isReusableScalarSchema({ schema }) || !isValueBranchScalarPath({ path })) { + continue + } + + const reuseKey = getDeclarationSchemaReuseKey({ schema }) + if (!reuseKey) { + continue + } + + const sameScopePath = candidatePaths.find(candidatePath => { + if (candidatePath === path) { + return false + } + + if (getDeclarationOwnerScopePath({ path: candidatePath }) !== getDeclarationOwnerScopePath({ path })) { + return false + } + + const candidateSchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: candidatePath, state }), + state + }) + return getDeclarationSchemaReuseKey({ schema: candidateSchema }) === reuseKey + }) + + if (sameScopePath) { + targets.set(path, getPreferredPathName({ path: sameScopePath, state })) + continue + } + + const globalPath = candidatePaths.find(candidatePath => { + if (candidatePath === path) { + return false + } + + const candidateSchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: candidatePath, state }), + state + }) + return getDeclarationSchemaReuseKey({ schema: candidateSchema }) === reuseKey + }) + + if (globalPath) { + targets.set(path, getPreferredPathName({ path: globalPath, state })) + } + } + + return targets +} + +function applyRootUnionBranchPropertyOverrides ({ path, unionInfo, overrides, state }) { + if (path !== '#') { + return + } + + const ownerName = getPreferredPathName({ path, state: { ...state, nameOverrides: overrides } }) + if (!ownerName) { + return + } + + const propertyEntriesByName = new Map() + for (const branchPath of unionInfo.branchPaths) { + const branchSchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: branchPath, state }), + state + }) + + for (const [propertyName, propertySchema] of Object.entries(branchSchema?.properties || {})) { + if (propertySchema?.const !== undefined) { + continue + } + + const propertyPath = `${branchPath}/properties/${propertyName}` + const resolvedPropertySchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: propertyPath, state }) || propertySchema, + state + }) + + const entries = propertyEntriesByName.get(propertyName) || [] + entries.push({ + path: propertyPath, + key: getDeclarationSchemaReuseKey({ schema: resolvedPropertySchema }), + schema: resolvedPropertySchema + }) + propertyEntriesByName.set(propertyName, entries) + } + } + + for (const [propertyName, entries] of propertyEntriesByName.entries()) { + if (!shouldUseRootUnionPropertyName({ entries })) { + continue + } + + for (const entry of entries) { + applySubtreeNameOverride({ + rootPath: entry.path, + nextRootName: `${ownerName}${normalizeTypeName(propertyName)}`, + overrides, + state + }) + } + } +} + +function applyRootUnionBranchChildOverrides ({ path, unionInfo, overrides, state }) { + if (path !== '#') { + return + } + + const ownerName = getPreferredPathName({ path, state: { ...state, nameOverrides: overrides } }) + if (!ownerName) { + return + } + + for (const branchPath of unionInfo.branchPaths) { + const branchName = getPreferredPathName({ path: branchPath, state: { ...state, nameOverrides: overrides } }) + const branchSchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: branchPath, state }), + state + }) + + if (!branchName || !branchSchema?.properties) { + continue + } + + for (const propertyName of Object.keys(branchSchema.properties)) { + const propertyPath = `${branchPath}/properties/${propertyName}` + const propertyTypeName = getPreferredPathName({ path: propertyPath, state: { ...state, nameOverrides: overrides } }) + const rootScopedName = `${ownerName}${normalizeTypeName(propertyName)}` + if (!propertyTypeName) { + continue + } + + const propertySchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: propertyPath, state }), + state + }) + + if (propertyTypeName !== rootScopedName && propertyTypeName.startsWith(branchName)) { + for (const childName of Object.keys(propertySchema?.properties || {})) { + applySubtreeNameOverride({ + rootPath: `${propertyPath}/properties/${childName}`, + nextRootName: `${branchName}${normalizeTypeName(childName)}`, + overrides, + state + }) + } + } + + const ownerPrefix = getCollapsedOwnerPrefix({ typeName: propertyTypeName, propertyName }) + if (!ownerPrefix || !shouldCollapseDirectContainerChildren({ propertyName, schema: propertySchema })) { + continue + } + + applyCollapsedContainerChildOverrides({ + containerPath: propertyPath, + containerSchema: propertySchema, + ownerPrefix, + overrides, + state + }) + + for (const childName of Object.keys(propertySchema?.properties || {})) { + const childPath = `${propertyPath}/properties/${childName}` + const childSchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: childPath, state }), + state + }) + const childTypeName = getPreferredPathName({ path: childPath, state: { ...state, nameOverrides: overrides } }) + const childOwnerPrefix = getCollapsedOwnerPrefix({ typeName: childTypeName, propertyName: childName }) + if (!childOwnerPrefix || !shouldCollapseDirectContainerChildren({ propertyName: childName, schema: childSchema })) { + continue + } + + applyCollapsedContainerChildOverrides({ + containerPath: childPath, + containerSchema: childSchema, + ownerPrefix: childOwnerPrefix, + overrides, + state + }) + } + } + } +} + +function applyCollapsedContainerChildOverrides ({ containerPath, containerSchema, ownerPrefix, overrides, state }) { + if (!containerSchema?.properties) { + return + } + + for (const childName of Object.keys(containerSchema.properties)) { + const childPath = `${containerPath}/properties/${childName}` + const childSchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: childPath, state }) || containerSchema.properties[childName], + state + }) + const currentChildName = getPreferredPathName({ path: childPath, state: { ...state, nameOverrides: overrides } }) + const nextChildName = buildCollapsedChildName({ + ownerPrefix, + propertyName: childName, + schema: childSchema, + fallbackName: currentChildName + }) + + if (!nextChildName) { + continue + } + + applySubtreeNameOverride({ + rootPath: childPath, + nextRootName: nextChildName, + overrides, + state + }) + + if (childSchema?.type === 'array' && !Array.isArray(childSchema.items)) { + const itemPath = `${childPath}/items` + const currentItemName = getPreferredPathName({ path: itemPath, state: { ...state, nameOverrides: overrides } }) + const nextItemName = buildCollapsedChildName({ + ownerPrefix, + propertyName: childName, + schema: childSchema.items, + fallbackName: currentItemName ? singularizeName({ name: currentItemName }) : singularizeName({ name: nextChildName }), + singular: true + }) + + applySubtreeNameOverride({ + rootPath: itemPath, + nextRootName: nextItemName, + overrides, + state + }) + } + } +} + +function applySharedUnionBranchPropertyOverrides ({ path, unionInfo, overrides, state }) { + const ownerName = getPreferredPathName({ path, state: { ...state, nameOverrides: overrides } }) + if (!ownerName) { + return + } + + const propertyEntriesByName = new Map() + for (const branchPath of unionInfo.branchPaths) { + const branchSchema = getScannedSchemaAtPath({ path: branchPath, state }) + for (const propertyName of Object.keys(branchSchema?.properties || {})) { + const propertySchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: `${branchPath}/properties/${propertyName}`, state }), + state + }) + + if (!propertySchema || propertySchema.const !== undefined) { + continue + } + + const entries = propertyEntriesByName.get(propertyName) || [] + entries.push({ + path: `${branchPath}/properties/${propertyName}`, + key: getDeclarationSchemaReuseKey({ schema: propertySchema }) + }) + propertyEntriesByName.set(propertyName, entries) + } + } + + for (const [propertyName, entries] of propertyEntriesByName.entries()) { + if (entries.length < 2) { + continue + } + + const uniqueKeys = [...new Set(entries.map(entry => entry.key))] + if (uniqueKeys.length !== 1 || !uniqueKeys[0]) { + continue + } + + for (const entry of entries) { + applySubtreeNameOverride({ + rootPath: entry.path, + nextRootName: `${ownerName}${normalizeTypeName(propertyName)}`, + overrides, + state + }) + } + } +} + +function applyPropertyUnionBranchOverrides ({ path, unionInfo, overrides, state }) { + const ownerName = getPreferredPathName({ path, state: { ...state, nameOverrides: overrides } }) + if (!ownerName) { + return + } + + for (const propertyName of Object.keys(resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path, state }), + state + }).properties || {})) { + const propertyInfo = getObjectUnionPropertyInfo({ path: `${path}/properties/${propertyName}`, state: { ...state, nameOverrides: overrides } }) + if (!propertyInfo) { + continue + } + + const propertyTypeName = getPreferredPathName({ + path: `${path}/properties/${propertyName}`, + state: { ...state, nameOverrides: overrides } + }) + const propertySuffix = propertyTypeName?.startsWith(ownerName) + ? propertyTypeName.slice(ownerName.length) + : normalizeTypeName(propertyName) + + for (const branchPath of unionInfo.branchPaths) { + const branchName = getPreferredPathName({ path: branchPath, state: { ...state, nameOverrides: overrides } }) + if (!branchName || !branchName.endsWith(ownerName)) { + continue + } + + const branchStem = branchName.slice(0, -ownerName.length) + applySubtreeNameOverride({ + rootPath: `${branchPath}/properties/${propertyName}`, + nextRootName: `${ownerName}${branchStem}${propertySuffix}`, + overrides, + state + }) + } + } +} + +function applyNestedUnionBranchOverrides ({ path, unionInfo, overrides, state }) { + if (path === '#' || isTopLevelArrayItemPath({ path })) { + return + } + + const ownerName = getPreferredPathName({ path, state: { ...state, nameOverrides: overrides } }) + const localRootName = state.nameRegistry.getPathName({ path }) + const stripPrefix = getUnionLocalPrefix({ path, localRootName, state }) + if (!ownerName || !stripPrefix) { + return + } + + for (const branchPath of unionInfo.branchPaths) { + const branchName = state.nameRegistry.getPathName({ path: branchPath }) + if (!branchName) { + continue + } + + const suffix = branchName.startsWith(stripPrefix) + ? branchName.slice(stripPrefix.length) + : branchName + + applySubtreeNameOverride({ + rootPath: branchPath, + nextRootName: `${ownerName}${suffix}`, + overrides, + state + }) + } +} + +function applyBranchLeafOverrides ({ path, unionInfo, overrides, state }) { + if (path === '#' || isTopLevelArrayItemPath({ path })) { + return + } + + for (const branchPath of unionInfo.branchPaths) { + const branchName = getPreferredPathName({ path: branchPath, state: { ...state, nameOverrides: overrides } }) + const branchSchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: branchPath, state }), + state + }) + + if (!branchName || !branchSchema?.properties) { + continue + } + + for (const [propertyName, propertySchema] of Object.entries(branchSchema.properties)) { + const propertyPath = `${branchPath}/properties/${propertyName}` + const resolvedPropertySchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: propertyPath, state }) || propertySchema, + state + }) + + if (isNamedScalarArraySchema({ schema: resolvedPropertySchema })) { + const normalizedPropertyName = normalizeTypeName(propertyName) + const normalizedArrayName = branchName.endsWith(normalizedPropertyName) + ? branchName + : null + + if (normalizedArrayName) { + applySubtreeNameOverride({ + rootPath: propertyPath, + nextRootName: normalizedArrayName, + overrides, + state + }) + } + + applySubtreeNameOverride({ + rootPath: `${propertyPath}/items`, + nextRootName: getNestedArrayItemName({ + branchName: normalizedArrayName || branchName, + propertyName + }), + overrides, + state + }) + } + } + } +} + +function applySubtreeNameOverride ({ rootPath, nextRootName, overrides, state }) { + const currentRootName = overrides.get(rootPath) || state.nameRegistry.getPathName({ path: rootPath }) + if (!currentRootName || !nextRootName || currentRootName === nextRootName) { + return + } + + overrides.set(rootPath, nextRootName) + + for (const [path, currentName] of state.nameRegistry.getPathEntries().entries()) { + if (!path.startsWith(`${rootPath}/`)) { + continue + } + + const existingName = overrides.get(path) || currentName + if (existingName.startsWith(currentRootName)) { + overrides.set(path, `${nextRootName}${existingName.slice(currentRootName.length)}`) + } + } +} + +function getObjectUnionPropertyInfo ({ path, state }) { + const match = path.match(/^(.*)\/properties\/([^/]+)$/) + if (!match) { + return null + } + + const [, parentPath, propertyName] = match + const parentSchema = getScannedSchemaAtPath({ path: parentPath, state }) + const unionInfo = getObjectUnionInfo({ path: parentPath, schema: parentSchema, state }) + if (!unionInfo) { + return null + } + + const branchTypeNames = unionInfo.branchPaths + .map(branchPath => getPreferredPathName({ path: `${branchPath}/properties/${propertyName}`, state })) + .filter(Boolean) + + if (branchTypeNames.length !== unionInfo.branchPaths.length) { + return null + } + + return { + propertyName, + branchTypeNames, + branchPaths: unionInfo.branchPaths.map(branchPath => `${branchPath}/properties/${propertyName}`) + } +} diff --git a/packages/massimo-cli/lib/json-schema/declarations/graph.js b/packages/massimo-cli/lib/json-schema/declarations/graph.js new file mode 100644 index 0000000..25fcf4e --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/declarations/graph.js @@ -0,0 +1,700 @@ +import { getCommentLines } from '../comments.js' +import { createRenderContext, buildObjectTypeLines, renderType } from '../render/index.js' +import { + getAliasSourceSchema, + getAliasTargetName, + getScannedSchemaAtPath, + shouldInlineArrayPropertyType, + shouldInlineNamedScalarPropertyType +} from '../core/scanner.js' +import { + getObjectUnionInfo, + getPreferredPathName, + hasObjectMembers, + isReusableScalarSchema, + isUnionBranchPropertyPath, + isValueBranchScalarPath, + omitProperties, + resolveDeclarationSchema, + shouldEmitOmittedUnionPropertyDeclaration, + shouldUseInterface +} from './helpers.js' + +export function buildDeclarationGraph ({ state }) { + const nodes = new Map() + const entryIds = collectDeclarationGraphForPath({ + path: '#', + state, + nodes, + visitedPaths: new Set() + }) + + return { + nodes, + entryIds + } +} + +export function renderDeclarationGraph ({ graph }) { + const declarations = [] + const emittedIds = new Set() + + for (const entryId of graph.entryIds) { + emitDeclarationNode({ + id: entryId, + graph, + declarations, + emittedIds + }) + } + + return declarations +} + +function buildDeclaration ({ path, name, schema, state, inlineConstPathNames = false }) { + const unionPropertyDeclaration = buildObjectUnionPropertyDeclaration({ path, name, schema, state }) + if (unionPropertyDeclaration) { + return unionPropertyDeclaration + } + + const resolvedSchema = resolveDeclarationSchema({ schema, state }) + const aliasTargetName = getAliasTargetName({ path, state }) + const structuralAliasTargetName = !aliasTargetName + ? getStructuralAliasTargetName({ path, schema: resolvedSchema, state }) + : null + + if (aliasTargetName || structuralAliasTargetName) { + const aliasSourceSchema = getAliasSourceSchema({ path, state }) + const commentSchema = getCommentLines({ schema: aliasSourceSchema, name }) + ? aliasSourceSchema + : getCommentLines({ schema, name }) + ? schema + : resolvedSchema + return { + kind: 'type', + name, + comment: getCommentLines({ schema: commentSchema, name }), + value: aliasTargetName || structuralAliasTargetName, + skipDependencies: true + } + } + + const context = createRenderContext({ + schema: resolvedSchema, + state, + path, + lookupPathName: false, + lookupChildPathNames: true, + inlineConstPathNames + }) + + if (shouldUseInterface({ schema: resolvedSchema })) { + return { + kind: 'interface', + name, + extends: [], + comment: getCommentLines({ schema: resolvedSchema, name }), + bodyLines: buildObjectTypeLines({ context, renderType }) + } + } + + return { + kind: 'type', + name, + comment: getCommentLines({ schema: resolvedSchema, name }), + value: renderType({ context }) + } +} + +function collectDeclarationGraphForPath ({ path, state, nodes, visitedPaths }) { + if (visitedPaths.has(path) || !state.nameRegistry.hasPathName({ path })) { + return [] + } + + visitedPaths.add(path) + + const schema = getScannedSchemaAtPath({ path, state }) + if (!schema) { + return [] + } + + const unionInfo = getObjectUnionInfo({ path, schema, state }) + if (unionInfo) { + return collectObjectUnionGraphNodes({ + path, + schema, + state, + nodes, + visitedPaths, + unionInfo + }) + } + + const declaration = buildDeclaration({ + path, + name: getPreferredPathName({ path, state }), + schema, + state + }) + const dependencyPaths = declaration.skipDependencies + ? [] + : (declaration.dependencyPaths || collectDependencyPaths({ schema, path, state })) + + for (const dependencyPath of dependencyPaths) { + collectDeclarationGraphForPath({ + path: dependencyPath, + state, + nodes, + visitedPaths + }) + } + + nodes.set(path, createDeclarationNode({ + id: path, + path, + declaration, + dependencyIds: dependencyPaths + .map(dependencyPath => getDeclarationNodeIdForPath({ path: dependencyPath, state })) + .filter(Boolean) + })) + + return [path] +} + +function collectObjectUnionGraphNodes ({ path, schema, state, nodes, visitedPaths, unionInfo }) { + const unionName = getPreferredPathName({ path, state }) || unionInfo.name + const omittedPropertyNames = getOmittedBasePropertyNames({ path, schema, state, unionInfo }) + const resolvedSchema = resolveDeclarationSchema({ schema, state }) + const baseSchema = { + ...resolvedSchema, + properties: omitProperties({ + properties: resolvedSchema.properties, + omittedPropertyNames + }), + required: (resolvedSchema.required || []) + .filter(propertyName => !omittedPropertyNames.has(propertyName)), + oneOf: undefined, + anyOf: undefined, + allOf: undefined + } + + if (unionInfo.branchPaths.length === 1) { + const branchPath = unionInfo.branchPaths[0] + const branchSchema = getScannedSchemaAtPath({ path: branchPath, state }) + if (branchSchema) { + const mergedSchema = mergeObjectUnionSchemas({ + baseSchema, + branchSchema: mergeUnionBranchSchema({ + path, + branchPath, + branchSchema, + state + }) + }) + + const dependencyPaths = collectDependencyPaths({ schema: mergedSchema, path, state }) + for (const dependencyPath of dependencyPaths) { + collectDeclarationGraphForPath({ + path: dependencyPath, + state, + nodes, + visitedPaths + }) + } + + nodes.set(path, createDeclarationNode({ + id: path, + path, + declaration: buildDeclaration({ + path, + name: unionName, + schema: mergedSchema, + state + }), + dependencyIds: dependencyPaths + .map(dependencyPath => getDeclarationNodeIdForPath({ path: dependencyPath, state })) + .filter(Boolean) + })) + + return [path] + } + } + + const hasBaseProperties = Object.keys(baseSchema.properties || {}).length > 0 + const entryIds = [] + const baseId = `${path}::base` + const baseDeclaration = hasBaseProperties + ? { + kind: 'interface', + name: unionInfo.baseName, + extends: [], + comment: null, + bodyLines: buildObjectTypeLines({ + context: createRenderContext({ + schema: baseSchema, + state, + path, + lookupPathName: false, + lookupChildPathNames: true + }), + renderType + }) + } + : null + + if (baseDeclaration && path === '#') { + nodes.set(baseId, createDeclarationNode({ + id: baseId, + path, + declaration: baseDeclaration, + dependencyIds: collectDependencyPaths({ schema: baseSchema, path, state }) + .map(dependencyPath => getDeclarationNodeIdForPath({ path: dependencyPath, state })) + .filter(Boolean) + })) + entryIds.push(baseId) + } + + const omittedEntryIds = collectObjectUnionBaseEntryIds({ + path, + schema: resolvedSchema, + omittedPropertyNames, + state, + nodes, + visitedPaths + }) + entryIds.push(...omittedEntryIds) + + if (baseDeclaration && path !== '#') { + nodes.set(baseId, createDeclarationNode({ + id: baseId, + path, + declaration: baseDeclaration, + dependencyIds: collectDependencyPaths({ schema: baseSchema, path, state }) + .map(dependencyPath => getDeclarationNodeIdForPath({ path: dependencyPath, state })) + .filter(Boolean) + })) + entryIds.push(baseId) + } + + const branchNames = [] + const branchIds = [] + for (const branchPath of unionInfo.branchPaths) { + const branchName = getPreferredPathName({ path: branchPath, state }) + const branchSchema = getScannedSchemaAtPath({ path: branchPath, state }) + if (!branchName || !branchSchema) { + continue + } + + branchNames.push(branchName) + const mergedBranchSchema = mergeUnionBranchSchema({ + path, + branchPath, + branchSchema, + state + }) + + const branchDependencyPaths = collectDependencyPaths({ schema: branchSchema, path: branchPath, state }) + for (const dependencyPath of branchDependencyPaths) { + collectDeclarationGraphForPath({ + path: dependencyPath, + state, + nodes, + visitedPaths + }) + } + + nodes.set(branchPath, createDeclarationNode({ + id: branchPath, + path: branchPath, + declaration: { + kind: 'interface', + name: branchName, + extends: hasBaseProperties ? [unionInfo.baseName] : [], + comment: null, + bodyLines: buildObjectTypeLines({ + context: createRenderContext({ + schema: mergedBranchSchema, + state, + path: branchPath, + lookupPathName: false, + lookupChildPathNames: true, + inlineConstPathNames: true + }), + renderType + }) + }, + dependencyIds: [ + ...(hasBaseProperties ? [baseId] : []), + ...branchDependencyPaths + .map(dependencyPath => getDeclarationNodeIdForPath({ path: dependencyPath, state })) + .filter(Boolean) + ] + })) + branchIds.push(branchPath) + entryIds.push(branchPath) + } + + nodes.set(path, createDeclarationNode({ + id: path, + path, + declaration: { + kind: 'type', + name: unionName, + comment: getCommentLines({ schema: resolvedSchema, name: unionName }), + value: branchNames.join(' | ') + }, + dependencyIds: [...omittedEntryIds, ...branchIds] + })) + entryIds.push(path) + + return entryIds +} + +function collectObjectUnionBaseEntryIds ({ + path, + schema, + omittedPropertyNames, + state, + nodes, + visitedPaths +}) { + const entryIds = [] + const hasBaseProperties = Object.keys( + omitProperties({ + properties: schema.properties, + omittedPropertyNames + }) || {} + ).length > 0 + + for (const propertyName of Object.keys(schema.properties || {})) { + const propertyPath = `${path}/properties/${propertyName}` + const propertySchema = schema.properties[propertyName] + + if (omittedPropertyNames.has(propertyName)) { + if (!shouldEmitOmittedUnionPropertyDeclaration({ path, propertyName, hasBaseProperties })) { + continue + } + + entryIds.push(...collectDeclarationGraphForPath({ + path: propertyPath, + state, + nodes, + visitedPaths + })) + continue + } + + if (shouldInlineArrayPropertyType({ path: propertyPath, schema: propertySchema, state })) { + const itemPath = `${propertyPath}/items` + if (state.nameRegistry.hasPathName({ path: itemPath })) { + entryIds.push(...collectDeclarationGraphForPath({ + path: itemPath, + state, + nodes, + visitedPaths + })) + } + continue + } + + if (shouldInlineNamedScalarPropertyType({ path: propertyPath, schema: propertySchema, state })) { + continue + } + + if (propertySchema?.const !== undefined && isUnionBranchPropertyPath({ path: propertyPath })) { + continue + } + + if (!state.nameRegistry.hasPathName({ path: propertyPath })) { + continue + } + + entryIds.push(...collectDeclarationGraphForPath({ + path: propertyPath, + state, + nodes, + visitedPaths + })) + } + + return entryIds +} + +function createDeclarationNode ({ id, path, declaration, dependencyIds }) { + return { + id, + path, + declaration, + dependencyIds: [...new Set(dependencyIds)].filter(dependencyId => dependencyId !== id) + } +} + +function emitDeclarationNode ({ id, graph, declarations, emittedIds }) { + if (emittedIds.has(id)) { + return + } + + const node = graph.nodes.get(id) + if (!node) { + return + } + + emittedIds.add(id) + declarations.push(node.declaration) + + for (const dependencyId of node.dependencyIds) { + emitDeclarationNode({ + id: dependencyId, + graph, + declarations, + emittedIds + }) + } +} + +function getDeclarationNodeIdForPath ({ path, state }) { + const schema = getScannedSchemaAtPath({ path, state }) + if (!schema) { + return null + } + + const unionInfo = getObjectUnionInfo({ path, schema, state }) + if (!unionInfo) { + return path + } + + return path +} + +function getStructuralAliasTargetName ({ path, schema, state }) { + if (!isReusableScalarSchema({ schema }) || !isValueBranchScalarPath({ path })) { + return null + } + + return state.structuralAliasTargets?.get(path) || null +} + +function collectDependencyPaths ({ schema, path, state }) { + const resolvedSchema = resolveDeclarationSchema({ schema, state }) + const dependencyPaths = [] + + collectObjectDependencyPaths({ schema: resolvedSchema, path, state, dependencyPaths }) + collectArrayDependencyPaths({ schema: resolvedSchema, path, state, dependencyPaths }) + collectCombinatorDependencyPaths({ schema: resolvedSchema, path, state, dependencyPaths }) + + return [...new Set(dependencyPaths)].filter(dependencyPath => dependencyPath !== path) +} + +function collectObjectDependencyPaths ({ schema, path, state, dependencyPaths }) { + if (!hasObjectMembers({ schema })) { + return + } + + for (const propertyName of Object.keys(schema.properties || {})) { + const propertyPath = `${path}/properties/${propertyName}` + const propertySchema = schema.properties[propertyName] + if (shouldInlineArrayPropertyType({ path: propertyPath, schema: propertySchema, state })) { + const itemPath = `${propertyPath}/items` + if (state.nameRegistry.hasPathName({ path: itemPath })) { + dependencyPaths.push(itemPath) + } + continue + } + + if (shouldInlineNamedScalarPropertyType({ path: propertyPath, schema: propertySchema, state })) { + continue + } + + if (propertySchema?.const !== undefined && isUnionBranchPropertyPath({ path: propertyPath })) { + continue + } + + if (state.nameRegistry.hasPathName({ path: propertyPath })) { + dependencyPaths.push(propertyPath) + } + } +} + +function collectArrayDependencyPaths ({ schema, path, state, dependencyPaths }) { + if (!schema.items) { + return + } + + if (Array.isArray(schema.items)) { + for (const index of schema.items.keys()) { + const itemPath = `${path}/items/${index}` + if (state.nameRegistry.hasPathName({ path: itemPath })) { + dependencyPaths.push(itemPath) + } + } + + return + } + + const itemPath = `${path}/items` + if (state.nameRegistry.hasPathName({ path: itemPath })) { + dependencyPaths.push(itemPath) + } +} + +function collectCombinatorDependencyPaths ({ schema, path, state, dependencyPaths }) { + for (const keyword of ['oneOf', 'anyOf', 'allOf']) { + if (!Array.isArray(schema[keyword])) { + continue + } + + for (const [index] of schema[keyword].entries()) { + const memberPath = `${path}/${keyword}/${index}` + const memberSchema = getScannedSchemaAtPath({ path: memberPath, state }) + if (memberSchema && renderDeclarationValue({ path: memberPath, schema: memberSchema, state }) === 'unknown') { + continue + } + + if (state.nameRegistry.hasPathName({ path: memberPath })) { + dependencyPaths.push(memberPath) + } + } + } +} + +function buildObjectUnionPropertyDeclaration ({ path, name, schema, state }) { + const propertyInfo = getObjectUnionPropertyInfo({ path, state }) + if (!propertyInfo) { + return null + } + + const resolvedSchema = resolveDeclarationSchema({ schema, state }) + if (renderDeclarationValue({ path, schema: resolvedSchema, state }) !== 'unknown') { + return null + } + + return { + kind: 'type', + name, + comment: getCommentLines({ schema: resolvedSchema, name }), + value: propertyInfo.branchTypeNames.join(' | '), + dependencyPaths: propertyInfo.branchPaths + } +} + +function getObjectUnionPropertyInfo ({ path, state }) { + const match = path.match(/^(.*)\/properties\/([^/]+)$/) + if (!match) { + return null + } + + const [, parentPath, propertyName] = match + const parentSchema = getScannedSchemaAtPath({ path: parentPath, state }) + const unionInfo = getObjectUnionInfo({ path: parentPath, schema: parentSchema, state }) + if (!unionInfo) { + return null + } + + const branchTypeNames = unionInfo.branchPaths + .map(branchPath => getPreferredPathName({ path: `${branchPath}/properties/${propertyName}`, state })) + .filter(Boolean) + + if (branchTypeNames.length !== unionInfo.branchPaths.length) { + return null + } + + return { + propertyName, + branchTypeNames, + branchPaths: unionInfo.branchPaths.map(branchPath => `${branchPath}/properties/${propertyName}`) + } +} + +function getOmittedBasePropertyNames ({ path, schema, state, unionInfo }) { + const resolvedSchema = resolveDeclarationSchema({ schema, state }) + const propertyNames = Object.keys(resolvedSchema.properties || {}) + const omitted = new Set() + + for (const propertyName of propertyNames) { + const propertyPath = `${path}/properties/${propertyName}` + const propertyInfo = getObjectUnionPropertyInfo({ path: propertyPath, state }) + if (!propertyInfo) { + continue + } + + const propertySchema = resolveDeclarationSchema({ + schema: getScannedSchemaAtPath({ path: propertyPath, state }), + state + }) + + if (shouldOmitObjectUnionBaseProperty({ + path, + propertyName, + propertySchema, + state, + unionInfo + }) || renderDeclarationValue({ + path: propertyPath, + schema: propertySchema, + state + }) === 'unknown') { + omitted.add(propertyName) + } + } + + return omitted +} + +function mergeUnionBranchSchema ({ path, branchPath, branchSchema, state }) { + const parentSchema = getScannedSchemaAtPath({ path, state }) + const parentRequired = new Set(parentSchema?.required || []) + const branchRequired = new Set(branchSchema.required || []) + + for (const propertyName of Object.keys(branchSchema.properties || {})) { + if (parentRequired.has(propertyName)) { + branchRequired.add(propertyName) + } + } + + return { + ...branchSchema, + required: [...branchRequired] + } +} + +function mergeObjectUnionSchemas ({ baseSchema, branchSchema }) { + return { + ...baseSchema, + ...branchSchema, + properties: { + ...(baseSchema.properties || {}), + ...(branchSchema.properties || {}) + }, + required: [...new Set([...(baseSchema.required || []), ...(branchSchema.required || [])])], + oneOf: undefined, + anyOf: undefined, + allOf: undefined + } +} + +function shouldOmitObjectUnionBaseProperty ({ path, propertyName, propertySchema, state, unionInfo }) { + if (path === '#' || propertySchema?.const !== undefined) { + return false + } + + return unionInfo.branchPaths.every(branchPath => { + const branchPropertySchema = getScannedSchemaAtPath({ + path: `${branchPath}/properties/${propertyName}`, + state + }) + + return branchPropertySchema?.const !== undefined + }) +} + +function renderDeclarationValue ({ path, schema, state }) { + return renderType({ + context: createRenderContext({ + schema, + state, + path, + lookupPathName: false, + lookupChildPathNames: true + }) + }) +} diff --git a/packages/massimo-cli/lib/json-schema/declarations/helpers.js b/packages/massimo-cli/lib/json-schema/declarations/helpers.js new file mode 100644 index 0000000..2aabb17 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/declarations/helpers.js @@ -0,0 +1,345 @@ +import { normalizeTypeName } from '../core/naming.js' +import { getSchemaAtPath, toRefPath } from '../core/pointer.js' +import { getScannedSchemaAtPath } from '../core/scanner.js' +import { isOpenObjectSchema, isRecordObjectSchema } from '../render/object.js' + +export function getPreferredPathName ({ path, state }) { + return state.nameOverrides?.get(path) || state.nameRegistry.getPathName({ path }) || null +} + +export function compareCanonicalPaths (left, right) { + return left.length - right.length || left.localeCompare(right) +} + +export function resolveDeclarationSchema ({ schema, state }) { + let currentSchema = schema + const visitedRefs = new Set() + + while (currentSchema?.$ref) { + const refPath = toRefPath(currentSchema.$ref) + if (visitedRefs.has(refPath)) { + break + } + + visitedRefs.add(refPath) + currentSchema = getSchemaAtPath({ schema: state.rootSchema, path: refPath }) + } + + return currentSchema || schema +} + +export function shouldUseInterface ({ schema }) { + return hasObjectMembers({ schema }) && + !hasCombinator({ schema }) && + !isRecordObjectSchema({ schema }) && + !isOpenObjectSchema({ schema }) +} + +export function hasObjectMembers ({ schema }) { + return schema.type === 'object' || schema.properties || schema.additionalProperties !== undefined || schema.patternProperties +} + +export function hasCombinator ({ schema }) { + return Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.allOf) +} + +export function getDeclarationOwnerScopePath ({ path }) { + const valueMatch = path.match(/^(.*\/properties\/value)\/properties\/[^/]+$/) + if (valueMatch) { + return normalizeUnionBranchScopePath({ path: valueMatch[1] }) + } + + const branchMatch = path.match(/^(.*\/(?:oneOf|anyOf)\/\d+)\/properties\/[^/]+$/) + if (branchMatch) { + return normalizeUnionBranchScopePath({ path: branchMatch[1] }) + } + + return path.replace(/\/properties\/[^/]+$/, '') +} + +export function normalizeUnionBranchScopePath ({ path }) { + return path.replace(/\/(oneOf|anyOf)\/\d+/g, '/$1/*') +} + +export function getObjectUnionInfo ({ path, schema, state }) { + const resolvedSchema = resolveDeclarationSchema({ schema, state }) + const members = resolvedSchema.oneOf || resolvedSchema.anyOf + if (!Array.isArray(members) || members.length === 0) { + return null + } + + const branchPaths = members.map((_, index) => `${path}/${resolvedSchema.oneOf ? 'oneOf' : 'anyOf'}/${index}`) + if (!branchPaths.every(branchPath => state.nameRegistry.hasPathName({ path: branchPath }))) { + return null + } + + const branchSchemas = branchPaths + .map(branchPath => getScannedSchemaAtPath({ path: branchPath, state })) + .filter(Boolean) + + if (!hasObjectMembers({ schema: resolvedSchema }) && !branchSchemas.every(branchSchema => hasObjectMembers({ schema: branchSchema }))) { + return null + } + + return { + name: state.nameRegistry.getPathName({ path }), + baseName: `Base${state.nameRegistry.getPathName({ path })}`, + branchPaths + } +} + +export function isUnionBranchPropertyPath ({ path }) { + return /\/(?:oneOf|anyOf)\/\d+\/properties\/[^/]+$/.test(path) +} + +export function isNamedArrayPath ({ path, state, schema }) { + if (!state.nameRegistry.hasPathName({ path }) || !schema?.items || Array.isArray(schema.items)) { + return false + } + + return state.nameRegistry.hasPathName({ path: `${path}/items` }) +} + +export function singularizeName ({ name }) { + if (!name) { + return name + } + + if (name.endsWith('ies')) { + return `${name.slice(0, -3)}y` + } + + if (name.endsWith('ses')) { + return name.slice(0, -2) + } + + if (name.endsWith('s') && name.length > 1) { + return name.slice(0, -1) + } + + return name +} + +export function getUnionLocalPrefix ({ path, localRootName, state }) { + if (isItemPath(path)) { + return localRootName + } + + const propertyName = getPropertyNameFromPath({ path }) + if (propertyName) { + return normalizeTypeName(propertyName) + } + + return localRootName +} + +export function getPropertyNameFromPath ({ path }) { + const match = path.match(/\/properties\/([^/]+)$/) + return match ? match[1] : null +} + +export function isItemPath (path) { + return /\/items$/.test(path) +} + +export function isTopLevelArrayItemPath ({ path }) { + return /^#\/properties\/[^/]+\/items$/.test(path) +} + +export function shouldEmitOmittedUnionPropertyDeclaration ({ path, propertyName, hasBaseProperties = false }) { + if (propertyName !== 'type') { + return true + } + + return path === '#' || (!hasBaseProperties && isTopLevelArrayItemPath({ path })) +} + +export function getDeclarationSchemaReuseKey ({ schema }) { + if (!schema || typeof schema !== 'object' || schema.$ref) { + return null + } + + return JSON.stringify(simplifyDeclarationSchema({ schema })) +} + +export function isScalarSchema ({ schema }) { + if (!schema || typeof schema !== 'object') { + return false + } + + if (schema.const !== undefined || Array.isArray(schema.enum)) { + return true + } + + return ['string', 'integer', 'number', 'boolean', 'null'].includes(schema.type) +} + +export function isNamedScalarArraySchema ({ schema }) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema.items)) { + return false + } + + return schema.type === 'array' && isScalarSchema({ schema: schema.items }) +} + +export function isReusableScalarSchema ({ schema }) { + return Array.isArray(schema?.enum) +} + +export function isValueBranchScalarPath ({ path }) { + return /\/(?:oneOf|anyOf)\/\d+\/properties\/value\/properties\/[^/]+$/.test(path) +} + +export function getNestedArrayItemName ({ branchName, propertyName }) { + const propertyTypeName = normalizeTypeName(propertyName) + + if (branchName.endsWith(propertyTypeName)) { + return singularizeName({ name: branchName }) + } + + return `${branchName}${singularizeName({ name: propertyTypeName })}` +} + +export function shouldUseRootUnionPropertyName ({ entries }) { + if (entries.length === 0) { + return false + } + + if (!entries.every(entry => hasObjectMembers({ schema: entry.schema }))) { + return false + } + + const uniqueKeys = [...new Set(entries.map(entry => entry.key).filter(Boolean))] + if (uniqueKeys.length === 1 && entries.length > 1) { + return true + } + + return entries.every(entry => !entry.schema?.title && !entry.schema?.description) +} + +export function simplifyDeclarationSchema ({ schema }) { + if (!schema || typeof schema !== 'object') { + return schema + } + + const simplified = {} + + for (const key of ['title', 'description', 'type', 'format', 'pattern', 'minimum', 'const']) { + if (schema[key] !== undefined) { + simplified[key] = schema[key] + } + } + + if (Array.isArray(schema.enum)) { + simplified.enum = [...schema.enum] + } + + if (Array.isArray(schema.required)) { + simplified.required = [...schema.required] + } + + if (schema.properties && typeof schema.properties === 'object') { + simplified.properties = Object.keys(schema.properties).sort().reduce((acc, key) => { + acc[key] = simplifyDeclarationSchema({ schema: schema.properties[key] }) + return acc + }, {}) + } + + if (schema.items) { + simplified.items = Array.isArray(schema.items) + ? schema.items.map(item => simplifyDeclarationSchema({ schema: item })) + : simplifyDeclarationSchema({ schema: schema.items }) + } + + if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { + simplified.additionalProperties = simplifyDeclarationSchema({ schema: schema.additionalProperties }) + } else if (schema.additionalProperties !== undefined) { + simplified.additionalProperties = schema.additionalProperties + } + + if (schema.patternProperties && typeof schema.patternProperties === 'object') { + simplified.patternProperties = Object.keys(schema.patternProperties).sort().reduce((acc, key) => { + acc[key] = simplifyDeclarationSchema({ schema: schema.patternProperties[key] }) + return acc + }, {}) + } + + for (const key of ['oneOf', 'anyOf', 'allOf']) { + if (Array.isArray(schema[key])) { + simplified[key] = schema[key].map(member => simplifyDeclarationSchema({ schema: member })) + } + } + + return simplified +} + +export function getCollapsedOwnerPrefix ({ typeName, propertyName }) { + if (!typeName || !propertyName) { + return null + } + + const propertyTypeName = normalizeTypeName(propertyName) + if (typeName.endsWith(propertyTypeName)) { + const prefix = typeName.slice(0, -propertyTypeName.length) + return prefix || typeName + } + + return typeName +} + +export function shouldCollapseDirectContainerChildren ({ propertyName, schema }) { + if (!schema?.properties || schema.type !== 'object') { + return false + } + + if (isContainerPropertyName({ propertyName })) { + return true + } + + return Object.values(schema.properties).some(propertySchema => { + return hasObjectMembers({ schema: propertySchema }) || + (propertySchema?.type === 'array' && !Array.isArray(propertySchema.items)) + }) +} + +export function buildCollapsedChildName ({ ownerPrefix, propertyName, schema, fallbackName, singular = false }) { + if (!ownerPrefix || !propertyName) { + return fallbackName || null + } + + let suffix = getCollapsedChildSuffix({ ownerPrefix, propertyName, schema }) + if (singular) { + suffix = singularizeName({ name: suffix }) + } + + return `${ownerPrefix}${suffix}` || fallbackName || null +} + +export function getCollapsedChildSuffix ({ ownerPrefix, propertyName, schema }) { + let suffix = normalizeTypeName(propertyName) + if (hasObjectMembers({ schema }) && suffix.startsWith('Default') && suffix.length > 'Default'.length) { + suffix = suffix.slice('Default'.length) + } + + const ownerTail = ownerPrefix.split(/(?=[A-Z])/).at(-1) + if (ownerTail && suffix.startsWith(ownerTail) && suffix.length > ownerTail.length) { + suffix = suffix.slice(ownerTail.length) + } + + return suffix +} + +export function isContainerPropertyName ({ propertyName }) { + const normalizedName = normalizeTypeName(propertyName || '') + return normalizedName.endsWith('Config') || normalizedName.endsWith('Category') +} + +export function omitProperties ({ properties, omittedPropertyNames }) { + if (!properties) { + return properties + } + + return Object.fromEntries( + Object.entries(properties).filter(([propertyName]) => !omittedPropertyNames.has(propertyName)) + ) +} diff --git a/packages/massimo-cli/lib/json-schema/declarations/index.js b/packages/massimo-cli/lib/json-schema/declarations/index.js new file mode 100644 index 0000000..dc33434 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/declarations/index.js @@ -0,0 +1,48 @@ +import { renderCommentBlock } from '../comments.js' +import { canonicalizeDeclarationState } from './canonicalize.js' +import { buildDeclarationGraph, renderDeclarationGraph } from './graph.js' +import { validateDeclarationGraph } from './validate.js' + +export { canonicalizeDeclarationState } + +export function buildDeclarations ({ state }) { + const canonicalState = state.nameOverrides && state.structuralAliasTargets + ? state + : canonicalizeDeclarationState({ state }) + const graph = buildDeclarationGraph({ state: canonicalState }) + + validateDeclarationGraph({ graph }) + + return renderDeclarationGraph({ graph }) +} + +export function renderDeclarations ({ declarations, rootName }) { + const lines = [] + + for (const [index, declaration] of declarations.entries()) { + if (declaration.comment) { + lines.push(...renderCommentBlock({ lines: declaration.comment })) + } + + if (declaration.kind === 'interface') { + const extendsClause = declaration.extends?.length > 0 + ? ` extends ${declaration.extends.join(', ')}` + : '' + lines.push(`interface ${declaration.name}${extendsClause} {`) + lines.push(...declaration.bodyLines) + lines.push('}') + } else { + lines.push(`type ${declaration.name} = ${declaration.value};`) + } + + const nextDeclaration = declarations[index + 1] + if (nextDeclaration && declaration.kind === 'interface' && !nextDeclaration.comment) { + lines.push('') + } + } + + lines.push('') + lines.push(`export { ${rootName} };`) + + return `${lines.join('\n')}\n` +} diff --git a/packages/massimo-cli/lib/json-schema/declarations/validate.js b/packages/massimo-cli/lib/json-schema/declarations/validate.js new file mode 100644 index 0000000..b68d31b --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/declarations/validate.js @@ -0,0 +1,68 @@ +export function validateDeclarationGraph ({ graph }) { + const nameSignatures = new Map() + + for (const node of graph.nodes.values()) { + for (const dependencyId of node.dependencyIds) { + if (!graph.nodes.has(dependencyId)) { + throw new Error(`Missing declaration dependency: ${dependencyId}`) + } + } + + const signature = getDeclarationSignature({ declaration: node.declaration }) + const existingSignature = nameSignatures.get(node.declaration.name) + if (existingSignature && existingSignature !== signature) { + throw new Error(`Conflicting declaration emitted for ${node.declaration.name}`) + } + nameSignatures.set(node.declaration.name, signature) + + if (isSelfReferentialAliasDeclaration({ declaration: node.declaration })) { + throw new Error(`Invalid self-referential alias emitted for ${node.declaration.name}`) + } + } + + validateDeclarationGraphCycles({ graph }) +} + +function validateDeclarationGraphCycles ({ graph }) { + const visiting = new Set() + const visited = new Set() + + function visit (id) { + if (visited.has(id)) { + return + } + + if (visiting.has(id)) { + throw new Error(`Declaration cycle detected at ${id}`) + } + + visiting.add(id) + const node = graph.nodes.get(id) + for (const dependencyId of node?.dependencyIds || []) { + visit(dependencyId) + } + visiting.delete(id) + visited.add(id) + } + + for (const id of graph.nodes.keys()) { + visit(id) + } +} + +function getDeclarationSignature ({ declaration }) { + return JSON.stringify({ + ...declaration, + dependencyPaths: undefined, + skipDependencies: undefined + }) +} + +function isSelfReferentialAliasDeclaration ({ declaration }) { + if (!declaration || declaration.kind !== 'type') { + return false + } + + const escapedName = declaration.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`\\b${escapedName}\\b`).test(declaration.value) +} diff --git a/packages/massimo-cli/lib/json-schema/generator.js b/packages/massimo-cli/lib/json-schema/generator.js index aab507e..29beb0d 100644 --- a/packages/massimo-cli/lib/json-schema/generator.js +++ b/packages/massimo-cli/lib/json-schema/generator.js @@ -1,14 +1,19 @@ -import { buildDeclarations, renderDeclarations } from './declarations.js' -import { scanJSONSchema } from './scanner.js' +import { + buildDeclarations, + canonicalizeDeclarationState, + renderDeclarations +} from './declarations/index.js' +import { scanJSONSchema } from './core/index.js' export function generateJSONSchemaTypes ({ schema, rootName }) { - const state = scanJSONSchema({ schema, rootName }) - const declarations = buildDeclarations({ state }) + const scannedState = scanJSONSchema({ schema, rootName }) + const canonicalState = canonicalizeDeclarationState({ state: scannedState }) + const declarations = buildDeclarations({ state: canonicalState }) return { types: renderDeclarations({ declarations, - rootName: state.rootName + rootName: canonicalState.rootName }) } } diff --git a/packages/massimo-cli/lib/json-schema/naming.js b/packages/massimo-cli/lib/json-schema/naming.js deleted file mode 100644 index 7b29630..0000000 --- a/packages/massimo-cli/lib/json-schema/naming.js +++ /dev/null @@ -1,13 +0,0 @@ -import { capitalize, toJavaScriptName } from '../utils.js' - -export function getDefaultRootName ({ schema, rootName }) { - return normalizeTypeName(rootName || schema?.title || 'Schema') -} - -export function normalizeTypeName (value) { - return capitalize(toJavaScriptName(String(value || 'Schema'))) -} - -export function joinTypeName ({ prefix = '', suffix = '' }) { - return normalizeTypeName(`${prefix}${normalizeTypeName(suffix)}`) -} diff --git a/packages/massimo-cli/lib/json-schema/object.js b/packages/massimo-cli/lib/json-schema/object.js deleted file mode 100644 index 17ca44d..0000000 --- a/packages/massimo-cli/lib/json-schema/object.js +++ /dev/null @@ -1,98 +0,0 @@ -import { createChildRenderContext } from './render-context.js' - -export function renderObjectType ({ context, renderType }) { - const lines = buildObjectTypeLines({ context, renderType }) - - if (lines.length === 0) { - return '{}' - } - - return `{ -${lines.join('\n')} -}` -} - -export function buildObjectTypeLines ({ context, renderType }) { - const propertyLines = renderPropertyLines({ context, renderType }) - const indexLines = renderIndexSignatureLines({ context, renderType }) - return [...propertyLines, ...indexLines] -} - -function renderPropertyLines ({ context, renderType }) { - if (!isSchemaObject(context.schema.properties)) { - return [] - } - - const requiredProperties = new Set(context.schema.required || []) - - return Object.entries(context.schema.properties).map(([propertyName, propertySchema]) => { - const propertyType = renderType({ - context: createChildRenderContext({ - context, - schema: propertySchema, - pathSuffix: `properties/${propertyName}` - }) - }) - - return ` ${renderPropertyKey({ propertyName })}${requiredProperties.has(propertyName) ? '' : '?'}: ${propertyType};` - }) -} - -function renderIndexSignatureLines ({ context, renderType }) { - const lines = [] - - if (context.schema.additionalProperties === true) { - lines.push(' [key: string]: unknown;') - } - - if (isSchemaObject(context.schema.additionalProperties)) { - const valueType = renderType({ - context: createChildRenderContext({ - context, - schema: context.schema.additionalProperties, - pathSuffix: 'additionalProperties' - }) - }) - - lines.push(` [key: string]: ${valueType};`) - } - - if (isSchemaObject(context.schema.patternProperties)) { - const patternTypes = Object.entries(context.schema.patternProperties).map(([pattern, patternSchema]) => { - return renderType({ - context: createChildRenderContext({ - context, - schema: patternSchema, - pathSuffix: `patternProperties/${pattern}`, - lookupPathName: false - }) - }) - }) - - if (patternTypes.length > 0) { - lines.push(` [key: string]: ${joinUniqueTypes({ types: patternTypes })};`) - } - } - - return dedupeLines({ lines }) -} - -function joinUniqueTypes ({ types }) { - return [...new Set(types)].join(' | ') -} - -function dedupeLines ({ lines }) { - return [...new Set(lines)] -} - -function renderPropertyKey ({ propertyName }) { - if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propertyName)) { - return propertyName - } - - return JSON.stringify(propertyName) -} - -function isSchemaObject (value) { - return value !== null && typeof value === 'object' -} diff --git a/packages/massimo-cli/lib/json-schema/array.js b/packages/massimo-cli/lib/json-schema/render/array.js similarity index 100% rename from packages/massimo-cli/lib/json-schema/array.js rename to packages/massimo-cli/lib/json-schema/render/array.js diff --git a/packages/massimo-cli/lib/json-schema/render/index.js b/packages/massimo-cli/lib/json-schema/render/index.js new file mode 100644 index 0000000..acafd61 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/render/index.js @@ -0,0 +1,7 @@ +export * from './array.js' +export * from './object.js' +export * from './primitive.js' +export * from './reference.js' +export * from './render-context.js' +export * from './render-type.js' +export * from './union.js' diff --git a/packages/massimo-cli/lib/json-schema/render/object.js b/packages/massimo-cli/lib/json-schema/render/object.js new file mode 100644 index 0000000..2f9f7a1 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/render/object.js @@ -0,0 +1,173 @@ +import { getCommentLines, renderCommentBlock } from '../comments.js' +import { createChildRenderContext } from './render-context.js' +import { shouldInlineNamedScalarPropertyType } from '../core/scanner.js' + +export function renderObjectType ({ context, renderType }) { + if (isRecordObjectSchema({ schema: context.schema })) { + return renderRecordType({ context, renderType }) + } + + if (isOpenObjectSchema({ schema: context.schema })) { + return 'Record' + } + + const lines = buildObjectTypeLines({ context, renderType }) + + if (lines.length === 0) { + return '{}' + } + + return `{ +${lines.join('\n')} +}` +} + +export function isRecordObjectSchema ({ schema }) { + const hasProperties = isSchemaObject(schema.properties) && Object.keys(schema.properties).length > 0 + const hasPatternProperties = isSchemaObject(schema.patternProperties) && Object.keys(schema.patternProperties).length > 0 + const hasAdditionalPropertiesObject = isSchemaObject(schema.additionalProperties) + + return !hasProperties && (hasPatternProperties || hasAdditionalPropertiesObject) +} + +export function isOpenObjectSchema ({ schema }) { + if (schema?.type !== 'object') { + return false + } + + const hasProperties = isSchemaObject(schema.properties) && Object.keys(schema.properties).length > 0 + const hasPatternProperties = isSchemaObject(schema.patternProperties) && Object.keys(schema.patternProperties).length > 0 + + return !hasProperties && !hasPatternProperties && schema.additionalProperties !== false +} + +function renderRecordType ({ context, renderType }) { + const valueTypes = [] + + if (isSchemaObject(context.schema.additionalProperties)) { + valueTypes.push(renderType({ + context: createChildRenderContext({ + context, + schema: context.schema.additionalProperties, + pathSuffix: 'additionalProperties', + lookupPathName: false + }) + })) + } + + if (isSchemaObject(context.schema.patternProperties)) { + for (const [pattern, patternSchema] of Object.entries(context.schema.patternProperties)) { + valueTypes.push(renderType({ + context: createChildRenderContext({ + context, + schema: patternSchema, + pathSuffix: `patternProperties/${pattern}`, + lookupPathName: false + }) + })) + } + } + + const valueType = joinUniqueTypes({ types: valueTypes }) || 'unknown' + return `Record` +} + +export function buildObjectTypeLines ({ context, renderType }) { + const propertyLines = renderPropertyLines({ context, renderType }) + const indexLines = renderIndexSignatureLines({ context, renderType }) + return [...propertyLines, ...indexLines] +} + +function renderPropertyLines ({ context, renderType }) { + if (!isSchemaObject(context.schema.properties)) { + return [] + } + + const requiredProperties = new Set(context.schema.required || []) + const lines = [] + + for (const [propertyName, propertySchema] of Object.entries(context.schema.properties)) { + const propertyPath = `${context.path}/properties/${propertyName}` + if (shouldInlineNamedScalarPropertyType({ path: propertyPath, schema: propertySchema, state: context })) { + const commentLines = getCommentLines({ + schema: propertySchema, + name: null + }) + + if (commentLines) { + lines.push(...renderCommentBlock({ lines: commentLines, indent: ' ' })) + } + } + + const propertyType = renderType({ + context: createChildRenderContext({ + context, + schema: propertySchema, + pathSuffix: `properties/${propertyName}` + }) + }) + + lines.push(` ${renderPropertyKey({ propertyName })}${requiredProperties.has(propertyName) ? '' : '?'}: ${propertyType};`) + } + + return lines +} + +function renderIndexSignatureLines ({ context, renderType }) { + const lines = [] + + if (context.schema.additionalProperties === true) { + lines.push(' [key: string]: unknown;') + } + + if (isSchemaObject(context.schema.additionalProperties)) { + const valueType = renderType({ + context: createChildRenderContext({ + context, + schema: context.schema.additionalProperties, + pathSuffix: 'additionalProperties' + }) + }) + + lines.push(` [key: string]: ${valueType};`) + } + + if (isSchemaObject(context.schema.patternProperties)) { + const patternTypes = Object.entries(context.schema.patternProperties).map(([pattern, patternSchema]) => { + return renderType({ + context: createChildRenderContext({ + context, + schema: patternSchema, + pathSuffix: `patternProperties/${pattern}`, + lookupPathName: false + }) + }) + }) + + if (patternTypes.length > 0) { + lines.push(` [key: string]: ${joinUniqueTypes({ types: patternTypes })};`) + } + } + + return dedupeLines({ lines }) +} + +function joinUniqueTypes ({ types }) { + return [...new Set(types)].join(' | ') +} + +function dedupeLines ({ lines }) { + return [...new Set(lines)] +} + +function renderPropertyKey ({ propertyName }) { + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propertyName)) { + return propertyName + } + + return JSON.stringify(propertyName) +} + +function isSchemaObject (value) { + return value !== null && typeof value === 'object' +} diff --git a/packages/massimo-cli/lib/json-schema/primitive.js b/packages/massimo-cli/lib/json-schema/render/primitive.js similarity index 100% rename from packages/massimo-cli/lib/json-schema/primitive.js rename to packages/massimo-cli/lib/json-schema/render/primitive.js diff --git a/packages/massimo-cli/lib/json-schema/reference.js b/packages/massimo-cli/lib/json-schema/render/reference.js similarity index 58% rename from packages/massimo-cli/lib/json-schema/reference.js rename to packages/massimo-cli/lib/json-schema/render/reference.js index dcbb10e..751a60c 100644 --- a/packages/massimo-cli/lib/json-schema/reference.js +++ b/packages/massimo-cli/lib/json-schema/render/reference.js @@ -1,9 +1,15 @@ -import { getSchemaAtPath, toRefPath } from './pointer.js' +import { getSchemaAtPath, toRefPath } from '../core/pointer.js' import { createRenderContext } from './render-context.js' +import { getAliasTargetName } from '../core/scanner.js' export function renderReferenceType ({ ref, context, renderType }) { const path = toRefPath(ref) - const registeredName = context.nameRegistry.getPathName({ path }) + const aliasTargetName = getAliasTargetName({ path, state: context }) + if (aliasTargetName) { + return aliasTargetName + } + + const registeredName = context.nameOverrides?.get(path) || context.nameRegistry.getPathName({ path }) if (registeredName) { return registeredName } diff --git a/packages/massimo-cli/lib/json-schema/render-context.js b/packages/massimo-cli/lib/json-schema/render/render-context.js similarity index 100% rename from packages/massimo-cli/lib/json-schema/render-context.js rename to packages/massimo-cli/lib/json-schema/render/render-context.js diff --git a/packages/massimo-cli/lib/json-schema/render-type.js b/packages/massimo-cli/lib/json-schema/render/render-type.js similarity index 72% rename from packages/massimo-cli/lib/json-schema/render-type.js rename to packages/massimo-cli/lib/json-schema/render/render-type.js index 527341c..8475809 100644 --- a/packages/massimo-cli/lib/json-schema/render-type.js +++ b/packages/massimo-cli/lib/json-schema/render/render-type.js @@ -2,14 +2,19 @@ import { renderArrayType } from './array.js' import { renderObjectType } from './object.js' import { renderPrimitiveType } from './primitive.js' import { renderReferenceType } from './reference.js' +import { shouldInlineArrayPropertyType, shouldInlineNamedScalarPropertyType } from '../core/scanner.js' import { renderIntersectionType, renderUnionType } from './union.js' export function renderType ({ context }) { - const { schema, nameRegistry, path, lookupPathName } = context + const { schema, nameRegistry, path, lookupPathName, nameOverrides } = context - if (lookupPathName) { - const registeredName = nameRegistry.getPathName({ path }) - if (registeredName) { + if ( + lookupPathName && + !(schema.const !== undefined && context.inlineConstPathNames) && + !shouldInlineNamedScalarPropertyType({ path, schema, state: context }) + ) { + const registeredName = nameOverrides?.get(path) || nameRegistry.getPathName({ path }) + if (registeredName && !shouldInlineArrayPropertyType({ path, schema, state: context })) { return registeredName } } @@ -47,7 +52,10 @@ export function renderType ({ context }) { } if (schema.const !== undefined || Array.isArray(schema.enum) || Array.isArray(schema.type) || isPrimitiveSchemaType({ schema })) { - return renderPrimitiveType({ schema }) + return renderPrimitiveType({ + schema, + singleQuoteConst: context.inlineConstPathNames + }) } return 'unknown' diff --git a/packages/massimo-cli/lib/json-schema/union.js b/packages/massimo-cli/lib/json-schema/render/union.js similarity index 100% rename from packages/massimo-cli/lib/json-schema/union.js rename to packages/massimo-cli/lib/json-schema/render/union.js diff --git a/packages/massimo-cli/test/json-schema-declarations.test.js b/packages/massimo-cli/test/json-schema-declarations.test.js index 1b19b17..4245fcd 100644 --- a/packages/massimo-cli/test/json-schema-declarations.test.js +++ b/packages/massimo-cli/test/json-schema-declarations.test.js @@ -1,7 +1,7 @@ import { equal } from 'node:assert/strict' import { test } from 'node:test' -import { buildDeclarations, renderDeclarations } from '../lib/json-schema/declarations.js' -import { scanJSONSchema } from '../lib/json-schema/scanner.js' +import { buildDeclarations, renderDeclarations } from '../lib/json-schema/declarations/index.js' +import { scanJSONSchema } from '../lib/json-schema/core/index.js' test('buildDeclarations emits interfaces and type aliases for named paths', () => { const state = scanJSONSchema({ @@ -30,22 +30,531 @@ test('buildDeclarations emits interfaces and type aliases for named paths', () = equal(renderDeclarations({ declarations, rootName: state.rootName }), [ 'interface Claim {', - ' id: ClaimId;', - ' data?: ClaimData;', + ' id: Id;', + ' data?: Data;', '}', - '', '/**', ' * Expected format: JSON Schema uuid', ' */', - 'type ClaimId = string;', - '', - 'interface ClaimData {', - ' status?: ClaimDataStatus;', + 'type Id = string;', + 'interface Data {', + ' status?: DataStatus;', '}', '', - 'type ClaimDataStatus = \'ACTIVE\' | \'INACTIVE\';', + 'type DataStatus = \'ACTIVE\' | \'INACTIVE\';', '', 'export { Claim };', '' ].join('\n')) }) + +test('buildDeclarations keeps repeated object shapes on deterministic local names', () => { + const state = scanJSONSchema({ + schema: { + title: 'Programme', + type: 'object', + required: ['validity', 'rules'], + properties: { + validity: { + type: 'object', + required: ['startAt', 'endAt'], + properties: { + startAt: { type: 'string' }, + endAt: { type: 'string' } + } + }, + rules: { + type: 'array', + items: { + type: 'object', + required: ['validity'], + properties: { + validity: { + type: 'object', + required: ['startAt', 'endAt'], + properties: { + startAt: { type: 'string' }, + endAt: { type: 'string' } + } + } + } + } + } + } + } + }) + + const declarations = buildDeclarations({ state }) + equal(renderDeclarations({ declarations, rootName: state.rootName }), [ + 'interface Programme {', + ' validity: Validity;', + ' rules: Array;', + '}', + '', + 'interface Validity {', + ' startAt: ValidityStartAt;', + ' endAt: ValidityEndAt;', + '}', + '', + 'type ValidityStartAt = string;', + 'type ValidityEndAt = string;', + 'interface Rule {', + ' validity: RuleValidity;', + '}', + '', + 'interface RuleValidity {', + ' startAt: RuleValidityStartAt;', + ' endAt: RuleValidityEndAt;', + '}', + '', + 'type RuleValidityStartAt = string;', + 'type RuleValidityEndAt = string;', + '', + 'export { Programme };', + '' + ].join('\n')) +}) + +test('buildDeclarations emits base and branch interfaces for object unions', () => { + const state = scanJSONSchema({ + schema: { + title: 'Programme', + type: 'object', + required: ['type'], + properties: { + type: { + enum: ['LOYALTY', 'CAMPAIGN'] + }, + code: { + type: 'string' + } + }, + oneOf: [ + { + type: 'object', + required: ['type', 'earning'], + properties: { + type: { const: 'LOYALTY' }, + earning: { type: 'string' } + } + }, + { + type: 'object', + required: ['type', 'messaging'], + properties: { + type: { const: 'CAMPAIGN' }, + messaging: { type: 'boolean' } + } + } + ] + } + }) + + const declarations = buildDeclarations({ state }) + equal(renderDeclarations({ declarations, rootName: state.rootName }), [ + 'interface BaseProgramme {', + ' type: Type;', + ' code?: Code;', + '}', + '', + "type Type = 'LOYALTY' | 'CAMPAIGN';", + 'type Code = string;', + 'interface LoyaltyProgramme extends BaseProgramme {', + " type: 'LOYALTY';", + ' earning: LoyaltyProgrammeEarning;', + '}', + '', + 'type LoyaltyProgrammeEarning = string;', + 'interface CampaignProgramme extends BaseProgramme {', + " type: 'CAMPAIGN';", + ' messaging: CampaignProgrammeMessaging;', + '}', + '', + 'type CampaignProgrammeMessaging = boolean;', + 'type Programme = LoyaltyProgramme | CampaignProgramme;', + '', + 'export { Programme };', + '' + ].join('\n')) +}) + +test('buildDeclarations collapses single-branch object unions into one declaration', () => { + const state = scanJSONSchema({ + schema: { + title: 'Provider', + type: 'object', + required: ['config'], + properties: { + config: { + type: 'object', + required: ['sourceSystem', 'discountId'], + properties: { + sourceSystem: { + enum: ['SDM'] + }, + discountId: { + type: 'number' + } + }, + oneOf: [ + { + type: 'object', + required: ['sourceSystem'], + properties: { + sourceSystem: { + const: 'SDM' + } + } + } + ] + } + } + } + }) + + const declarations = buildDeclarations({ state }) + equal(renderDeclarations({ declarations, rootName: state.rootName }), [ + 'interface Provider {', + ' config: Config;', + '}', + '', + 'interface Config {', + ' discountId: ConfigDiscountId;', + ' sourceSystem: ConfigSourceSystem;', + '}', + '', + 'type ConfigDiscountId = number;', + "type ConfigSourceSystem = 'SDM';", + '', + 'export { Provider };', + '' + ].join('\n')) +}) + +test('buildDeclarations still emits omitted union property aliases', () => { + const state = scanJSONSchema({ + schema: { + title: 'Offer', + type: 'object', + required: ['type', 'value'], + properties: { + type: { + enum: ['TENDER', 'SERVICE'] + }, + value: {} + }, + oneOf: [ + { + type: 'object', + required: ['type', 'value'], + properties: { + type: { const: 'TENDER' }, + value: { + type: 'object', + properties: { + amount: { type: 'string' } + } + } + } + }, + { + type: 'object', + required: ['type', 'value'], + properties: { + type: { const: 'SERVICE' }, + value: { + type: 'object', + properties: {} + } + } + } + ] + } + }) + + const declarations = buildDeclarations({ state }) + equal(renderDeclarations({ declarations, rootName: state.rootName }), [ + 'interface BaseOffer {', + ' type: Type;', + '}', + '', + "type Type = 'TENDER' | 'SERVICE';", + 'type Value = OfferTenderValue | OfferServiceValue;', + 'interface OfferTenderValue {', + ' amount?: OfferTenderValueAmount;', + '}', + '', + 'type OfferTenderValueAmount = string;', + 'type OfferServiceValue = Record;', + 'interface TenderOffer extends BaseOffer {', + " type: 'TENDER';", + ' value: OfferTenderValue;', + '}', + '', + 'interface ServiceOffer extends BaseOffer {', + " type: 'SERVICE';", + ' value: OfferServiceValue;', + '}', + '', + 'type Offer = TenderOffer | ServiceOffer;', + '', + 'export { Offer };', + '' + ].join('\n')) +}) + +test('buildDeclarations inlines nested scalar array properties with singular item aliases', () => { + const state = scanJSONSchema({ + schema: { + title: 'Lifecycle', + type: 'object', + properties: { + constraints: { + type: 'array', + items: { + type: 'object', + required: ['type'], + properties: { + type: { + enum: ['CHANNELS', 'CODE'] + } + }, + oneOf: [ + { + type: 'object', + required: ['type', 'channels'], + properties: { + type: { const: 'CHANNELS' }, + channels: { + type: 'array', + items: { + enum: ['ONLINE', 'STORE'] + } + } + } + }, + { + type: 'object', + required: ['type', 'code'], + properties: { + type: { const: 'CODE' }, + code: { + type: 'string' + } + } + } + ] + } + } + } + } + }) + + const declarations = buildDeclarations({ state }) + equal(renderDeclarations({ declarations, rootName: state.rootName }), [ + 'interface Lifecycle {', + ' constraints?: Array;', + '}', + '', + 'type Constraint = ChannelsConstraint | CodeConstraint;', + "type ConstraintType = 'CHANNELS' | 'CODE';", + 'interface ChannelsConstraint {', + " type: 'CHANNELS';", + ' channels: Array;', + '}', + '', + "type ChannelsConstraintChannel = 'ONLINE' | 'STORE';", + 'interface CodeConstraint {', + " type: 'CODE';", + ' code: CodeConstraintCode;', + '}', + '', + 'type CodeConstraintCode = string;', + '', + 'export { Lifecycle };', + '' + ].join('\n')) +}) + +test('buildDeclarations inlines formatted duplicate-suffix scalar properties in nested union branches', () => { + const state = scanJSONSchema({ + schema: { + title: 'Lifecycle', + type: 'object', + definitions: { + ExpirationDate: { + type: 'object', + required: ['type', 'date'], + properties: { + type: { const: 'DATE' }, + date: { + type: 'string', + format: 'date-time' + } + } + }, + ExpirationDuration: { + type: 'object', + required: ['type', 'value'], + properties: { + type: { const: 'DURATION' }, + value: { + type: 'number', + minimum: 0 + } + } + } + }, + properties: { + expiration: { + type: 'object', + description: 'Expiration config', + required: ['type'], + properties: { + type: { + enum: ['DATE', 'DURATION'] + } + }, + oneOf: [ + { + $ref: '#/definitions/ExpirationDate' + }, + { + $ref: '#/definitions/ExpirationDuration' + } + ] + } + } + } + }) + + const declarations = buildDeclarations({ state }) + equal(renderDeclarations({ declarations, rootName: state.rootName }), [ + 'interface Lifecycle {', + ' expiration?: Expiration;', + '}', + '/**', + ' * Expiration config', + ' */', + 'type Expiration = ExpirationDate | ExpirationDuration;', + 'interface ExpirationDate {', + " type: 'DATE';", + ' /**', + ' * Expected format: JSON Schema date-time', + ' */', + ' date: string;', + '}', + '', + 'interface ExpirationDuration {', + " type: 'DURATION';", + ' value: ExpirationDurationValue;', + '}', + '/**', + ' * Expected minimum: 0', + ' */', + 'type ExpirationDurationValue = number;', + '', + 'export { Lifecycle };', + '' + ].join('\n')) +}) + +test('buildDeclarations shares repeated enum aliases within nested union branch values', () => { + const state = scanJSONSchema({ + schema: { + title: 'Offer', + type: 'object', + properties: { + realisationCost: { + type: 'object', + required: ['currencyCode'], + properties: { + currencyCode: { + type: 'string', + enum: ['EUR', 'USD'] + } + } + }, + value: { + required: ['type'], + properties: { + type: { + enum: ['TENDER', 'FIXED_DISCOUNT', 'FIXED_PRICE'] + } + }, + oneOf: [ + { + type: 'object', + required: ['type', 'currencyCode'], + properties: { + type: { const: 'TENDER' }, + currencyCode: { + type: 'string', + enum: ['EUR', 'USD'] + } + } + }, + { + type: 'object', + required: ['type', 'currencyCode'], + properties: { + type: { const: 'FIXED_DISCOUNT' }, + currencyCode: { + type: 'string', + enum: ['EUR', 'USD'] + } + } + }, + { + type: 'object', + required: ['type', 'currencyCode'], + properties: { + type: { const: 'FIXED_PRICE' }, + currencyCode: { + type: 'string', + enum: ['EUR', 'USD'] + } + } + } + ] + } + } + } + }) + + const declarations = buildDeclarations({ state }) + equal(renderDeclarations({ declarations, rootName: state.rootName }), [ + 'interface Offer {', + ' realisationCost?: RealisationCost;', + ' value?: Value;', + '}', + '', + 'interface RealisationCost {', + ' currencyCode: RealisationCostCurrencyCode;', + '}', + '', + "type RealisationCostCurrencyCode = 'EUR' | 'USD';", + 'type Value = ValueTenderValue | ValueFixedDiscountValue | ValueFixedPriceValue;', + 'interface ValueTenderValue {', + " type: 'TENDER';", + ' currencyCode: ValueCurrencyCode;', + '}', + '', + "type ValueCurrencyCode = 'EUR' | 'USD';", + 'interface ValueFixedDiscountValue {', + " type: 'FIXED_DISCOUNT';", + ' currencyCode: ValueCurrencyCode_1;', + '}', + '', + "type ValueCurrencyCode_1 = 'EUR' | 'USD';", + 'interface ValueFixedPriceValue {', + " type: 'FIXED_PRICE';", + ' currencyCode: ValueCurrencyCode_2;', + '}', + '', + "type ValueCurrencyCode_2 = 'EUR' | 'USD';", + '', + 'export { Offer };', + '' + ].join('\n')) +}) diff --git a/packages/massimo-cli/test/json-schema-reference.test.js b/packages/massimo-cli/test/json-schema-reference.test.js index fa34333..1f5af50 100644 --- a/packages/massimo-cli/test/json-schema-reference.test.js +++ b/packages/massimo-cli/test/json-schema-reference.test.js @@ -1,8 +1,7 @@ import { equal } from 'node:assert/strict' import { test } from 'node:test' -import { renderReferenceType } from '../lib/json-schema/reference.js' -import { createRenderContext, createChildRenderContext } from '../lib/json-schema/render-context.js' -import { scanJSONSchema } from '../lib/json-schema/scanner.js' +import { renderReferenceType, createRenderContext, createChildRenderContext } from '../lib/json-schema/render/index.js' +import { scanJSONSchema } from '../lib/json-schema/core/index.js' test('createChildRenderContext preserves shared render state', () => { const state = scanJSONSchema({ diff --git a/packages/massimo-cli/test/json-schema-render-type.test.js b/packages/massimo-cli/test/json-schema-render-type.test.js index b2e55d3..0da2748 100644 --- a/packages/massimo-cli/test/json-schema-render-type.test.js +++ b/packages/massimo-cli/test/json-schema-render-type.test.js @@ -1,8 +1,7 @@ import { equal } from 'node:assert/strict' import { test } from 'node:test' -import { createRenderContext } from '../lib/json-schema/render-context.js' -import { renderType } from '../lib/json-schema/render-type.js' -import { scanJSONSchema } from '../lib/json-schema/scanner.js' +import { createRenderContext, renderType } from '../lib/json-schema/render/index.js' +import { scanJSONSchema } from '../lib/json-schema/core/index.js' test('renderType handles primitive schema types', () => { equal(renderInlineType({ schema: { type: 'string' } }), 'string') diff --git a/packages/massimo-cli/test/json-schema-scanner.test.js b/packages/massimo-cli/test/json-schema-scanner.test.js index 9ec88ff..36215db 100644 --- a/packages/massimo-cli/test/json-schema-scanner.test.js +++ b/packages/massimo-cli/test/json-schema-scanner.test.js @@ -2,7 +2,7 @@ import { readFile } from 'fs/promises' import { deepEqual, equal } from 'node:assert/strict' import { test } from 'node:test' import { join } from 'path' -import { scanJSONSchema } from '../lib/json-schema/scanner.js' +import { scanJSONSchema } from '../lib/json-schema/core/index.js' const fixturesDir = join(import.meta.dirname, 'fixtures', 'json-schema') @@ -26,8 +26,8 @@ test('scan collects refs and names referenced definitions', async () => { '#/definitions/CommandType' ]) - equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandData' }), 'CommandData') - equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandType' }), 'CommandType') + equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandData' }), 'Data') + equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandType' }), 'Type') }) test('scan keeps generated names unique', () => { @@ -50,6 +50,43 @@ test('scan keeps generated names unique', () => { equal(state.nameRegistry.getPathName({ path: '#/definitions/User' }), 'User_1') }) +test('scan derives singular array item names and discriminated union branch names', () => { + const state = scanJSONSchema({ + schema: { + title: 'Programme', + type: 'object', + properties: { + offers: { + type: 'array', + items: { + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'TENDER' } + } + } + ] + } + } + }, + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'LOYALTY' } + } + } + ] + } + }) + + equal(state.nameRegistry.getPathName({ path: '#/properties/offers/items' }), 'Offer') + equal(state.nameRegistry.getPathName({ path: '#/properties/offers/items/oneOf/0' }), 'TenderOffer') + equal(state.nameRegistry.getPathName({ path: '#/oneOf/0' }), 'LoyaltyProgramme') +}) + async function readFixtureSchema ({ example }) { const schemaPath = join(fixturesDir, example, 'schema.json') return JSON.parse(await readFile(schemaPath, 'utf8')) From 85933ec31721781d5242b5e87216554d011fda67 Mon Sep 17 00:00:00 2001 From: Safwan Parkar Date: Sun, 8 Mar 2026 03:40:20 +0100 Subject: [PATCH 4/6] fix(massimo-cli): improve alias reuse and union declaration shaping Signed-off-by: Safwan Parkar --- .../massimo-cli/lib/json-schema/comments.js | 4 + .../lib/json-schema/core/scanner.js | 242 +++++++++++++++++- .../lib/json-schema/render/array.js | 3 +- .../lib/json-schema/render/primitive.js | 6 +- .../lib/json-schema/render/render-context.js | 21 +- .../lib/json-schema/render/union.js | 12 +- .../test/json-schema-reference.test.js | 44 +++- .../test/json-schema-render-type.test.js | 33 ++- .../test/json-schema-scanner.test.js | 4 +- 9 files changed, 351 insertions(+), 18 deletions(-) diff --git a/packages/massimo-cli/lib/json-schema/comments.js b/packages/massimo-cli/lib/json-schema/comments.js index dc3bdce..d52d928 100644 --- a/packages/massimo-cli/lib/json-schema/comments.js +++ b/packages/massimo-cli/lib/json-schema/comments.js @@ -1,6 +1,10 @@ import { normalizeTypeName } from './core/naming.js' export function getCommentLines ({ schema, name }) { + if (!schema || typeof schema !== 'object') { + return null + } + const lines = [] if (schema.title && normalizeTypeName(schema.title) !== name) { diff --git a/packages/massimo-cli/lib/json-schema/core/scanner.js b/packages/massimo-cli/lib/json-schema/core/scanner.js index 7338598..6d73c88 100644 --- a/packages/massimo-cli/lib/json-schema/core/scanner.js +++ b/packages/massimo-cli/lib/json-schema/core/scanner.js @@ -13,6 +13,7 @@ export function scanJSONSchema ({ schema, rootName = undefined }) { }) registerReferencedSchemas({ state }) + registerReferenceAliases({ state }) return state } @@ -23,8 +24,11 @@ export function createScanState ({ schema, rootName = undefined }) { rootName: getDefaultRootName({ schema, rootName }), nameRegistry: createNameRegistry(), schemasByPath: new Map([['#', schema]]), + aliasTargetByPath: new Map(), + aliasSourceSchemaByPath: new Map(), references: new Set(), - expandedReferencePaths: new Set() + expandedReferencePaths: new Set(), + refByPath: new Map() } } @@ -39,6 +43,15 @@ function traverseSchema ({ schema, path, suggestedName, state }) { if (schema.$ref) { state.references.add(schema.$ref) + if (!state.refByPath.has(path)) { + state.refByPath.set(path, schema.$ref) + } + if (isNestedPropertyPath(path) && !shouldExpandNestedReference({ + path, + targetPath: toRefPath(schema.$ref) + })) { + return + } expandReferenceSchema({ ref: schema.$ref, path, @@ -59,6 +72,50 @@ export function getScannedSchemaAtPath ({ path, state }) { return state.schemasByPath.get(path) || getSchemaAtPath({ schema: state.rootSchema, path }) } +export function getAliasTargetName ({ path, state }) { + return state.aliasTargetByPath?.get(path) || null +} + +export function getAliasSourceSchema ({ path, state }) { + return state.aliasSourceSchemaByPath?.get(path) || null +} + +export function hasReferenceAtPath ({ path, state }) { + return state.refByPath?.has(path) || false +} + +export function shouldInlineArrayPropertyType ({ path, schema, state }) { + if (!isPropertyPath(path) || !isArraySchema({ schema }) || hasReferenceAtPath({ path, state })) { + return false + } + + if (!isDirectPropertyPath(path) && !isSimpleArrayItemSchema({ schema: schema.items })) { + return false + } + + return state.nameRegistry.hasPathName({ path: `${path}/items` }) +} + +export function shouldInlineNamedScalarPropertyType ({ path, schema, state }) { + if ( + !isPropertyPath(path) || + !isUnionBranchPropertyPath(path) || + hasReferenceAtPath({ path, state }) || + !isInlineFormattedScalarSchema({ schema }) + ) { + return false + } + + const parentPath = path.replace(/\/properties\/[^/]+$/, '') + const parentName = state.nameOverrides?.get(parentPath) || state.nameRegistry.getPathName({ path: parentPath }) + if (!parentName) { + return false + } + + const propertyName = path.slice(path.lastIndexOf('/') + 1) + return parentName.endsWith(normalizeTypeName(propertyName)) +} + function traverseDefinitions ({ schema, path, state }) { for (const [containerName, definitions] of Object.entries({ definitions: schema.definitions, @@ -227,6 +284,77 @@ function expandReferenceSchema ({ ref, path, suggestedName, state }) { }) } +function registerReferenceAliases ({ state }) { + for (const [path, ref] of state.refByPath.entries()) { + if (!state.nameRegistry.hasPathName({ path })) { + continue + } + + const aliasTargetName = resolveReferenceTargetName({ path, ref, state }) + if (!aliasTargetName) { + continue + } + + const localName = state.nameRegistry.getPathName({ path }) + if (localName !== aliasTargetName) { + state.aliasTargetByPath.set(path, aliasTargetName) + } + + const sourceSchema = getSchemaAtPath({ schema: state.rootSchema, path: toRefPath(ref) }) + if (isSchemaObject(sourceSchema)) { + state.aliasSourceSchemaByPath.set(path, sourceSchema) + } + } +} + +function resolveReferenceTargetName ({ path, ref, state, visitedRefs = new Set() }) { + const refPath = toRefPath(ref) + if (visitedRefs.has(refPath)) { + return state.nameRegistry.getPathName({ path: refPath }) || null + } + + visitedRefs.add(refPath) + + const isWithinBoundary = isPathWithinBoundary({ + path: refPath, + boundaryPath: getReferenceBoundaryPath({ path }) + }) + const nestedRef = state.refByPath.get(refPath) + + if (isWithinBoundary) { + if (!nestedRef) { + return null + } + + const nestedTargetName = resolveReferenceTargetName({ + path, + ref: nestedRef, + state, + visitedRefs + }) + return nestedTargetName || null + } + + const aliasTargetName = state.aliasTargetByPath.get(refPath) + if (aliasTargetName) { + return aliasTargetName + } + + if (nestedRef) { + const nestedTargetName = resolveReferenceTargetName({ + path, + ref: nestedRef, + state, + visitedRefs + }) + if (nestedTargetName) { + return nestedTargetName + } + } + + return state.nameRegistry.getPathName({ path: refPath }) || null +} + function getRegisteredName ({ schema, path, suggestedName, state }) { if (state.nameRegistry.hasPathName({ path })) { const existingName = state.nameRegistry.getPathName({ path }) @@ -241,11 +369,14 @@ function getRegisteredName ({ schema, path, suggestedName, state }) { const reuseKey = getSchemaReuseKey({ schema }) if (reuseKey && isDefinitionPath(path) && state.nameRegistry.hasStructureName({ key: reuseKey })) { - return state.nameRegistry.linkPathName({ + const aliasTargetName = state.nameRegistry.getStructureName({ key: reuseKey }) + const aliasName = state.nameRegistry.registerPathName({ path, - name: state.nameRegistry.getStructureName({ key: reuseKey }), - baseName: resolvedName + name: resolvedName }) + + state.aliasTargetByPath.set(path, aliasTargetName) + return aliasName } const name = state.nameRegistry.registerPathName({ @@ -263,6 +394,10 @@ function resolveScanName ({ schema, path, suggestedName, state }) { return state.rootName } + if (schema?.$ref && isNestedPropertyPath(path)) { + return null + } + if (suggestedName) { return normalizeTypeName(suggestedName) } @@ -286,6 +421,10 @@ function getFallbackNameFromPath ({ path }) { function getArrayItemSuggestedName ({ schema, path, parentName }) { const propertyName = getPropertyNameFromPath({ path }) + if (isInlineArrayItemSchema({ schema })) { + return '' + } + if (propertyName) { return singularizeTypeName(propertyName) } @@ -329,6 +468,58 @@ function getDiscriminatorConstValue ({ schema }) { return null } +function isNestedPropertyPath (path) { + return /^#\/properties\/[^/]+\/properties\//.test(path) || /\/properties\/[^/]+\/properties\//.test(path) +} + +function isInlineArrayItemSchema ({ schema }) { + if (!schema || typeof schema !== 'object') { + return true + } + + if (schema.$ref) { + return false + } + + if (Array.isArray(schema.enum)) { + return false + } + + if (schema.const !== undefined) { + return true + } + + return ['string', 'integer', 'number', 'boolean', 'null'].includes(schema.type) +} + +function isSimpleArrayItemSchema ({ schema }) { + if (!schema || typeof schema !== 'object') { + return false + } + + if (schema.const !== undefined || Array.isArray(schema.enum)) { + return true + } + + return ['string', 'integer', 'number', 'boolean', 'null'].includes(schema.type) +} + +function isUnionBranchPropertyPath (path) { + return /\/(?:oneOf|anyOf)\/\d+\/properties\/[^/]+$/.test(path) +} + +function isInlineFormattedScalarSchema ({ schema }) { + if (!schema || typeof schema !== 'object') { + return false + } + + return schema.type === 'string' && (Boolean(schema.format) || Boolean(schema.pattern)) +} + +function isArraySchema ({ schema }) { + return schema?.type === 'array' || Array.isArray(schema?.items) +} + function isDefinitionPath (path) { return /\/(definitions|\$defs)\/[^/]+$/.test(path) } @@ -337,6 +528,49 @@ function isItemPath (path) { return /\/items(\/\d+)?$/.test(path) } +function isDirectPropertyPath (path) { + return /^#(?:\/(?:definitions|\$defs)\/[^/]+)*\/properties\/[^/]+$/.test(path) +} + +function isPropertyPath (path) { + return /\/properties\/[^/]+$/.test(path) +} + +function getReferenceBoundaryPath ({ path }) { + const definitionMatch = path.match(/^#\/(definitions|\$defs)\/[^/]+/) + if (definitionMatch) { + return definitionMatch[0] + } + + const branchMatch = path.match(/^#\/(?:oneOf|anyOf|allOf)\/\d+/) + if (branchMatch) { + return branchMatch[0] + } + + const arrayItemMatch = path.match(/^#\/properties\/[^/]+\/items/) + if (arrayItemMatch) { + return arrayItemMatch[0] + } + + const rootPropertyMatch = path.match(/^#\/properties\/[^/]+/) + if (rootPropertyMatch) { + return rootPropertyMatch[0] + } + + return '#' +} + +function isPathWithinBoundary ({ path, boundaryPath }) { + return path === boundaryPath || path.startsWith(`${boundaryPath}/`) +} + +function shouldExpandNestedReference ({ path, targetPath }) { + return isPathWithinBoundary({ + path: targetPath, + boundaryPath: getReferenceBoundaryPath({ path }) + }) +} + function isSchemaObject (value) { return value !== null && typeof value === 'object' } diff --git a/packages/massimo-cli/lib/json-schema/render/array.js b/packages/massimo-cli/lib/json-schema/render/array.js index 28fe7c1..705114b 100644 --- a/packages/massimo-cli/lib/json-schema/render/array.js +++ b/packages/massimo-cli/lib/json-schema/render/array.js @@ -26,8 +26,7 @@ export function renderArrayType ({ context, renderType }) { context: createChildRenderContext({ context, schema: schema.items, - pathSuffix: 'items', - lookupPathName: false + pathSuffix: 'items' }) }) diff --git a/packages/massimo-cli/lib/json-schema/render/primitive.js b/packages/massimo-cli/lib/json-schema/render/primitive.js index fbf3124..e1b0208 100644 --- a/packages/massimo-cli/lib/json-schema/render/primitive.js +++ b/packages/massimo-cli/lib/json-schema/render/primitive.js @@ -18,8 +18,12 @@ export function renderEnumType ({ values }) { return values.map(value => renderEnumLiteralType({ value })).join(' | ') } -export function renderPrimitiveType ({ schema }) { +export function renderPrimitiveType ({ schema, singleQuoteConst = false }) { if (schema.const !== undefined) { + if (singleQuoteConst && typeof schema.const === 'string') { + return renderEnumLiteralType({ value: schema.const }) + } + return renderConstLiteralType({ value: schema.const }) } diff --git a/packages/massimo-cli/lib/json-schema/render/render-context.js b/packages/massimo-cli/lib/json-schema/render/render-context.js index a58a7f3..e83f96a 100644 --- a/packages/massimo-cli/lib/json-schema/render/render-context.js +++ b/packages/massimo-cli/lib/json-schema/render/render-context.js @@ -1,11 +1,22 @@ -export function createRenderContext ({ schema, state, path = '#', lookupPathName = true, lookupChildPathNames = lookupPathName }) { +export function createRenderContext ({ + schema, + state, + path = '#', + lookupPathName = true, + lookupChildPathNames = lookupPathName, + inlineConstPathNames = false +}) { return { schema, rootSchema: state.rootSchema, nameRegistry: state.nameRegistry, + aliasTargetByPath: state.aliasTargetByPath, + refByPath: state.refByPath, + nameOverrides: state.nameOverrides, path, lookupPathName, - lookupChildPathNames + lookupChildPathNames, + inlineConstPathNames } } @@ -14,8 +25,12 @@ export function createChildRenderContext ({ context, schema, pathSuffix, lookupP schema, rootSchema: context.rootSchema, nameRegistry: context.nameRegistry, + aliasTargetByPath: context.aliasTargetByPath, + refByPath: context.refByPath, + nameOverrides: context.nameOverrides, path: `${context.path}/${pathSuffix}`, lookupPathName, - lookupChildPathNames: context.lookupChildPathNames + lookupChildPathNames: context.lookupChildPathNames, + inlineConstPathNames: context.inlineConstPathNames } } diff --git a/packages/massimo-cli/lib/json-schema/render/union.js b/packages/massimo-cli/lib/json-schema/render/union.js index 91ab38b..fda18d3 100644 --- a/packages/massimo-cli/lib/json-schema/render/union.js +++ b/packages/massimo-cli/lib/json-schema/render/union.js @@ -22,10 +22,10 @@ export function renderIntersectionType ({ context, renderType }) { function renderCombinatorMembers ({ context, members, separator, keyword, renderType }) { if (!Array.isArray(members) || members.length === 0) { - return 'unknown' + return null } - return members.map((schema, index) => { + const renderedMembers = members.map((schema, index) => { const memberType = renderType({ context: createChildRenderContext({ context, @@ -36,7 +36,13 @@ function renderCombinatorMembers ({ context, members, separator, keyword, render }) return wrapCombinatorMember({ memberType, separator }) - }).join(separator) + }).filter(memberType => memberType !== 'unknown') + + if (renderedMembers.length === 0) { + return null + } + + return renderedMembers.join(separator) } function wrapCombinatorMember ({ memberType, separator }) { diff --git a/packages/massimo-cli/test/json-schema-reference.test.js b/packages/massimo-cli/test/json-schema-reference.test.js index 1f5af50..36321ce 100644 --- a/packages/massimo-cli/test/json-schema-reference.test.js +++ b/packages/massimo-cli/test/json-schema-reference.test.js @@ -35,7 +35,7 @@ test('createChildRenderContext preserves shared render state', () => { equal(childContext.lookupChildPathNames, rootContext.lookupChildPathNames) }) -test('renderReferenceType uses scanned registry names for internal refs', () => { +test('renderReferenceType prefers the canonical scanned name for internal refs', () => { const state = scanJSONSchema({ schema: { title: 'Envelope', @@ -112,3 +112,45 @@ test('renderReferenceType falls back to inline rendering when registry has no na } }), 'boolean') }) + +test('renderReferenceType does not alias refs that stay within the same logical scope', () => { + const state = scanJSONSchema({ + schema: { + title: 'Envelope', + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + status: { + $ref: '#/properties/items/items/definitions/Status' + } + }, + definitions: { + Status: { + type: 'string', + enum: ['ACTIVE', 'INACTIVE'] + } + } + } + } + } + } + }) + + const context = createRenderContext({ + schema: state.rootSchema.properties.items.items.properties.status, + state, + path: '#/properties/items/items/properties/status' + }) + + equal(renderReferenceType({ + ref: '#/properties/items/items/definitions/Status', + context, + renderType () { + return 'should-inline' + } + }), 'ItemStatus') +}) diff --git a/packages/massimo-cli/test/json-schema-render-type.test.js b/packages/massimo-cli/test/json-schema-render-type.test.js index 0da2748..56e54d6 100644 --- a/packages/massimo-cli/test/json-schema-render-type.test.js +++ b/packages/massimo-cli/test/json-schema-render-type.test.js @@ -44,6 +44,35 @@ test('renderType handles refs through the scanned registry', () => { equal(renderType({ context }), 'Payload') }) +test('renderType inlines direct array properties when the item type is already named', () => { + const state = scanJSONSchema({ + schema: { + title: 'Envelope', + type: 'object', + properties: { + payloads: { + type: 'array', + items: { + title: 'Payload', + type: 'object', + properties: { + id: { type: 'string' } + } + } + } + } + } + }) + + const context = createRenderContext({ + schema: state.rootSchema.properties.payloads, + state, + path: '#/properties/payloads' + }) + + equal(renderType({ context }), 'Array') +}) + test('renderType handles arrays and tuples', () => { equal(renderInlineType({ schema: { @@ -114,7 +143,7 @@ test('renderType handles object index signatures', () => { enum: ['A', 'B'] } } - }), "{\n [key: string]: 'A' | 'B';\n}") + }), "Record") equal(renderInlineType({ schema: { @@ -125,7 +154,7 @@ test('renderType handles object index signatures', () => { } } } - }), '{\n [key: string]: string;\n}') + }), 'Record') }) test('renderType handles oneOf and anyOf unions', () => { diff --git a/packages/massimo-cli/test/json-schema-scanner.test.js b/packages/massimo-cli/test/json-schema-scanner.test.js index 36215db..597f511 100644 --- a/packages/massimo-cli/test/json-schema-scanner.test.js +++ b/packages/massimo-cli/test/json-schema-scanner.test.js @@ -26,8 +26,8 @@ test('scan collects refs and names referenced definitions', async () => { '#/definitions/CommandType' ]) - equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandData' }), 'Data') - equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandType' }), 'Type') + equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandData' }), 'CommandData') + equal(state.nameRegistry.getPathName({ path: '#/definitions/CommandType' }), 'CommandType') }) test('scan keeps generated names unique', () => { From df1c150e4cb1444474aac2131aa99c44d72f06d8 Mon Sep 17 00:00:00 2001 From: Safwan Parkar Date: Tue, 10 Mar 2026 08:44:46 +0100 Subject: [PATCH 5/6] docs: add documenting comments and readme for json-schema Signed-off-by: Safwan Parkar --- .../massimo-cli/lib/json-schema-generator.js | 3 + .../massimo-cli/lib/json-schema/README.md | 229 ++++++++++++++++++ .../massimo-cli/lib/json-schema/comments.js | 6 + .../lib/json-schema/core/name-registry.js | 3 + .../lib/json-schema/core/naming.js | 12 + .../lib/json-schema/core/pointer.js | 6 + .../lib/json-schema/core/scanner.js | 76 ++++++ .../json-schema/declarations/canonicalize.js | 47 ++++ .../lib/json-schema/declarations/graph.js | 72 +++++- .../lib/json-schema/declarations/helpers.js | 93 +++++++ .../lib/json-schema/declarations/index.js | 10 + .../lib/json-schema/declarations/validate.js | 12 + .../massimo-cli/lib/json-schema/generator.js | 4 + .../lib/json-schema/render/array.js | 3 + .../lib/json-schema/render/object.js | 12 + .../lib/json-schema/render/primitive.js | 15 ++ .../lib/json-schema/render/reference.js | 3 + .../lib/json-schema/render/render-context.js | 6 + .../lib/json-schema/render/render-type.js | 12 + .../lib/json-schema/render/union.js | 12 + 20 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 packages/massimo-cli/lib/json-schema/README.md diff --git a/packages/massimo-cli/lib/json-schema-generator.js b/packages/massimo-cli/lib/json-schema-generator.js index 9866671..2edfaf9 100644 --- a/packages/massimo-cli/lib/json-schema-generator.js +++ b/packages/massimo-cli/lib/json-schema-generator.js @@ -1,5 +1,8 @@ import { generateJSONSchemaTypes } from './json-schema/generator.js' +/** + * Process a JSON Schema document and return the generated type output used by the CLI. + */ export function processJSONSchema ({ schema, rootName }) { return generateJSONSchemaTypes({ schema, rootName }) } diff --git a/packages/massimo-cli/lib/json-schema/README.md b/packages/massimo-cli/lib/json-schema/README.md new file mode 100644 index 0000000..456ee41 --- /dev/null +++ b/packages/massimo-cli/lib/json-schema/README.md @@ -0,0 +1,229 @@ +# JSON Schema Type Generation + +This directory contains the JSON Schema Draft-07 to TypeScript declaration generator used by `massimo-cli`. + +The code is organized around one idea: generate `.d.ts` output in a way that is deterministic, inspectable, and easy to evolve without depending on side effects of other componentss. + +## End-To-End Flow + +The public entrypoint of the generator is [generator.js](./generator.js). + +Generation happens in four phases: +1. Scan + - Implemented in [core/scanner.js](./core/scanner.js) + - Walks the schema once + - Registers path-based names + - Records scanned schema nodes, refs, and alias metadata + +2. Canonicalize + - Implemented in [declarations/canonicalize.js](./declarations/canonicalize.js) + - Turns raw scan-time names into stable public declaration names + - Resolves collisions deterministically + - Computes safe structural alias targets + +3. Build Declaration Graph + - Implemented in [declarations/graph.js](./declarations/graph.js) + - Converts canonicalized schema paths into declaration nodes + - Expands object unions into base/branch/union declarations + - Captures explicit dependencies between declarations + +4. Validate and Render + - Implemented in [declarations/validate.js](./declarations/validate.js) and [declarations/index.js](./declarations/index.js) + - Rejects invalid declaration graphs + - Emits final declarations in deterministic order + - Renders the `.d.ts` module text + +The render layer in [render/](./render) is used by both graph construction and final declaration building whenever a schema node needs an inline TypeScript type expression. + +## Data Models + +### Scan state + +The scan phase returns a plain object that is passed through the rest of the pipeline. Its important fields are: +- `rootSchema` + - the original input schema +- `rootName` + - the top-level emitted type name +- `nameRegistry` + - a path-to-name registry built during traversal +- `schemasByPath` + - scanned schema nodes keyed by canonical schema path +- `aliasTargetByPath` + - explicit alias target names for ref-driven reuse +- `aliasSourceSchemaByPath` + - the schema node to use when an alias should inherit comments +- `refByPath` + - which scanned paths came from `$ref` + +This state is intentionally path-indexed. The later phases do not rediscover structure by traversing emitted declarations; they always go back to canonical schema paths. + +### Canonicalized state + +Canonicalization returns the scan state plus two additional fields: +- `nameOverrides` + - final public names for emitted paths when they differ from raw scan-time names +- `structuralAliasTargets` + - conservative alias reuse for safe scalar cases + +From this point on, declaration emission should consider naming settled + +### Declaration graph + +The graph phase produces: +- `nodes` + - a map of declaration node id to declaration node +- `entryIds` + - the top-level declaration ids that define the public emission order + +Each declaration node contains: +- `id` + - stable internal identifier, usually a schema path +- `path` + - original schema path +- `declaration` + - final declaration payload, either `interface` or `type` +- `dependencyIds` + - declaration nodes that must also exist in the output + +The decalration graph is the boundary between "figuring out what should exist" and "rendering text". + +## Why The Phases Are Split + +I initially used a prototype-style approach that mixed naming, recursion, and rendering. That made it easy for the output to depend on whichever branch happened to be visited first. + +The current split is meant to prevent that: +- **scan** decides what exists +- **canonicalize** decides what things are called +- **build declaration graph** decides which declarations are emitted and how they depend on each other +- **validate** rejects broken states early, and **render** only turns a validated declaration set into text (i.e. the types) + +This split is needed to make sure that when a schema has deep nesting, a lot of ref reuse and somewhat heavy usage of combinators (e.g. allOf, oneOf), the output is deterministic. Without the split, the generated definitions got messy and would usually have a lot of duplicated defs like `Type`, `Type_1`, `Type_2`. + +## Module Responsibilities + +### `core/` +- [core/scanner.js](./core/scanner.js) + - schema traversal + - registration of names + - ref expansion and alias metadata + - helpers that tell the render layer when to inline property types. + - if you're wondering why the scan layer has helpers for render layer, it's because those decisions rely on scan-time knowledge and not just the local schema node. + - a way to think of this is - they are not render helpers, but more like scan-state query helpers used by rendering. hopefully that makes sense! +- [core/name-registry.js](./core/name-registry.js) + - registers and resolves path:name assignments +- [core/naming.js](./core/naming.js) + - type name normalization and singularization +- [core/pointer.js](./core/pointer.js) + - JSON Pointer resolution + +### `render/` +- [render/render-type.js](./render/render-type.js) + - top-level dispatcher for inline type rendering +- [render/primitive.js](./render/primitive.js) + - handles strings, numbers, booleans, consts, enums +- [render/array.js](./render/array.js) + - handles arrays and tuples +- [render/object.js](./render/object.js) + - handles object bodies, records, and open objects +- [render/union.js](./render/union.js) + - handles combinators: `oneOf`, `anyOf`, `allOf` +- [render/reference.js](./render/reference.js) + - `$ref` rendering and alias preference +- [render/render-context.js](./render/render-context.js) + - shared render context passed between nested render calls + +### `declarations/` +- [declarations/index.js](./declarations/index.js) + - orchestration and final declaration rendering +- [declarations/canonicalize.js](./declarations/canonicalize.js) + - naming policy + - deterministic name collision handling + - safe structural aliasing +- [declarations/graph.js](./declarations/graph.js) + - declaration graph construction + - object-union expansion + - dependency collection +- [declarations/validate.js](./declarations/validate.js) + - graph integrity checks +- [declarations/helpers.js](./declarations/helpers.js) + - shared schema/path helpers used by canonicalization and graph building + +### Top-level support files + +- [comments.js](./comments.js) + - comment extraction from schema metadata + +## Naming Strategy + +Names originate from schema paths, not from emitted declaration order. + +The scan phase proposes names from: +- root title or explicit root name +- property names +- array item singularization +- definition keys +- union branch discriminators when available + +Canonicalization then applies deterministic fixes: +- preserve one stable public name per emitted path +- singularize array item names when array and item would otherwise collide +- suffix name collisions in canonical path order +- reuse a previously named scalar alias only in narrow safe cases + +If naming is ambiguous, the code prefers a distinct deterministic name over a more clever but less predictable alias collapse. + +## Union Handling + +Object unions are the most complex part of declaration emission. + +The graph layer handles three main cases: +1. Plain object union + - emit `BaseTypeName`, branch interfaces, and `type TypeName = BranchA | BranchB` + +2. Single-branch object union + - collapse into one declaration to avoid emitting unnecessary wrappers + +3. Union-derived properties + - when a base property would otherwise be `unknown`, build a property alias from the branch-specific declarations instead + +This logic lives in [declarations/graph.js](./declarations/graph.js), not in the render layer, because it is about declaration structure rather than inline type syntax. + +## Determinism Rules + +The current implementation tries to keep output deterministic by enforcing these rules: +- names are based on canonical schema paths +- name collisions are resolved in canonical path order +- declaration graphs are built before rendering +- validation rejects conflicting duplicate declarations +- validation rejects missing dependencies +- validation rejects accidental self-referential aliases + +Note that deterministic does not mean minimal. The code will emit extra declarations when that is the safer outcome. +Deterministic means same schema input and same code version produce the same emitted output. + +## Tests + +The tests are split by responsibility: +- [json-schema-scanner.test.js](/Users/curamet/development/oss/massimo/packages/massimo-cli/test/json-schema-scanner.test.js) + - scan state and name discovery +- [json-schema-render-type.test.js](/Users/curamet/development/oss/massimo/packages/massimo-cli/test/json-schema-render-type.test.js) + - inline type rendering +- [json-schema-reference.test.js](/Users/curamet/development/oss/massimo/packages/massimo-cli/test/json-schema-reference.test.js) + - ref behavior +- [json-schema-declarations.test.js](/Users/curamet/development/oss/massimo/packages/massimo-cli/test/json-schema-declarations.test.js) + - declaration-level behavior +- [json-schema-generator.test.js](/Users/curamet/development/oss/massimo/packages/massimo-cli/test/json-schema-generator.test.js) + - end-to-end generation + +## Where To Make Changes + +- Change path discovery or raw naming inputs in `core/scanner.js` +- Change public declaration naming policy in `declarations/canonicalize.js` +- Change declaration expansion or dependency ordering in `declarations/graph.js` +- Change inline type rendering in `render/` +- Change output comments in `comments.js` + +As a rule of thumb: +- if you are changing what a type is called, start in `declarations/canonicalize.js` +- if you are changing what declarations should exist, start in `declarations/graph.js` +- if you are changing how a schema fragment renders inline, start in `render/` diff --git a/packages/massimo-cli/lib/json-schema/comments.js b/packages/massimo-cli/lib/json-schema/comments.js index d52d928..657c5bc 100644 --- a/packages/massimo-cli/lib/json-schema/comments.js +++ b/packages/massimo-cli/lib/json-schema/comments.js @@ -1,5 +1,8 @@ import { normalizeTypeName } from './core/naming.js' +/** + * Collect comment lines for a schema node from the metadata that should be exposed in the output. + */ export function getCommentLines ({ schema, name }) { if (!schema || typeof schema !== 'object') { return null @@ -30,6 +33,9 @@ export function getCommentLines ({ schema, name }) { return lines.length > 0 ? lines : null } +/** + * Render a JSDoc block from precomputed comment lines. + */ export function renderCommentBlock ({ lines, indent = '' }) { return [ `${indent}/**`, diff --git a/packages/massimo-cli/lib/json-schema/core/name-registry.js b/packages/massimo-cli/lib/json-schema/core/name-registry.js index 0e36835..56142cf 100644 --- a/packages/massimo-cli/lib/json-schema/core/name-registry.js +++ b/packages/massimo-cli/lib/json-schema/core/name-registry.js @@ -1,3 +1,6 @@ +/** + * Create the registry that owns stable schema-path and structural-name assignments for a scan run. + */ export function createNameRegistry () { const namesByPath = new Map() const namesByStructure = new Map() diff --git a/packages/massimo-cli/lib/json-schema/core/naming.js b/packages/massimo-cli/lib/json-schema/core/naming.js index e33f153..04f47ec 100644 --- a/packages/massimo-cli/lib/json-schema/core/naming.js +++ b/packages/massimo-cli/lib/json-schema/core/naming.js @@ -1,17 +1,29 @@ import { capitalize, toJavaScriptName } from '../../utils.js' +/** + * Resolve the root type name, preferring an explicit override and then schema metadata. + */ export function getDefaultRootName ({ schema, rootName }) { return normalizeTypeName(rootName || schema?.title || 'Schema') } +/** + * Convert an arbitrary schema-derived label into a TypeScript-friendly public type name. + */ export function normalizeTypeName (value) { return capitalize(toJavaScriptName(String(value || 'Schema'))) } +/** + * Join a parent prefix and child suffix into a normalized type name. + */ export function joinTypeName ({ prefix = '', suffix = '' }) { return normalizeTypeName(`${prefix}${normalizeTypeName(suffix)}`) } +/** + * Apply the generator's simple singularization rules when deriving array item type names. + */ export function singularizeTypeName (value) { const normalized = normalizeTypeName(value) diff --git a/packages/massimo-cli/lib/json-schema/core/pointer.js b/packages/massimo-cli/lib/json-schema/core/pointer.js index 0d25409..b38e58b 100644 --- a/packages/massimo-cli/lib/json-schema/core/pointer.js +++ b/packages/massimo-cli/lib/json-schema/core/pointer.js @@ -1,9 +1,15 @@ import jsonpointer from 'jsonpointer' +/** + * Normalize an internal JSON Schema reference string into the path form used by the generator. + */ export function toRefPath (ref) { return typeof ref === 'string' && ref.startsWith('#') ? ref : '#' } +/** + * Resolve a schema node from a normalized generator path. + */ export function getSchemaAtPath ({ schema, path }) { if (path === '#') { return schema diff --git a/packages/massimo-cli/lib/json-schema/core/scanner.js b/packages/massimo-cli/lib/json-schema/core/scanner.js index 6d73c88..be38a8e 100644 --- a/packages/massimo-cli/lib/json-schema/core/scanner.js +++ b/packages/massimo-cli/lib/json-schema/core/scanner.js @@ -2,9 +2,14 @@ import { createNameRegistry } from './name-registry.js' import { getDefaultRootName, joinTypeName, normalizeTypeName, singularizeTypeName } from './naming.js' import { getSchemaAtPath, toRefPath } from './pointer.js' +/** + * Scan a JSON Schema document into the path-indexed state consumed by the later generation phases. + */ export function scanJSONSchema ({ schema, rootName = undefined }) { const state = createScanState({ schema, rootName }) + // The scan pass builds the stable path-indexed state that later phases rely on for + // canonical naming, aliasing, and declaration emission. traverseSchema({ schema, path: '#', @@ -18,6 +23,9 @@ export function scanJSONSchema ({ schema, rootName = undefined }) { return state } +/** + * Create the mutable scan state that accumulates schemas, names, refs, and alias metadata. + */ export function createScanState ({ schema, rootName = undefined }) { return { rootSchema: schema, @@ -32,6 +40,9 @@ export function createScanState ({ schema, rootName = undefined }) { } } +/** + * Walk a schema node, register its public name if needed, and recurse into nested child schemas. + */ function traverseSchema ({ schema, path, suggestedName, state }) { if (!isSchemaObject(schema)) { return @@ -68,22 +79,37 @@ function traverseSchema ({ schema, path, suggestedName, state }) { traverseDefinitions({ schema, path, state }) } +/** + * Read the scanned schema for a path, falling back to direct pointer lookup when needed. + */ export function getScannedSchemaAtPath ({ path, state }) { return state.schemasByPath.get(path) || getSchemaAtPath({ schema: state.rootSchema, path }) } +/** + * Look up the public alias target name chosen for a scanned reference path. + */ export function getAliasTargetName ({ path, state }) { return state.aliasTargetByPath?.get(path) || null } +/** + * Return the schema used as the comment source for a reference alias declaration. + */ export function getAliasSourceSchema ({ path, state }) { return state.aliasSourceSchemaByPath?.get(path) || null } +/** + * Check whether a scanned path originated from a `$ref`. + */ export function hasReferenceAtPath ({ path, state }) { return state.refByPath?.has(path) || false } +/** + * Decide whether a named array property should render inline while still emitting its item type. + */ export function shouldInlineArrayPropertyType ({ path, schema, state }) { if (!isPropertyPath(path) || !isArraySchema({ schema }) || hasReferenceAtPath({ path, state })) { return false @@ -96,6 +122,9 @@ export function shouldInlineArrayPropertyType ({ path, schema, state }) { return state.nameRegistry.hasPathName({ path: `${path}/items` }) } +/** + * Decide whether a named scalar property should render inline instead of as a separate alias. + */ export function shouldInlineNamedScalarPropertyType ({ path, schema, state }) { if ( !isPropertyPath(path) || @@ -107,6 +136,8 @@ export function shouldInlineNamedScalarPropertyType ({ path, schema, state }) { } const parentPath = path.replace(/\/properties\/[^/]+$/, '') + // If a branch-local scalar already matches the parent-owned public name, emit it inline + // instead of generating a redundant alias declaration. const parentName = state.nameOverrides?.get(parentPath) || state.nameRegistry.getPathName({ path: parentPath }) if (!parentName) { return false @@ -116,6 +147,9 @@ export function shouldInlineNamedScalarPropertyType ({ path, schema, state }) { return parentName.endsWith(normalizeTypeName(propertyName)) } +/** + * Traverse `definitions` and `$defs` containers and register their members as named schemas. + */ function traverseDefinitions ({ schema, path, state }) { for (const [containerName, definitions] of Object.entries({ definitions: schema.definitions, @@ -136,6 +170,9 @@ function traverseDefinitions ({ schema, path, state }) { } } +/** + * Traverse object properties and derive child type names from the owning schema name. + */ function traverseProperties ({ schema, path, parentName, state }) { if (!isSchemaObject(schema.properties)) { return @@ -155,6 +192,9 @@ function traverseProperties ({ schema, path, parentName, state }) { } } +/** + * Traverse array items, handling both tuple and homogeneous array forms. + */ function traverseItems ({ schema, path, parentName, state }) { if (!schema.items) { return @@ -183,6 +223,9 @@ function traverseItems ({ schema, path, parentName, state }) { }) } +/** + * Traverse object-valued `additionalProperties` schemas. + */ function traverseAdditionalProperties ({ schema, path, parentName, state }) { if (!isSchemaObject(schema.additionalProperties)) { return @@ -196,6 +239,9 @@ function traverseAdditionalProperties ({ schema, path, parentName, state }) { }) } +/** + * Traverse all JSON Schema combinator members for the current node. + */ function traverseCombinators ({ schema, path, parentName, state }) { traverseCombinatorMembers({ members: schema.oneOf, @@ -225,6 +271,9 @@ function traverseCombinators ({ schema, path, parentName, state }) { }) } +/** + * Traverse each member in a specific combinator array with a deterministic suggested name. + */ function traverseCombinatorMembers ({ members, path, parentName, kind, keyword, state }) { if (!Array.isArray(members)) { return @@ -240,6 +289,9 @@ function traverseCombinatorMembers ({ members, path, parentName, kind, keyword, } } +/** + * Register names for referenced schemas that were never visited directly during traversal. + */ function registerReferencedSchemas ({ state }) { for (const reference of state.references) { const path = toRefPath(reference) @@ -262,6 +314,9 @@ function registerReferencedSchemas ({ state }) { } } +/** + * Expand a reference into the current path when the scanner needs local visibility into its shape. + */ function expandReferenceSchema ({ ref, path, suggestedName, state }) { const refPath = toRefPath(ref) const expansionKey = `${path}=>${refPath}` @@ -284,6 +339,9 @@ function expandReferenceSchema ({ ref, path, suggestedName, state }) { }) } +/** + * Resolve scanned `$ref` paths into alias targets once all referenced names are known. + */ function registerReferenceAliases ({ state }) { for (const [path, ref] of state.refByPath.entries()) { if (!state.nameRegistry.hasPathName({ path })) { @@ -307,6 +365,9 @@ function registerReferenceAliases ({ state }) { } } +/** + * Follow nested references until the scanner finds the public alias target for a referenced path. + */ function resolveReferenceTargetName ({ path, ref, state, visitedRefs = new Set() }) { const refPath = toRefPath(ref) if (visitedRefs.has(refPath)) { @@ -355,6 +416,9 @@ function resolveReferenceTargetName ({ path, ref, state, visitedRefs = new Set() return state.nameRegistry.getPathName({ path: refPath }) || null } +/** + * Register the stable name for a path, reusing compatible definition structures when allowed. + */ function getRegisteredName ({ schema, path, suggestedName, state }) { if (state.nameRegistry.hasPathName({ path })) { const existingName = state.nameRegistry.getPathName({ path }) @@ -389,6 +453,9 @@ function getRegisteredName ({ schema, path, suggestedName, state }) { return name } +/** + * Decide the initial scan-time name for a path before conflict resolution happens later. + */ function resolveScanName ({ schema, path, suggestedName, state }) { if (path === '#') { return state.rootName @@ -415,10 +482,16 @@ function resolveScanName ({ schema, path, suggestedName, state }) { return null } +/** + * Derive a fallback type name from the tail of a schema path. + */ function getFallbackNameFromPath ({ path }) { return normalizeTypeName(path.split('/').at(-1) || 'Schema') } +/** + * Suggest a public type name for array item schemas based on the owner path and item shape. + */ function getArrayItemSuggestedName ({ schema, path, parentName }) { const propertyName = getPropertyNameFromPath({ path }) if (isInlineArrayItemSchema({ schema })) { @@ -436,6 +509,9 @@ function getArrayItemSuggestedName ({ schema, path, parentName }) { return joinTypeName({ prefix: singularizeTypeName(parentName), suffix: 'Item' }) } +/** + * Suggest a stable member name for oneOf/anyOf/allOf branches. + */ function getCombinatorSuggestedName ({ memberSchema, parentName, kind, index }) { if (memberSchema?.$ref) { return getFallbackNameFromPath({ path: toRefPath(memberSchema.$ref) }) diff --git a/packages/massimo-cli/lib/json-schema/declarations/canonicalize.js b/packages/massimo-cli/lib/json-schema/declarations/canonicalize.js index f3506f3..c297aa8 100644 --- a/packages/massimo-cli/lib/json-schema/declarations/canonicalize.js +++ b/packages/massimo-cli/lib/json-schema/declarations/canonicalize.js @@ -21,6 +21,10 @@ import { singularizeName } from './helpers.js' +/** + * Produce the declaration state used for emission by adding stable public-name overrides and + * precomputed structural alias targets to the scan state. + */ export function canonicalizeDeclarationState ({ state }) { const nameOverrides = buildDeclarationNameOverrides({ state }) const stateWithOverrides = { @@ -28,6 +32,8 @@ export function canonicalizeDeclarationState ({ state }) { nameOverrides } + // Apply deterministic naming "repairs" after the first override pass so later phases can rely + // on a single stable public name for every emitted path. enforceStableArrayItemNames({ state: stateWithOverrides, nameOverrides }) enforceStableNameConflicts({ state: stateWithOverrides, nameOverrides }) @@ -43,6 +49,9 @@ export function canonicalizeDeclarationState ({ state }) { } } +/** + * Build the deterministic name-override map applied before declaration graph construction. + */ function buildDeclarationNameOverrides ({ state }) { const overrides = new Map() const paths = [...state.nameRegistry.getPathEntries().keys()].sort((left, right) => left.length - right.length) @@ -76,6 +85,9 @@ function buildDeclarationNameOverrides ({ state }) { return overrides } +/** + * Ensure named arrays never reuse the exact same public name for both the array and its item type. + */ function enforceStableArrayItemNames ({ state, nameOverrides }) { const paths = [...state.nameRegistry.getPathEntries().keys()].sort(compareCanonicalPaths) @@ -102,6 +114,9 @@ function enforceStableArrayItemNames ({ state, nameOverrides }) { } } +/** + * Resolve public-name collisions with deterministic suffixes based on canonical path order. + */ function enforceStableNameConflicts ({ state, nameOverrides }) { const countsByName = new Map() const paths = [...state.nameRegistry.getPathEntries().keys()].sort(compareCanonicalPaths) @@ -128,10 +143,15 @@ function enforceStableNameConflicts ({ state, nameOverrides }) { } } +/** + * Precompute the small set of structural alias targets that are safe to reuse deterministically. + */ function buildStructuralAliasTargets ({ state }) { const targets = new Map() const candidatePaths = [...state.nameRegistry.getPathEntries().keys()].sort(compareCanonicalPaths) + // Structural alias reuse is conservative on purpose - if a candidate is ambiguous, prefer + // a distinct deterministic name over a surprising alias collapse. for (const path of candidatePaths) { const schema = resolveDeclarationSchema({ schema: getScannedSchemaAtPath({ path, state }), @@ -187,6 +207,9 @@ function buildStructuralAliasTargets ({ state }) { return targets } +/** + * Rename repeated root-union properties when every branch contributes the same object-like shape. + */ function applyRootUnionBranchPropertyOverrides ({ path, unionInfo, overrides, state }) { if (path !== '#') { return @@ -241,6 +264,9 @@ function applyRootUnionBranchPropertyOverrides ({ path, unionInfo, overrides, st } } +/** + * Normalize helper names nested under root-union branch properties to reduce redundant prefixes. + */ function applyRootUnionBranchChildOverrides ({ path, unionInfo, overrides, state }) { if (path !== '#') { return @@ -323,6 +349,9 @@ function applyRootUnionBranchChildOverrides ({ path, unionInfo, overrides, state } } +/** + * Flatten child declarations under container-like properties that should share the same owner prefix. + */ function applyCollapsedContainerChildOverrides ({ containerPath, containerSchema, ownerPrefix, overrides, state }) { if (!containerSchema?.properties) { return @@ -374,6 +403,9 @@ function applyCollapsedContainerChildOverrides ({ containerPath, containerSchema } } +/** + * Collapse repeated branch property types to one shared owner-level name when their shapes match. + */ function applySharedUnionBranchPropertyOverrides ({ path, unionInfo, overrides, state }) { const ownerName = getPreferredPathName({ path, state: { ...state, nameOverrides: overrides } }) if (!ownerName) { @@ -423,6 +455,9 @@ function applySharedUnionBranchPropertyOverrides ({ path, unionInfo, overrides, } } +/** + * Rename nested union-typed properties so branch-owned helper names stay deterministic and scoped. + */ function applyPropertyUnionBranchOverrides ({ path, unionInfo, overrides, state }) { const ownerName = getPreferredPathName({ path, state: { ...state, nameOverrides: overrides } }) if (!ownerName) { @@ -463,6 +498,9 @@ function applyPropertyUnionBranchOverrides ({ path, unionInfo, overrides, state } } +/** + * Rename non-root union branches relative to their owner type instead of their raw scan-time names. + */ function applyNestedUnionBranchOverrides ({ path, unionInfo, overrides, state }) { if (path === '#' || isTopLevelArrayItemPath({ path })) { return @@ -494,6 +532,9 @@ function applyNestedUnionBranchOverrides ({ path, unionInfo, overrides, state }) } } +/** + * Apply deterministic overrides for leaf helper types that hang directly off union branches. + */ function applyBranchLeafOverrides ({ path, unionInfo, overrides, state }) { if (path === '#' || isTopLevelArrayItemPath({ path })) { return @@ -546,6 +587,9 @@ function applyBranchLeafOverrides ({ path, unionInfo, overrides, state }) { } } +/** + * Rename a path and every named descendant that currently derives its name from that path root. + */ function applySubtreeNameOverride ({ rootPath, nextRootName, overrides, state }) { const currentRootName = overrides.get(rootPath) || state.nameRegistry.getPathName({ path: rootPath }) if (!currentRootName || !nextRootName || currentRootName === nextRootName) { @@ -566,6 +610,9 @@ function applySubtreeNameOverride ({ rootPath, nextRootName, overrides, state }) } } +/** + * Look up object-union metadata for a property path so declaration naming can reason about branches. + */ function getObjectUnionPropertyInfo ({ path, state }) { const match = path.match(/^(.*)\/properties\/([^/]+)$/) if (!match) { diff --git a/packages/massimo-cli/lib/json-schema/declarations/graph.js b/packages/massimo-cli/lib/json-schema/declarations/graph.js index 25fcf4e..0c3f350 100644 --- a/packages/massimo-cli/lib/json-schema/declarations/graph.js +++ b/packages/massimo-cli/lib/json-schema/declarations/graph.js @@ -20,8 +20,14 @@ import { shouldUseInterface } from './helpers.js' +/** + * Build the intermediate declaration graph used for deterministic ordering and validation. + */ export function buildDeclarationGraph ({ state }) { const nodes = new Map() + + // Graph construction happens before rendering so that dependency ordering and duplicate detection + // is based on explicit structure, instead of the order during recursive emission. const entryIds = collectDeclarationGraphForPath({ path: '#', state, @@ -35,6 +41,9 @@ export function buildDeclarationGraph ({ state }) { } } +/** + * Render the declaration graph into the ordered declaration list consumed by the final text renderer. + */ export function renderDeclarationGraph ({ graph }) { const declarations = [] const emittedIds = new Set() @@ -51,6 +60,9 @@ export function renderDeclarationGraph ({ graph }) { return declarations } +/** + * Build the declaration record for a single named schema path. + */ function buildDeclaration ({ path, name, schema, state, inlineConstPathNames = false }) { const unionPropertyDeclaration = buildObjectUnionPropertyDeclaration({ path, name, schema, state }) if (unionPropertyDeclaration) { @@ -106,6 +118,9 @@ function buildDeclaration ({ path, name, schema, state, inlineConstPathNames = f } } +/** + * Collect the declaration graph nodes reachable from a named schema path. + */ function collectDeclarationGraphForPath ({ path, state, nodes, visitedPaths }) { if (visitedPaths.has(path) || !state.nameRegistry.hasPathName({ path })) { return [] @@ -161,6 +176,9 @@ function collectDeclarationGraphForPath ({ path, state, nodes, visitedPaths }) { return [path] } +/** + * Expand an object-like union into its base, branch, omitted-property, and union alias nodes. + */ function collectObjectUnionGraphNodes ({ path, schema, state, nodes, visitedPaths, unionInfo }) { const unionName = getPreferredPathName({ path, state }) || unionInfo.name const omittedPropertyNames = getOmittedBasePropertyNames({ path, schema, state, unionInfo }) @@ -179,6 +197,8 @@ function collectObjectUnionGraphNodes ({ path, schema, state, nodes, visitedPath } if (unionInfo.branchPaths.length === 1) { + // collapse trivial object unions early so single-branch shapes do not leak unnecessary + // base/union declarations into the public surface. const branchPath = unionInfo.branchPaths[0] const branchSchema = getScannedSchemaAtPath({ path: branchPath, state }) if (branchSchema) { @@ -350,6 +370,9 @@ function collectObjectUnionGraphNodes ({ path, schema, state, nodes, visitedPath return entryIds } +/** + * Collect declarations that must still emit for properties omitted from an object-union base. + */ function collectObjectUnionBaseEntryIds ({ path, schema, @@ -420,6 +443,9 @@ function collectObjectUnionBaseEntryIds ({ return entryIds } +/** + * Create a graph node with normalized dependency ids and self-dependencies removed. + */ function createDeclarationNode ({ id, path, declaration, dependencyIds }) { return { id, @@ -429,6 +455,9 @@ function createDeclarationNode ({ id, path, declaration, dependencyIds }) { } } +/** + * Emit a declaration node and then its dependencies in graph order. + */ function emitDeclarationNode ({ id, graph, declarations, emittedIds }) { if (emittedIds.has(id)) { return @@ -452,6 +481,9 @@ function emitDeclarationNode ({ id, graph, declarations, emittedIds }) { } } +/** + * Resolve the graph node id used for a declaration path. + */ function getDeclarationNodeIdForPath ({ path, state }) { const schema = getScannedSchemaAtPath({ path, state }) if (!schema) { @@ -466,6 +498,9 @@ function getDeclarationNodeIdForPath ({ path, state }) { return path } +/** + * Look up a precomputed structural alias target for a declaration path when one is allowed. + */ function getStructuralAliasTargetName ({ path, schema, state }) { if (!isReusableScalarSchema({ schema }) || !isValueBranchScalarPath({ path })) { return null @@ -474,6 +509,9 @@ function getStructuralAliasTargetName ({ path, schema, state }) { return state.structuralAliasTargets?.get(path) || null } +/** + * Collect every named dependency that must emit before a declaration can render correctly. + */ function collectDependencyPaths ({ schema, path, state }) { const resolvedSchema = resolveDeclarationSchema({ schema, state }) const dependencyPaths = [] @@ -485,6 +523,9 @@ function collectDependencyPaths ({ schema, path, state }) { return [...new Set(dependencyPaths)].filter(dependencyPath => dependencyPath !== path) } +/** + * Collect declaration dependencies introduced by object members. + */ function collectObjectDependencyPaths ({ schema, path, state, dependencyPaths }) { if (!hasObjectMembers({ schema })) { return @@ -515,6 +556,9 @@ function collectObjectDependencyPaths ({ schema, path, state, dependencyPaths }) } } +/** + * Collect declaration dependencies introduced by array items. + */ function collectArrayDependencyPaths ({ schema, path, state, dependencyPaths }) { if (!schema.items) { return @@ -537,6 +581,9 @@ function collectArrayDependencyPaths ({ schema, path, state, dependencyPaths }) } } +/** + * Collect declaration dependencies introduced by union and intersection combinators. + */ function collectCombinatorDependencyPaths ({ schema, path, state, dependencyPaths }) { for (const keyword of ['oneOf', 'anyOf', 'allOf']) { if (!Array.isArray(schema[keyword])) { @@ -557,6 +604,9 @@ function collectCombinatorDependencyPaths ({ schema, path, state, dependencyPath } } +/** + * Synthesize a property-level union alias when the base property cannot render a concrete type itself. + */ function buildObjectUnionPropertyDeclaration ({ path, name, schema, state }) { const propertyInfo = getObjectUnionPropertyInfo({ path, state }) if (!propertyInfo) { @@ -577,6 +627,9 @@ function buildObjectUnionPropertyDeclaration ({ path, name, schema, state }) { } } +/** + * Resolve the branch property declarations that contribute to a property-level object union. + */ function getObjectUnionPropertyInfo ({ path, state }) { const match = path.match(/^(.*)\/properties\/([^/]+)$/) if (!match) { @@ -605,6 +658,9 @@ function getObjectUnionPropertyInfo ({ path, state }) { } } +/** + * Determine which properties should be removed from an object-union base declaration. + */ function getOmittedBasePropertyNames ({ path, schema, state, unionInfo }) { const resolvedSchema = resolveDeclarationSchema({ schema, state }) const propertyNames = Object.keys(resolvedSchema.properties || {}) @@ -640,6 +696,9 @@ function getOmittedBasePropertyNames ({ path, schema, state, unionInfo }) { return omitted } +/** + * Merge inherited required properties from the union parent into one branch schema. + */ function mergeUnionBranchSchema ({ path, branchPath, branchSchema, state }) { const parentSchema = getScannedSchemaAtPath({ path, state }) const parentRequired = new Set(parentSchema?.required || []) @@ -657,13 +716,16 @@ function mergeUnionBranchSchema ({ path, branchPath, branchSchema, state }) { } } +/** + * Merge a union base schema with a single branch when collapsing trivial object unions. + */ function mergeObjectUnionSchemas ({ baseSchema, branchSchema }) { return { ...baseSchema, ...branchSchema, properties: { - ...(baseSchema.properties || {}), - ...(branchSchema.properties || {}) + ...baseSchema.properties, + ...branchSchema.properties }, required: [...new Set([...(baseSchema.required || []), ...(branchSchema.required || [])])], oneOf: undefined, @@ -672,6 +734,9 @@ function mergeObjectUnionSchemas ({ baseSchema, branchSchema }) { } } +/** + * Decide whether a property should disappear from a non-root union base because every branch narrows it. + */ function shouldOmitObjectUnionBaseProperty ({ path, propertyName, propertySchema, state, unionInfo }) { if (path === '#' || propertySchema?.const !== undefined) { return false @@ -687,6 +752,9 @@ function shouldOmitObjectUnionBaseProperty ({ path, propertyName, propertySchema }) } +/** + * Render the raw TypeScript type for a schema path without going through named declaration emission. + */ function renderDeclarationValue ({ path, schema, state }) { return renderType({ context: createRenderContext({ diff --git a/packages/massimo-cli/lib/json-schema/declarations/helpers.js b/packages/massimo-cli/lib/json-schema/declarations/helpers.js index 2aabb17..859f379 100644 --- a/packages/massimo-cli/lib/json-schema/declarations/helpers.js +++ b/packages/massimo-cli/lib/json-schema/declarations/helpers.js @@ -3,14 +3,23 @@ import { getSchemaAtPath, toRefPath } from '../core/pointer.js' import { getScannedSchemaAtPath } from '../core/scanner.js' import { isOpenObjectSchema, isRecordObjectSchema } from '../render/object.js' +/** + * Resolve the public declaration name for a path after canonical overrides have been applied. + */ export function getPreferredPathName ({ path, state }) { return state.nameOverrides?.get(path) || state.nameRegistry.getPathName({ path }) || null } +/** + * Sort canonical paths deterministically so path processing never depends on traversal order. + */ export function compareCanonicalPaths (left, right) { return left.length - right.length || left.localeCompare(right) } +/** + * Resolve `$ref` chains to the concrete schema used for declaration decisions. + */ export function resolveDeclarationSchema ({ schema, state }) { let currentSchema = schema const visitedRefs = new Set() @@ -28,6 +37,9 @@ export function resolveDeclarationSchema ({ schema, state }) { return currentSchema || schema } +/** + * Decide whether a schema should emit as an interface instead of a type alias. + */ export function shouldUseInterface ({ schema }) { return hasObjectMembers({ schema }) && !hasCombinator({ schema }) && @@ -35,14 +47,23 @@ export function shouldUseInterface ({ schema }) { !isOpenObjectSchema({ schema }) } +/** + * Check whether a schema has any object-like structure that can emit members. + */ export function hasObjectMembers ({ schema }) { return schema.type === 'object' || schema.properties || schema.additionalProperties !== undefined || schema.patternProperties } +/** + * Check whether a schema contains a union or intersection combinator. + */ export function hasCombinator ({ schema }) { return Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.allOf) } +/** + * Compute the owner scope used when reusing declarations inside union branches. + */ export function getDeclarationOwnerScopePath ({ path }) { const valueMatch = path.match(/^(.*\/properties\/value)\/properties\/[^/]+$/) if (valueMatch) { @@ -57,10 +78,16 @@ export function getDeclarationOwnerScopePath ({ path }) { return path.replace(/\/properties\/[^/]+$/, '') } +/** + * Collapse branch-specific paths into a wildcard form so sibling branches share the same scope. + */ export function normalizeUnionBranchScopePath ({ path }) { return path.replace(/\/(oneOf|anyOf)\/\d+/g, '/$1/*') } +/** + * Return metadata for object-like unions whose branches can be emitted as named interfaces. + */ export function getObjectUnionInfo ({ path, schema, state }) { const resolvedSchema = resolveDeclarationSchema({ schema, state }) const members = resolvedSchema.oneOf || resolvedSchema.anyOf @@ -88,10 +115,16 @@ export function getObjectUnionInfo ({ path, schema, state }) { } } +/** + * Check whether a path points at a property that lives inside a oneOf/anyOf branch. + */ export function isUnionBranchPropertyPath ({ path }) { return /\/(?:oneOf|anyOf)\/\d+\/properties\/[^/]+$/.test(path) } +/** + * Check whether an array path has both a named array type and a separately named item type. + */ export function isNamedArrayPath ({ path, state, schema }) { if (!state.nameRegistry.hasPathName({ path }) || !schema?.items || Array.isArray(schema.items)) { return false @@ -100,6 +133,9 @@ export function isNamedArrayPath ({ path, state, schema }) { return state.nameRegistry.hasPathName({ path: `${path}/items` }) } +/** + * Apply the declaration layer's simple singularization rules to an already-normalized name. + */ export function singularizeName ({ name }) { if (!name) { return name @@ -120,6 +156,9 @@ export function singularizeName ({ name }) { return name } +/** + * Pick the local name prefix used when renaming nested union branch declarations. + */ export function getUnionLocalPrefix ({ path, localRootName, state }) { if (isItemPath(path)) { return localRootName @@ -133,19 +172,31 @@ export function getUnionLocalPrefix ({ path, localRootName, state }) { return localRootName } +/** + * Extract the final property segment from a schema path. + */ export function getPropertyNameFromPath ({ path }) { const match = path.match(/\/properties\/([^/]+)$/) return match ? match[1] : null } +/** + * Check whether a path represents an array item schema. + */ export function isItemPath (path) { return /\/items$/.test(path) } +/** + * Check whether a path is the top-level item type for a root array property. + */ export function isTopLevelArrayItemPath ({ path }) { return /^#\/properties\/[^/]+\/items$/.test(path) } +/** + * Decide whether an omitted base-union property still needs its own public declaration. + */ export function shouldEmitOmittedUnionPropertyDeclaration ({ path, propertyName, hasBaseProperties = false }) { if (propertyName !== 'type') { return true @@ -154,6 +205,9 @@ export function shouldEmitOmittedUnionPropertyDeclaration ({ path, propertyName, return path === '#' || (!hasBaseProperties && isTopLevelArrayItemPath({ path })) } +/** + * Build the deterministic structural reuse key used for conservative declaration aliasing. + */ export function getDeclarationSchemaReuseKey ({ schema }) { if (!schema || typeof schema !== 'object' || schema.$ref) { return null @@ -162,6 +216,9 @@ export function getDeclarationSchemaReuseKey ({ schema }) { return JSON.stringify(simplifyDeclarationSchema({ schema })) } +/** + * Check whether a schema is represented as a scalar literal or primitive alias. + */ export function isScalarSchema ({ schema }) { if (!schema || typeof schema !== 'object') { return false @@ -174,6 +231,9 @@ export function isScalarSchema ({ schema }) { return ['string', 'integer', 'number', 'boolean', 'null'].includes(schema.type) } +/** + * Check whether a schema is an array of scalar values that can still carry a named item type. + */ export function isNamedScalarArraySchema ({ schema }) { if (!schema || typeof schema !== 'object' || Array.isArray(schema.items)) { return false @@ -182,14 +242,23 @@ export function isNamedScalarArraySchema ({ schema }) { return schema.type === 'array' && isScalarSchema({ schema: schema.items }) } +/** + * Limit structural alias reuse to scalar schemas that are safe to collapse globally. + */ export function isReusableScalarSchema ({ schema }) { return Array.isArray(schema?.enum) } +/** + * Check whether a path points at a scalar field inside a union branch `value` object. + */ export function isValueBranchScalarPath ({ path }) { return /\/(?:oneOf|anyOf)\/\d+\/properties\/value\/properties\/[^/]+$/.test(path) } +/** + * Derive a deterministic nested array item name from the owning branch and property name. + */ export function getNestedArrayItemName ({ branchName, propertyName }) { const propertyTypeName = normalizeTypeName(propertyName) @@ -200,6 +269,9 @@ export function getNestedArrayItemName ({ branchName, propertyName }) { return `${branchName}${singularizeName({ name: propertyTypeName })}` } +/** + * Decide whether repeated root-branch properties should collapse to a shared owner-level name. + */ export function shouldUseRootUnionPropertyName ({ entries }) { if (entries.length === 0) { return false @@ -217,6 +289,9 @@ export function shouldUseRootUnionPropertyName ({ entries }) { return entries.every(entry => !entry.schema?.title && !entry.schema?.description) } +/** + * Reduce a schema to the fields that influence declaration identity and structural reuse. + */ export function simplifyDeclarationSchema ({ schema }) { if (!schema || typeof schema !== 'object') { return schema @@ -273,6 +348,9 @@ export function simplifyDeclarationSchema ({ schema }) { return simplified } +/** + * Compute the prefix used when flattening child container names under an owner declaration. + */ export function getCollapsedOwnerPrefix ({ typeName, propertyName }) { if (!typeName || !propertyName) { return null @@ -287,6 +365,9 @@ export function getCollapsedOwnerPrefix ({ typeName, propertyName }) { return typeName } +/** + * Decide whether a container property's direct children should inherit a flatter owner-prefixed name. + */ export function shouldCollapseDirectContainerChildren ({ propertyName, schema }) { if (!schema?.properties || schema.type !== 'object') { return false @@ -302,6 +383,9 @@ export function shouldCollapseDirectContainerChildren ({ propertyName, schema }) }) } +/** + * Build a flattened child declaration name for container-owned helper types. + */ export function buildCollapsedChildName ({ ownerPrefix, propertyName, schema, fallbackName, singular = false }) { if (!ownerPrefix || !propertyName) { return fallbackName || null @@ -315,6 +399,9 @@ export function buildCollapsedChildName ({ ownerPrefix, propertyName, schema, fa return `${ownerPrefix}${suffix}` || fallbackName || null } +/** + * Compute the suffix appended to a flattened container child declaration name. + */ export function getCollapsedChildSuffix ({ ownerPrefix, propertyName, schema }) { let suffix = normalizeTypeName(propertyName) if (hasObjectMembers({ schema }) && suffix.startsWith('Default') && suffix.length > 'Default'.length) { @@ -329,11 +416,17 @@ export function getCollapsedChildSuffix ({ ownerPrefix, propertyName, schema }) return suffix } +/** + * Check whether a property name represents a generic container that should flatten its children. + */ export function isContainerPropertyName ({ propertyName }) { const normalizedName = normalizeTypeName(propertyName || '') return normalizedName.endsWith('Config') || normalizedName.endsWith('Category') } +/** + * Return a property map without the names that were omitted from a union base schema. + */ export function omitProperties ({ properties, omittedPropertyNames }) { if (!properties) { return properties diff --git a/packages/massimo-cli/lib/json-schema/declarations/index.js b/packages/massimo-cli/lib/json-schema/declarations/index.js index dc33434..99f0042 100644 --- a/packages/massimo-cli/lib/json-schema/declarations/index.js +++ b/packages/massimo-cli/lib/json-schema/declarations/index.js @@ -5,10 +5,17 @@ import { validateDeclarationGraph } from './validate.js' export { canonicalizeDeclarationState } +/** + * Build the final declaration list from scan state by canonicalizing names, building a graph, + * validating it, and then rendering it into declaration records. + */ export function buildDeclarations ({ state }) { const canonicalState = state.nameOverrides && state.structuralAliasTargets ? state : canonicalizeDeclarationState({ state }) + + // Declaration emission is intentionally split into canonicalize -> graph -> validate -> render + // so emitted names and ordering never depend on traversal side effects. const graph = buildDeclarationGraph({ state: canonicalState }) validateDeclarationGraph({ graph }) @@ -16,6 +23,9 @@ export function buildDeclarations ({ state }) { return renderDeclarationGraph({ graph }) } +/** + * Render declaration records into the final `.d.ts` text output. + */ export function renderDeclarations ({ declarations, rootName }) { const lines = [] diff --git a/packages/massimo-cli/lib/json-schema/declarations/validate.js b/packages/massimo-cli/lib/json-schema/declarations/validate.js index b68d31b..195bef9 100644 --- a/packages/massimo-cli/lib/json-schema/declarations/validate.js +++ b/packages/massimo-cli/lib/json-schema/declarations/validate.js @@ -1,3 +1,6 @@ +/** + * Validate the declaration graph before text rendering so invalid output fails fast. + */ export function validateDeclarationGraph ({ graph }) { const nameSignatures = new Map() @@ -23,6 +26,9 @@ export function validateDeclarationGraph ({ graph }) { validateDeclarationGraphCycles({ graph }) } +/** + * Reject declaration graphs that contain dependency cycles. + */ function validateDeclarationGraphCycles ({ graph }) { const visiting = new Set() const visited = new Set() @@ -50,6 +56,9 @@ function validateDeclarationGraphCycles ({ graph }) { } } +/** + * Build a comparable signature for duplicate-declaration detection. + */ function getDeclarationSignature ({ declaration }) { return JSON.stringify({ ...declaration, @@ -58,6 +67,9 @@ function getDeclarationSignature ({ declaration }) { }) } +/** + * Detect alias declarations that directly point back to themselves. + */ function isSelfReferentialAliasDeclaration ({ declaration }) { if (!declaration || declaration.kind !== 'type') { return false diff --git a/packages/massimo-cli/lib/json-schema/generator.js b/packages/massimo-cli/lib/json-schema/generator.js index 29beb0d..bd13e40 100644 --- a/packages/massimo-cli/lib/json-schema/generator.js +++ b/packages/massimo-cli/lib/json-schema/generator.js @@ -5,7 +5,11 @@ import { } from './declarations/index.js' import { scanJSONSchema } from './core/index.js' +/** + * Run the full JSON Schema to TypeScript declaration pipeline for a single schema document. + */ export function generateJSONSchemaTypes ({ schema, rootName }) { + // top-level pipeline is intentionally explicit to allow for isolated changes in each step. const scannedState = scanJSONSchema({ schema, rootName }) const canonicalState = canonicalizeDeclarationState({ state: scannedState }) const declarations = buildDeclarations({ state: canonicalState }) diff --git a/packages/massimo-cli/lib/json-schema/render/array.js b/packages/massimo-cli/lib/json-schema/render/array.js index 705114b..57f5b60 100644 --- a/packages/massimo-cli/lib/json-schema/render/array.js +++ b/packages/massimo-cli/lib/json-schema/render/array.js @@ -1,5 +1,8 @@ import { createChildRenderContext } from './render-context.js' +/** + * Render an array or tuple schema into a TypeScript array or tuple type. + */ export function renderArrayType ({ context, renderType }) { const { schema } = context diff --git a/packages/massimo-cli/lib/json-schema/render/object.js b/packages/massimo-cli/lib/json-schema/render/object.js index 2f9f7a1..6c1fdf0 100644 --- a/packages/massimo-cli/lib/json-schema/render/object.js +++ b/packages/massimo-cli/lib/json-schema/render/object.js @@ -2,6 +2,9 @@ import { getCommentLines, renderCommentBlock } from '../comments.js' import { createChildRenderContext } from './render-context.js' import { shouldInlineNamedScalarPropertyType } from '../core/scanner.js' +/** + * Render an object-like schema as either an interface body, a record, or an open object fallback. + */ export function renderObjectType ({ context, renderType }) { if (isRecordObjectSchema({ schema: context.schema })) { return renderRecordType({ context, renderType }) @@ -22,6 +25,9 @@ ${lines.join('\n')} }` } +/** + * Identify object schemas that are better represented as `Record`. + */ export function isRecordObjectSchema ({ schema }) { const hasProperties = isSchemaObject(schema.properties) && Object.keys(schema.properties).length > 0 const hasPatternProperties = isSchemaObject(schema.patternProperties) && Object.keys(schema.patternProperties).length > 0 @@ -30,6 +36,9 @@ export function isRecordObjectSchema ({ schema }) { return !hasProperties && (hasPatternProperties || hasAdditionalPropertiesObject) } +/** + * Identify open object schemas with no explicit members that should fall back to a generic record. + */ export function isOpenObjectSchema ({ schema }) { if (schema?.type !== 'object') { return false @@ -72,6 +81,9 @@ function renderRecordType ({ context, renderType }) { return `Record` } +/** + * Build the property and index-signature lines that make up an object declaration body. + */ export function buildObjectTypeLines ({ context, renderType }) { const propertyLines = renderPropertyLines({ context, renderType }) const indexLines = renderIndexSignatureLines({ context, renderType }) diff --git a/packages/massimo-cli/lib/json-schema/render/primitive.js b/packages/massimo-cli/lib/json-schema/render/primitive.js index e1b0208..ab67436 100644 --- a/packages/massimo-cli/lib/json-schema/render/primitive.js +++ b/packages/massimo-cli/lib/json-schema/render/primitive.js @@ -1,3 +1,6 @@ +/** + * Render a JSON Schema const value as a TypeScript literal type. + */ export function renderConstLiteralType ({ value }) { if (typeof value === 'string') { return JSON.stringify(value) @@ -6,6 +9,9 @@ export function renderConstLiteralType ({ value }) { return String(value) } +/** + * Render a JSON Schema enum member as a TypeScript literal type. + */ export function renderEnumLiteralType ({ value }) { if (typeof value === 'string') { return `'${value.replace(/'/g, "\\'")}'` @@ -14,10 +20,16 @@ export function renderEnumLiteralType ({ value }) { return String(value) } +/** + * Render a JSON Schema enum into a TypeScript union of literal types. + */ export function renderEnumType ({ values }) { return values.map(value => renderEnumLiteralType({ value })).join(' | ') } +/** + * Render a primitive-shaped JSON Schema node into a TypeScript type expression. + */ export function renderPrimitiveType ({ schema, singleQuoteConst = false }) { if (schema.const !== undefined) { if (singleQuoteConst && typeof schema.const === 'string') { @@ -38,6 +50,9 @@ export function renderPrimitiveType ({ schema, singleQuoteConst = false }) { return mapJSONSchemaType({ typeName: schema.type }) } +/** + * Map a JSON Schema primitive type keyword onto its TypeScript counterpart. + */ export function mapJSONSchemaType ({ typeName }) { switch (typeName) { case 'string': diff --git a/packages/massimo-cli/lib/json-schema/render/reference.js b/packages/massimo-cli/lib/json-schema/render/reference.js index 751a60c..eb90649 100644 --- a/packages/massimo-cli/lib/json-schema/render/reference.js +++ b/packages/massimo-cli/lib/json-schema/render/reference.js @@ -2,6 +2,9 @@ import { getSchemaAtPath, toRefPath } from '../core/pointer.js' import { createRenderContext } from './render-context.js' import { getAliasTargetName } from '../core/scanner.js' +/** + * Render a `$ref` either as a named alias target or by recursively rendering the referenced schema. + */ export function renderReferenceType ({ ref, context, renderType }) { const path = toRefPath(ref) const aliasTargetName = getAliasTargetName({ path, state: context }) diff --git a/packages/massimo-cli/lib/json-schema/render/render-context.js b/packages/massimo-cli/lib/json-schema/render/render-context.js index e83f96a..021e2df 100644 --- a/packages/massimo-cli/lib/json-schema/render/render-context.js +++ b/packages/massimo-cli/lib/json-schema/render/render-context.js @@ -1,3 +1,6 @@ +/** + * Create the rendering context object shared by all nested render helpers. + */ export function createRenderContext ({ schema, state, @@ -20,6 +23,9 @@ export function createRenderContext ({ } } +/** + * Create a child rendering context for a nested schema path. + */ export function createChildRenderContext ({ context, schema, pathSuffix, lookupPathName = context.lookupChildPathNames }) { return { schema, diff --git a/packages/massimo-cli/lib/json-schema/render/render-type.js b/packages/massimo-cli/lib/json-schema/render/render-type.js index 8475809..89cea33 100644 --- a/packages/massimo-cli/lib/json-schema/render/render-type.js +++ b/packages/massimo-cli/lib/json-schema/render/render-type.js @@ -5,6 +5,9 @@ import { renderReferenceType } from './reference.js' import { shouldInlineArrayPropertyType, shouldInlineNamedScalarPropertyType } from '../core/scanner.js' import { renderIntersectionType, renderUnionType } from './union.js' +/** + * Dispatch a schema node to the correct TypeScript type renderer. + */ export function renderType ({ context }) { const { schema, nameRegistry, path, lookupPathName, nameOverrides } = context @@ -61,14 +64,23 @@ export function renderType ({ context }) { return 'unknown' } +/** + * Check whether a schema uses one of the direct JSON Schema primitive type keywords. + */ function isPrimitiveSchemaType ({ schema }) { return ['string', 'integer', 'number', 'boolean', 'null'].includes(schema.type) } +/** + * Check whether a schema should be treated as object-like during rendering. + */ function hasObjectShape ({ schema }) { return schema.type === 'object' || schema.properties || schema.additionalProperties !== undefined || schema.patternProperties } +/** + * Render the active combinator expression for a schema, if any. + */ function renderCombinatorType ({ context, renderType }) { if (Array.isArray(context.schema.oneOf) || Array.isArray(context.schema.anyOf)) { return renderUnionType({ context, renderType }) diff --git a/packages/massimo-cli/lib/json-schema/render/union.js b/packages/massimo-cli/lib/json-schema/render/union.js index fda18d3..f423f64 100644 --- a/packages/massimo-cli/lib/json-schema/render/union.js +++ b/packages/massimo-cli/lib/json-schema/render/union.js @@ -1,5 +1,8 @@ import { createChildRenderContext } from './render-context.js' +/** + * Render a `oneOf` or `anyOf` schema as a TypeScript union. + */ export function renderUnionType ({ context, renderType }) { return renderCombinatorMembers({ context, @@ -10,6 +13,9 @@ export function renderUnionType ({ context, renderType }) { }) } +/** + * Render an `allOf` schema as a TypeScript intersection. + */ export function renderIntersectionType ({ context, renderType }) { return renderCombinatorMembers({ context, @@ -20,6 +26,9 @@ export function renderIntersectionType ({ context, renderType }) { }) } +/** + * Render the members of a JSON Schema combinator with the correct child paths and grouping. + */ function renderCombinatorMembers ({ context, members, separator, keyword, renderType }) { if (!Array.isArray(members) || members.length === 0) { return null @@ -45,6 +54,9 @@ function renderCombinatorMembers ({ context, members, separator, keyword, render return renderedMembers.join(separator) } +/** + * Parenthesize combinator members when needed to preserve TypeScript precedence. + */ function wrapCombinatorMember ({ memberType, separator }) { if (separator === ' & ' && memberType.includes(' | ')) { return `(${memberType})` From 6e5fd1d64723eb6f6431472426bee91bf0e574cb Mon Sep 17 00:00:00 2001 From: Safwan Parkar Date: Fri, 13 Mar 2026 00:10:07 +0100 Subject: [PATCH 6/6] chore(massimo-cli): enable json-schema generation via cli Signed-off-by: Safwan Parkar --- packages/massimo-cli/help/help.txt | 7 ++ packages/massimo-cli/index.js | 59 +++++++++++++ .../massimo-cli/test/cli-json-schema.test.js | 83 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 packages/massimo-cli/test/cli-json-schema.test.js diff --git a/packages/massimo-cli/help/help.txt b/packages/massimo-cli/help/help.txt index 43828cc..3c48c14 100644 --- a/packages/massimo-cli/help/help.txt +++ b/packages/massimo-cli/help/help.txt @@ -18,6 +18,12 @@ Instead of an URL, you can also use a local file: $ massimo path/to/schema -n myclient ``` +To generate only TypeScript types from a local JSON Schema file, use: + +```bash +$ massimo path/to/schema.json --type json-schema -n mytypes --types-only +``` + All the above commands will create a Fastify plugin that exposes a client in the `request` object for the remote API in a folder `myclient` and a file named myclient.js inside it. If platformatic config file is specified, it will be edited and a `clients` section will be added. @@ -78,3 +84,4 @@ Options: * `--retry-timeout-ms` - If passed, the HTTP request to get an Open API schema will be retried (reference: https://undici.nodejs.org/#/docs/api/RetryHandler.md) * `--type-extension` - Force the use of module-specific type extensions (.d.mts for ESM, .d.cts for CJS) instead of the default logic. * `--skip-prefixed-url` - If passed, it will avoid unnecessary extra HTTP call to check if, for instance, the `/documentation/json` Open API URL exists +* `--type json-schema` - Generate types from a local JSON Schema file. This mode currently supports local files only and emits types only. diff --git a/packages/massimo-cli/index.js b/packages/massimo-cli/index.js index fbf3ab2..15c7e2e 100755 --- a/packages/massimo-cli/index.js +++ b/packages/massimo-cli/index.js @@ -11,6 +11,7 @@ import { getGlobalDispatcher, interceptors, request } from 'undici' import YAML from 'yaml' import { processFrontendOpenAPI } from './lib/frontend-openapi-generator.js' import { processGraphQL } from './lib/graphql-generator.js' +import { processJSONSchema } from './lib/json-schema-generator.js' import { processOpenAPI } from './lib/openapi-generator.js' function parseFile (content) { @@ -223,6 +224,34 @@ async function writeGraphQLClient ( ) } +async function writeJSONSchemaTypes ({ + folder, + name, + text, + logger, + moduleFormat, + typeExtension, + explicitModuleFormat +}) { + await createDirectory(folder) + + const schema = parseFile(text) + if (!schema) { + throw new Error( + 'Cannot parse JSON Schema file. Please make sure it is valid JSON or YAML.' + ) + } + + const { types } = processJSONSchema({ + schema, + rootName: name + }) + const typeExt = await determineTypeExtension(folder, moduleFormat, typeExtension, explicitModuleFormat, false) + + logger.info(`Writing JSON Schema types to ${join(folder, `${name}.${typeExt}`)}`) + await writeFile(join(folder, `${name}.${typeExt}`), types) +} + async function downloadAndWriteOpenAPI ( logger, url, @@ -355,12 +384,27 @@ async function readFromFileAndWrite ( typesComment, withCredentials, propsOptional, + type, moduleFormat, typeExtension, explicitModuleFormat ) { logger.info(`Trying to read schema from file ${file}`) const text = await readFile(file, 'utf8') + + if (type === 'json-schema') { + await writeJSONSchemaTypes({ + folder, + name, + text, + logger, + moduleFormat, + typeExtension, + explicitModuleFormat + }) + return 'json-schema' + } + // try OpenAPI first try { await writeOpenAPIClient( @@ -434,6 +478,20 @@ async function downloadAndProcess (options) { const generateImplementation = options.generateImplementation + if (options.type === 'json-schema') { + if (url.startsWith('http')) { + throw new Error('JSON Schema generation currently supports local files only.') + } + + if (options.frontend) { + throw new Error('JSON Schema generation does not support --frontend.') + } + + if (generateImplementation) { + throw new Error('JSON Schema generation only supports type output. Use --types-only or --config.') + } + } + let found = false const toTry = [] if (url.startsWith('http')) { @@ -620,6 +678,7 @@ async function downloadAndProcess (options) { typesComment, withCredentials, propsOptional, + type, moduleFormat, typeExtension, explicitModuleFormat diff --git a/packages/massimo-cli/test/cli-json-schema.test.js b/packages/massimo-cli/test/cli-json-schema.test.js new file mode 100644 index 0000000..e6eb14c --- /dev/null +++ b/packages/massimo-cli/test/cli-json-schema.test.js @@ -0,0 +1,83 @@ +import { execa } from 'execa' +import { promises as fs } from 'fs' +import { equal, match, ok, rejects } from 'node:assert' +import { after, test } from 'node:test' +import { join } from 'path' +import { moveToTmpdir } from './helper.js' + +test('json schema type generation from local file', async () => { + const dir = await moveToTmpdir(after) + const schemaPath = join(dir, 'schema.json') + + await fs.writeFile(schemaPath, JSON.stringify({ + title: 'Claim', + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string' + } + } + }, null, 2)) + + await execa('node', [ + join(import.meta.dirname, '..', 'index.js'), + schemaPath, + '--type', + 'json-schema', + '--name', + 'claim-types', + '--types-only' + ]) + + const output = await fs.readFile(join(dir, 'claim-types', 'claim-types.d.ts'), 'utf8') + match(output, /interface ClaimTypes \{/) + match(output, /id: Id;/) + ok(!output.includes('package.json')) +}) + +test('json schema CLI rejects remote urls', async () => { + await rejects( + execa('node', [ + join(import.meta.dirname, '..', 'index.js'), + 'https://example.com/schema.json', + '--type', + 'json-schema', + '--name', + 'claim-types', + '--types-only' + ]), + error => { + equal(error.exitCode, 1) + match(error.message, /JSON Schema generation currently supports local files only\./) + return true + } + ) +}) + +test('json schema CLI rejects implementation generation', async () => { + const dir = await moveToTmpdir(after) + const schemaPath = join(dir, 'schema.json') + + await fs.writeFile(schemaPath, JSON.stringify({ + title: 'Claim', + type: 'object', + properties: {} + }, null, 2)) + + await rejects( + execa('node', [ + join(import.meta.dirname, '..', 'index.js'), + schemaPath, + '--type', + 'json-schema', + '--name', + 'claim-types' + ]), + error => { + equal(error.exitCode, 1) + match(error.message, /JSON Schema generation only supports type output\./) + return true + } + ) +})