Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Auto-generated from all feature plans. Last updated: 2026-02-05

## Active Technologies
- F# 8.0+ / .NET SDK 8.0+ + FSharp.Core, FSharp.SystemTextJson (Core); NJsonSchema (main package); Microsoft.OpenApi (OpenApi package) (002-extended-types)
- N/A (library, no persistence) (002-extended-types)

- F# 8.0+ / .NET SDK 8.0+ (001-core-extraction)

Expand All @@ -22,6 +24,7 @@ tests/
F# 8.0+ / .NET SDK 8.0+: Follow standard conventions

## Recent Changes
- 002-extended-types: Added F# 8.0+ / .NET SDK 8.0+ + FSharp.Core, FSharp.SystemTextJson (Core); NJsonSchema (main package); Microsoft.OpenApi (OpenApi package)

- 001-core-extraction: Added F# 8.0+ / .NET SDK 8.0+

Expand Down
44 changes: 44 additions & 0 deletions specs/002-extended-types/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Specification Quality Checklist: Extended Type Support for JSON Schema Generation

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-06
**Feature**: [Extended Type Support](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain that block implementation
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows (6 prioritized user stories)
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Clarifications Resolved (Session 2026-02-06)

✅ **Q1 - Scope Narrowing**: Narrowed from 6 stories to 4 actual gaps (anonymous records, Choice types, DU encoding styles, custom format annotations). Map, Set, and built-in formats already implemented.

✅ **Q2 - DU Attribute Detection**: Per-type `[<JsonFSharpConverter>]` attributes override global config (not just global config).

✅ **Q3 - Field Representation**: NamedFields only for all DU encoding styles. Positional array representation out of scope.

## Validation Notes

- **Strengths**: Spec tightly scoped to actual codebase gaps; all 4 DU encoding styles covered; per-type attribute overrides included; Choice type encoding clear; backwards compatibility explicitly protected
- **Status**: ✅ Specification COMPLETE and ready for planning
- **Next Step**: Ready for /speckit.plan to generate implementation design artifacts
53 changes: 53 additions & 0 deletions specs/002-extended-types/contracts/api-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# API Surface Contracts: Extended Type Support

**Feature**: 002-extended-types | **Date**: 2026-02-06

This feature is a library (not a service), so contracts describe the public API surface changes rather than HTTP endpoints.

## New Public Types

### FSharp.Data.JsonSchema.Core

```fsharp
/// New attribute for custom format annotations.
[<AttributeUsage(AttributeTargets.Property ||| AttributeTargets.Field, AllowMultiple = false)>]
type JsonSchemaFormatAttribute(format: string) =
inherit Attribute()
member _.Format : string
```

No other new public types are introduced.

## Modified Behavior (Internal, No API Surface Change)

### SchemaAnalyzer.analyze

**Before**: Ignores `config.UnionEncoding`, hardcodes InternalTag for all DUs. Does not recognize Choice or anonymous record types.

**After**:
1. Reads `config.UnionEncoding` as global default
2. Detects `[<JsonFSharpConverter(UnionEncoding = ...)>]` per-type attributes (overrides config)
3. Recognizes `Choice<'a,'b>` through `Choice<'a,...,'g>` → produces `SchemaNode.AnyOf`
4. Recognizes anonymous records → produces inline `SchemaNode.Object` (no `$ref`)
5. Reads `[<JsonSchemaFormat>]` attributes on properties → overrides built-in format inference

### SchemaGeneratorConfig

No changes to the type definition. The existing `UnionEncoding` field will now be read and respected.

## Backwards Compatibility Contract

| Scenario | Guarantee |
|----------|-----------|
| Existing InternalTag DUs (default config) | Byte-identical schema output |
| Existing records | No change |
| Existing Map/Set/Dictionary | No change |
| Existing built-in format inference | No change (attribute overrides only when present) |
| Existing snapshot tests (141) | All pass unchanged |

## Version Impact

- **Package version**: MINOR bump (new additive capabilities, no breaking changes)
- **FSharp.Data.JsonSchema.Core**: 1.0.0 → 1.1.0
- **FSharp.Data.JsonSchema**: 3.0.0 → 3.1.0
- **FSharp.Data.JsonSchema.OpenApi**: 1.0.0 → 1.1.0
215 changes: 215 additions & 0 deletions specs/002-extended-types/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# Data Model: Extended Type Support

**Feature**: 002-extended-types | **Date**: 2026-02-06

## Existing IR (No Changes Required)

The current `SchemaNode` discriminated union is expressive enough for all 4 encoding styles, anonymous records, and Choice types. No new variants are needed.

```fsharp
type SchemaNode =
| Object of ObjectSchema // Used for: records, anonymous records, DU cases (InternalTag, AdjacentTag, ExternalTag)
| Array of items: SchemaNode // (no changes)
| AnyOf of schemas: SchemaNode list // Used for: Choice types, Untagged DUs, multi-case DUs
| OneOf of schemas: SchemaNode list * discriminator: Discriminator option // (no changes)
| Nullable of inner: SchemaNode // (no changes)
| Primitive of primitiveType: PrimitiveType * format: string option // format carries custom annotations
| Enum of values: string list * underlyingType: PrimitiveType // (no changes)
| Ref of typeId: string // (no changes)
| Map of valueSchema: SchemaNode // (no changes)
| Const of value: string * primitiveType: PrimitiveType // Used for: DU tag values
| Any // (no changes)
```

## Existing Config (No Changes Required)

```fsharp
type SchemaGeneratorConfig = {
UnionEncoding: UnionEncodingStyle // EXISTING but currently unused — will be read by analyzer
DiscriminatorPropertyName: string // Used for InternalTag (default: "kind")
PropertyNamingPolicy: string -> string // Applied to all field names
AdditionalPropertiesDefault: bool // Applied to anonymous records too
TypeIdResolver: Type -> string // (no changes)
OptionStyle: OptionSchemaStyle // (no changes)
UnwrapSingleCaseDU: bool // (no changes)
RecordFieldsRequired: bool // Applied to anonymous records too
UnwrapFieldlessTags: bool // (no changes)
}
```

## New Types

### JsonSchemaFormatAttribute (new, in Core)

```fsharp
namespace FSharp.Data.JsonSchema.Core

open System

/// Specifies a custom JSON Schema format string for the annotated property or type.
/// When present, overrides built-in format inference (e.g., DateTime → "date-time").
[<AttributeUsage(AttributeTargets.Property ||| AttributeTargets.Field, AllowMultiple = false)>]
type JsonSchemaFormatAttribute(format: string) =
inherit Attribute()
member _.Format = format
```

**Relationships**:
- Read by `SchemaAnalyzer.analyzeType` during field/property analysis
- Produces `SchemaNode.Primitive(type, Some format)` overriding built-in inference

## Schema Shape by Encoding Style

### InternalTag (existing — no change)

For a DU case `Case1 of name: string * value: int`:

```
SchemaNode.Object {
Properties = [
{ Name = "kind"; Schema = Const("Case1", String) } // discriminator inside
{ Name = "name"; Schema = Primitive(String, None) }
{ Name = "value"; Schema = Primitive(Integer, None) }
]
Required = ["kind"; "name"; "value"]
TypeId = Some "Case1"
}
```

### AdjacentTag (new)

Same case produces:

```
SchemaNode.Object {
Properties = [
{ Name = "Case"; Schema = Const("Case1", String) } // tag property
{ Name = "Fields"; Schema = Object { // fields property
Properties = [
{ Name = "name"; Schema = Primitive(String, None) }
{ Name = "value"; Schema = Primitive(Integer, None) }
]
Required = ["name"; "value"]
TypeId = None
}}
]
Required = ["Case"; "Fields"]
TypeId = Some "Case1"
}
```

For a fieldless case `Case2`:

```
SchemaNode.Object {
Properties = [
{ Name = "Case"; Schema = Const("Case2", String) }
]
Required = ["Case"]
TypeId = Some "Case2"
}
```

### ExternalTag (new)

Same case produces:

```
SchemaNode.Object {
Properties = [
{ Name = "Case1"; Schema = Object { // case name is the key
Properties = [
{ Name = "name"; Schema = Primitive(String, None) }
{ Name = "value"; Schema = Primitive(Integer, None) }
]
Required = ["name"; "value"]
TypeId = None
}}
]
Required = ["Case1"]
TypeId = Some "Case1"
}
```

For a fieldless case `Case2`:

```
SchemaNode.Object {
Properties = [
{ Name = "Case2"; Schema = Object {
Properties = []
Required = []
TypeId = None
}}
]
Required = ["Case2"]
TypeId = Some "Case2"
}
```

### Untagged (new)

Same case produces (no discriminator, just fields):

```
SchemaNode.Object {
Properties = [
{ Name = "name"; Schema = Primitive(String, None) }
{ Name = "value"; Schema = Primitive(Integer, None) }
]
Required = ["name"; "value"]
TypeId = Some "Case1"
}
```

For a fieldless case: `SchemaNode.Const("Case2", PrimitiveType.String)` (same as current behavior when unwrapping fieldless tags).

### Multi-case DU assembly

All encoding styles combine cases the same way:
- Root DU: register each case in definitions, produce `SchemaNode.AnyOf` of `SchemaNode.Ref` entries
- Non-root DU: inline case schemas in `SchemaNode.AnyOf`

Exception: Untagged DUs do not use discriminator in `OneOf` — they use plain `AnyOf`.

## Choice Type Schema Shape

For `Choice<string, int>`:

```
SchemaNode.AnyOf [
SchemaNode.Primitive(String, None)
SchemaNode.Primitive(Integer, None)
]
```

For `Choice<string, ComplexRecord>`:

```
SchemaNode.AnyOf [
SchemaNode.Primitive(String, None)
SchemaNode.Ref "ComplexRecord"
]
```

(ComplexRecord added to definitions as usual.)

## Anonymous Record Schema Shape

For `{| Name: string; Age: int |}`:

```
SchemaNode.Object {
Properties = [
{ Name = "name"; Schema = Primitive(String, None) } // naming policy applied
{ Name = "age"; Schema = Primitive(Integer, None) }
]
Required = ["name"; "age"]
AdditionalProperties = false // from config
TypeId = None // anonymous records have no stable type identity
Description = None
Title = None // no meaningful name
}
```

Key difference from named records: `TypeId = None` and `Title = None`, so anonymous records are always inlined (never produce `$ref` definitions).
Loading
Loading