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
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github-cli 2.42.1
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tests/
F# 8.0+ / .NET SDK 8.0+: Follow standard conventions

## Recent Changes
- 003-recursive-types: Added F# 8.0+ / .NET SDK 8.0+ + FSharp.Core, FSharp.SystemTextJson (Core); NJsonSchema (main package); Microsoft.OpenApi (OpenApi package)
- 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
35 changes: 35 additions & 0 deletions specs/003-recursive-types/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Recursive Type Schema Generation

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-06
**Feature**: [spec.md](../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
- [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
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- All items pass validation. The spec is ready for `/speckit.clarify` or `/speckit.plan`.
- The Assumptions section acknowledges that existing recursion detection infrastructure likely already handles this; the feature is primarily about validation, test coverage, and documenting the behavior.
215 changes: 215 additions & 0 deletions specs/003-recursive-types/contracts/expected-schemas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# Expected Schema Contracts: Recursive Types

**Feature**: 003-recursive-types
**Date**: 2026-02-06

## Contract 1: Self-Recursive DU (TreeNode)

**Input Type**:
```fsharp
type TreeNode =
| Leaf of int
| Branch of TreeNode * TreeNode
```

**Expected JSON Schema** (NJsonSchema output, InternalTag encoding):
```json
{
"title": "TreeNode",
"definitions": {
"Leaf": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "Item"],
"properties": {
"kind": {
"type": "string",
"default": "Leaf",
"enum": ["Leaf"],
"x-enumNames": ["Leaf"]
},
"Item": {
"type": "integer",
"format": "int32"
}
}
},
"Branch": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "Item1", "Item2"],
"properties": {
"kind": {
"type": "string",
"default": "Branch",
"enum": ["Branch"],
"x-enumNames": ["Branch"]
},
"Item1": {
"$ref": "#"
},
"Item2": {
"$ref": "#"
}
}
}
},
"anyOf": [
{ "$ref": "#/definitions/Leaf" },
{ "$ref": "#/definitions/Branch" }
]
}
```

**Key assertions**:
- `Item1` and `Item2` in Branch case both use `$ref: "#"` (root self-reference)
- No infinite nesting or expansion

## Contract 2: Self-Recursive Record (LinkedNode)

**Input Type**:
```fsharp
type LinkedNode = { Value: int; Next: LinkedNode option }
```

**Expected JSON Schema**:
```json
{
"title": "LinkedNode",
"type": "object",
"additionalProperties": false,
"required": ["value"],
"properties": {
"value": {
"type": "integer",
"format": "int32"
},
"next": {
"anyOf": [
{ "$ref": "#" },
{ "type": "null" }
]
}
}
}
```

**Key assertions**:
- `next` field uses `$ref: "#"` wrapped in anyOf with null (nullable self-reference)
- No definitions needed (record is the root, self-reference uses "#")

## Contract 3: Recursion Through Collection (TreeRecord)

**Input Type**:
```fsharp
type TreeRecord = { Value: string; Children: TreeRecord list }
```

**Expected JSON Schema**:
```json
{
"title": "TreeRecord",
"type": "object",
"additionalProperties": false,
"required": ["value", "children"],
"properties": {
"value": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"$ref": "#"
}
}
}
}
```

**Key assertions**:
- `children` array items use `$ref: "#"` (root self-reference)
- No definitions needed

## Contract 4: Multi-Case Self-Recursive DU (Expression)

**Input Type**:
```fsharp
type Expression =
| Literal of int
| Add of Expression * Expression
| Negate of Expression
```

**Expected JSON Schema**:
```json
{
"title": "Expression",
"definitions": {
"Literal": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "Item"],
"properties": {
"kind": {
"type": "string",
"default": "Literal",
"enum": ["Literal"],
"x-enumNames": ["Literal"]
},
"Item": {
"type": "integer",
"format": "int32"
}
}
},
"Add": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "Item1", "Item2"],
"properties": {
"kind": {
"type": "string",
"default": "Add",
"enum": ["Add"],
"x-enumNames": ["Add"]
},
"Item1": {
"$ref": "#"
},
"Item2": {
"$ref": "#"
}
}
},
"Negate": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "Item"],
"properties": {
"kind": {
"type": "string",
"default": "Negate",
"enum": ["Negate"],
"x-enumNames": ["Negate"]
},
"Item": {
"$ref": "#"
}
}
}
},
"anyOf": [
{ "$ref": "#/definitions/Literal" },
{ "$ref": "#/definitions/Add" },
{ "$ref": "#/definitions/Negate" }
]
}
```

**Key assertions**:
- All recursive case fields (Add.Item1, Add.Item2, Negate.Item) use `$ref: "#"`
- Non-recursive case (Literal) has normal integer field

---

**Note**: These schemas are approximate. The actual output will be determined by running the tests and capturing verified snapshots. Minor formatting differences (property ordering, whitespace) may vary.
119 changes: 119 additions & 0 deletions specs/003-recursive-types/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Data Model: Recursive Type Schema Generation

**Feature**: 003-recursive-types
**Date**: 2026-02-06

## Entities

### SchemaNode (Existing - No Changes)

The core intermediate representation for JSON Schema nodes. Already includes the `Ref` variant that handles recursive references.

| Variant | Description | Recursive Relevance |
|---------|-------------|-------------------|
| `Ref of typeId: string` | Reference to another schema definition | `"#"` = self-reference to root; other strings = definition reference |
| `AnyOf of SchemaNode list` | Union of schemas (DU representation) | Root DU produces AnyOf of Refs to case definitions |
| `Object` | Object with properties | DU cases and records produce Object nodes |
| `Array of SchemaNode` | Array with item schema | Items can be `Ref` for recursive collections |
| `Nullable of SchemaNode` | Nullable wrapper | Inner schema can be `Ref` for recursive option fields |
| Other variants | Primitive, Enum, Map, Const, OneOf, Any | Not directly involved in recursion |

### SchemaDocument (Existing - No Changes)

```
SchemaDocument = {
Root: SchemaNode -- Top-level schema (AnyOf for DUs, Object for records)
Definitions: (string * SchemaNode) list -- Named definitions in insertion order
}
```

### Recursion Detection State (Internal - No Changes)

| State | Type | Purpose |
|-------|------|---------|
| `visiting` | `HashSet<Type>` | Tracks types currently being analyzed (cycle detection) |
| `analyzed` | `Dictionary<Type, string>` | Caches completed type-to-typeId mappings |
| `definitions` | `Dictionary<string, SchemaNode>` | Accumulates definitions in insertion order |

## Recursive Type Patterns

### Pattern 1: Self-Recursive DU (Issue #15)

**F# Type**:
```fsharp
type TreeNode =
| Leaf of int
| Branch of TreeNode * TreeNode
```

**Expected SchemaDocument**:
```
Root = AnyOf [Ref "Leaf"; Ref "Branch"]
Definitions = [
("Leaf", Object { Properties = [kind="Leaf" (Const); Item (Primitive Int)] })
("Branch", Object { Properties = [kind="Branch" (Const); Item1 (Ref "#"); Item2 (Ref "#")] })
]
```

### Pattern 2: Self-Recursive Record

**F# Type**:
```fsharp
type LinkedNode = { Value: int; Next: LinkedNode option }
```

**Expected SchemaDocument**:
```
Root = Object { Properties = [value (Primitive Int); next (Nullable (Ref "#"))] }
Definitions = []
```

### Pattern 3: Recursion Through Collection

**F# Type**:
```fsharp
type TreeRecord = { Value: string; Children: TreeRecord list }
```

**Expected SchemaDocument**:
```
Root = Object { Properties = [value (Primitive String); children (Array (Ref "#"))] }
Definitions = []
```

### Pattern 4: Multi-Case Self-Recursive DU

**F# Type**:
```fsharp
type Expression =
| Literal of int
| Add of Expression * Expression
| Negate of Expression
```

**Expected SchemaDocument**:
```
Root = AnyOf [Ref "Literal"; Ref "Add"; Ref "Negate"]
Definitions = [
("Literal", Object { Properties = [kind="Literal" (Const); Item (Primitive Int)] })
("Add", Object { Properties = [kind="Add" (Const); Item1 (Ref "#"); Item2 (Ref "#")] })
("Negate", Object { Properties = [kind="Negate" (Const); Item (Ref "#")] })
]
```

## Relationships

```
SchemaAnalyzer.analyze
├── analyzeType → analyzeMultiCaseDU (for DU types)
│ ├── buildCaseSchema → analyzeDuCaseFieldSchema
│ │ └── getOrAnalyzeRef → detects visiting set → Ref "#" or Ref typeId
│ └── definitions accumulate case schemas
├── analyzeType → analyzeRecord (for record types)
│ └── analyzeFieldSchema → getOrAnalyzeRef → Ref "#" or Ref typeId
└── Returns SchemaDocument { Root; Definitions }
```

## State Transitions

No state transitions apply - schema generation is a pure analysis pass that reads F# type metadata and produces an immutable SchemaDocument.
Loading
Loading