Skip to content

Commit e28a4ff

Browse files
authored
Merge pull request #28 from fsprojects/003-recursive-types
feat: add support for recursive type schema generation
2 parents ed80ac1 + 76a8830 commit e28a4ff

21 files changed

+1294
-0
lines changed

.tool-versions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
github-cli 2.42.1

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ tests/
2424
F# 8.0+ / .NET SDK 8.0+: Follow standard conventions
2525

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

2930
- 001-core-extraction: Added F# 8.0+ / .NET SDK 8.0+
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Specification Quality Checklist: Recursive Type Schema Generation
2+
3+
**Purpose**: Validate specification completeness and quality before proceeding to planning
4+
**Created**: 2026-02-06
5+
**Feature**: [spec.md](../spec.md)
6+
7+
## Content Quality
8+
9+
- [x] No implementation details (languages, frameworks, APIs)
10+
- [x] Focused on user value and business needs
11+
- [x] Written for non-technical stakeholders
12+
- [x] All mandatory sections completed
13+
14+
## Requirement Completeness
15+
16+
- [x] No [NEEDS CLARIFICATION] markers remain
17+
- [x] Requirements are testable and unambiguous
18+
- [x] Success criteria are measurable
19+
- [x] Success criteria are technology-agnostic (no implementation details)
20+
- [x] All acceptance scenarios are defined
21+
- [x] Edge cases are identified
22+
- [x] Scope is clearly bounded
23+
- [x] Dependencies and assumptions identified
24+
25+
## Feature Readiness
26+
27+
- [x] All functional requirements have clear acceptance criteria
28+
- [x] User scenarios cover primary flows
29+
- [x] Feature meets measurable outcomes defined in Success Criteria
30+
- [x] No implementation details leak into specification
31+
32+
## Notes
33+
34+
- All items pass validation. The spec is ready for `/speckit.clarify` or `/speckit.plan`.
35+
- 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.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Expected Schema Contracts: Recursive Types
2+
3+
**Feature**: 003-recursive-types
4+
**Date**: 2026-02-06
5+
6+
## Contract 1: Self-Recursive DU (TreeNode)
7+
8+
**Input Type**:
9+
```fsharp
10+
type TreeNode =
11+
| Leaf of int
12+
| Branch of TreeNode * TreeNode
13+
```
14+
15+
**Expected JSON Schema** (NJsonSchema output, InternalTag encoding):
16+
```json
17+
{
18+
"title": "TreeNode",
19+
"definitions": {
20+
"Leaf": {
21+
"type": "object",
22+
"additionalProperties": false,
23+
"required": ["kind", "Item"],
24+
"properties": {
25+
"kind": {
26+
"type": "string",
27+
"default": "Leaf",
28+
"enum": ["Leaf"],
29+
"x-enumNames": ["Leaf"]
30+
},
31+
"Item": {
32+
"type": "integer",
33+
"format": "int32"
34+
}
35+
}
36+
},
37+
"Branch": {
38+
"type": "object",
39+
"additionalProperties": false,
40+
"required": ["kind", "Item1", "Item2"],
41+
"properties": {
42+
"kind": {
43+
"type": "string",
44+
"default": "Branch",
45+
"enum": ["Branch"],
46+
"x-enumNames": ["Branch"]
47+
},
48+
"Item1": {
49+
"$ref": "#"
50+
},
51+
"Item2": {
52+
"$ref": "#"
53+
}
54+
}
55+
}
56+
},
57+
"anyOf": [
58+
{ "$ref": "#/definitions/Leaf" },
59+
{ "$ref": "#/definitions/Branch" }
60+
]
61+
}
62+
```
63+
64+
**Key assertions**:
65+
- `Item1` and `Item2` in Branch case both use `$ref: "#"` (root self-reference)
66+
- No infinite nesting or expansion
67+
68+
## Contract 2: Self-Recursive Record (LinkedNode)
69+
70+
**Input Type**:
71+
```fsharp
72+
type LinkedNode = { Value: int; Next: LinkedNode option }
73+
```
74+
75+
**Expected JSON Schema**:
76+
```json
77+
{
78+
"title": "LinkedNode",
79+
"type": "object",
80+
"additionalProperties": false,
81+
"required": ["value"],
82+
"properties": {
83+
"value": {
84+
"type": "integer",
85+
"format": "int32"
86+
},
87+
"next": {
88+
"anyOf": [
89+
{ "$ref": "#" },
90+
{ "type": "null" }
91+
]
92+
}
93+
}
94+
}
95+
```
96+
97+
**Key assertions**:
98+
- `next` field uses `$ref: "#"` wrapped in anyOf with null (nullable self-reference)
99+
- No definitions needed (record is the root, self-reference uses "#")
100+
101+
## Contract 3: Recursion Through Collection (TreeRecord)
102+
103+
**Input Type**:
104+
```fsharp
105+
type TreeRecord = { Value: string; Children: TreeRecord list }
106+
```
107+
108+
**Expected JSON Schema**:
109+
```json
110+
{
111+
"title": "TreeRecord",
112+
"type": "object",
113+
"additionalProperties": false,
114+
"required": ["value", "children"],
115+
"properties": {
116+
"value": {
117+
"type": "string"
118+
},
119+
"children": {
120+
"type": "array",
121+
"items": {
122+
"$ref": "#"
123+
}
124+
}
125+
}
126+
}
127+
```
128+
129+
**Key assertions**:
130+
- `children` array items use `$ref: "#"` (root self-reference)
131+
- No definitions needed
132+
133+
## Contract 4: Multi-Case Self-Recursive DU (Expression)
134+
135+
**Input Type**:
136+
```fsharp
137+
type Expression =
138+
| Literal of int
139+
| Add of Expression * Expression
140+
| Negate of Expression
141+
```
142+
143+
**Expected JSON Schema**:
144+
```json
145+
{
146+
"title": "Expression",
147+
"definitions": {
148+
"Literal": {
149+
"type": "object",
150+
"additionalProperties": false,
151+
"required": ["kind", "Item"],
152+
"properties": {
153+
"kind": {
154+
"type": "string",
155+
"default": "Literal",
156+
"enum": ["Literal"],
157+
"x-enumNames": ["Literal"]
158+
},
159+
"Item": {
160+
"type": "integer",
161+
"format": "int32"
162+
}
163+
}
164+
},
165+
"Add": {
166+
"type": "object",
167+
"additionalProperties": false,
168+
"required": ["kind", "Item1", "Item2"],
169+
"properties": {
170+
"kind": {
171+
"type": "string",
172+
"default": "Add",
173+
"enum": ["Add"],
174+
"x-enumNames": ["Add"]
175+
},
176+
"Item1": {
177+
"$ref": "#"
178+
},
179+
"Item2": {
180+
"$ref": "#"
181+
}
182+
}
183+
},
184+
"Negate": {
185+
"type": "object",
186+
"additionalProperties": false,
187+
"required": ["kind", "Item"],
188+
"properties": {
189+
"kind": {
190+
"type": "string",
191+
"default": "Negate",
192+
"enum": ["Negate"],
193+
"x-enumNames": ["Negate"]
194+
},
195+
"Item": {
196+
"$ref": "#"
197+
}
198+
}
199+
}
200+
},
201+
"anyOf": [
202+
{ "$ref": "#/definitions/Literal" },
203+
{ "$ref": "#/definitions/Add" },
204+
{ "$ref": "#/definitions/Negate" }
205+
]
206+
}
207+
```
208+
209+
**Key assertions**:
210+
- All recursive case fields (Add.Item1, Add.Item2, Negate.Item) use `$ref: "#"`
211+
- Non-recursive case (Literal) has normal integer field
212+
213+
---
214+
215+
**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.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Data Model: Recursive Type Schema Generation
2+
3+
**Feature**: 003-recursive-types
4+
**Date**: 2026-02-06
5+
6+
## Entities
7+
8+
### SchemaNode (Existing - No Changes)
9+
10+
The core intermediate representation for JSON Schema nodes. Already includes the `Ref` variant that handles recursive references.
11+
12+
| Variant | Description | Recursive Relevance |
13+
|---------|-------------|-------------------|
14+
| `Ref of typeId: string` | Reference to another schema definition | `"#"` = self-reference to root; other strings = definition reference |
15+
| `AnyOf of SchemaNode list` | Union of schemas (DU representation) | Root DU produces AnyOf of Refs to case definitions |
16+
| `Object` | Object with properties | DU cases and records produce Object nodes |
17+
| `Array of SchemaNode` | Array with item schema | Items can be `Ref` for recursive collections |
18+
| `Nullable of SchemaNode` | Nullable wrapper | Inner schema can be `Ref` for recursive option fields |
19+
| Other variants | Primitive, Enum, Map, Const, OneOf, Any | Not directly involved in recursion |
20+
21+
### SchemaDocument (Existing - No Changes)
22+
23+
```
24+
SchemaDocument = {
25+
Root: SchemaNode -- Top-level schema (AnyOf for DUs, Object for records)
26+
Definitions: (string * SchemaNode) list -- Named definitions in insertion order
27+
}
28+
```
29+
30+
### Recursion Detection State (Internal - No Changes)
31+
32+
| State | Type | Purpose |
33+
|-------|------|---------|
34+
| `visiting` | `HashSet<Type>` | Tracks types currently being analyzed (cycle detection) |
35+
| `analyzed` | `Dictionary<Type, string>` | Caches completed type-to-typeId mappings |
36+
| `definitions` | `Dictionary<string, SchemaNode>` | Accumulates definitions in insertion order |
37+
38+
## Recursive Type Patterns
39+
40+
### Pattern 1: Self-Recursive DU (Issue #15)
41+
42+
**F# Type**:
43+
```fsharp
44+
type TreeNode =
45+
| Leaf of int
46+
| Branch of TreeNode * TreeNode
47+
```
48+
49+
**Expected SchemaDocument**:
50+
```
51+
Root = AnyOf [Ref "Leaf"; Ref "Branch"]
52+
Definitions = [
53+
("Leaf", Object { Properties = [kind="Leaf" (Const); Item (Primitive Int)] })
54+
("Branch", Object { Properties = [kind="Branch" (Const); Item1 (Ref "#"); Item2 (Ref "#")] })
55+
]
56+
```
57+
58+
### Pattern 2: Self-Recursive Record
59+
60+
**F# Type**:
61+
```fsharp
62+
type LinkedNode = { Value: int; Next: LinkedNode option }
63+
```
64+
65+
**Expected SchemaDocument**:
66+
```
67+
Root = Object { Properties = [value (Primitive Int); next (Nullable (Ref "#"))] }
68+
Definitions = []
69+
```
70+
71+
### Pattern 3: Recursion Through Collection
72+
73+
**F# Type**:
74+
```fsharp
75+
type TreeRecord = { Value: string; Children: TreeRecord list }
76+
```
77+
78+
**Expected SchemaDocument**:
79+
```
80+
Root = Object { Properties = [value (Primitive String); children (Array (Ref "#"))] }
81+
Definitions = []
82+
```
83+
84+
### Pattern 4: Multi-Case Self-Recursive DU
85+
86+
**F# Type**:
87+
```fsharp
88+
type Expression =
89+
| Literal of int
90+
| Add of Expression * Expression
91+
| Negate of Expression
92+
```
93+
94+
**Expected SchemaDocument**:
95+
```
96+
Root = AnyOf [Ref "Literal"; Ref "Add"; Ref "Negate"]
97+
Definitions = [
98+
("Literal", Object { Properties = [kind="Literal" (Const); Item (Primitive Int)] })
99+
("Add", Object { Properties = [kind="Add" (Const); Item1 (Ref "#"); Item2 (Ref "#")] })
100+
("Negate", Object { Properties = [kind="Negate" (Const); Item (Ref "#")] })
101+
]
102+
```
103+
104+
## Relationships
105+
106+
```
107+
SchemaAnalyzer.analyze
108+
├── analyzeType → analyzeMultiCaseDU (for DU types)
109+
│ ├── buildCaseSchema → analyzeDuCaseFieldSchema
110+
│ │ └── getOrAnalyzeRef → detects visiting set → Ref "#" or Ref typeId
111+
│ └── definitions accumulate case schemas
112+
├── analyzeType → analyzeRecord (for record types)
113+
│ └── analyzeFieldSchema → getOrAnalyzeRef → Ref "#" or Ref typeId
114+
└── Returns SchemaDocument { Root; Definitions }
115+
```
116+
117+
## State Transitions
118+
119+
No state transitions apply - schema generation is a pure analysis pass that reads F# type metadata and produces an immutable SchemaDocument.

0 commit comments

Comments
 (0)