dopespec 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +195 -0
  2. package/bin/dopespec.mjs +26 -0
  3. package/dist/__tests__/codegen.test.d.ts +2 -0
  4. package/dist/__tests__/codegen.test.d.ts.map +1 -0
  5. package/dist/__tests__/codegen.test.js +713 -0
  6. package/dist/__tests__/codegen.test.js.map +1 -0
  7. package/dist/__tests__/e2e-proof.test.d.ts +2 -0
  8. package/dist/__tests__/e2e-proof.test.d.ts.map +1 -0
  9. package/dist/__tests__/e2e-proof.test.js +153 -0
  10. package/dist/__tests__/e2e-proof.test.js.map +1 -0
  11. package/dist/__tests__/schema.test.d.ts +2 -0
  12. package/dist/__tests__/schema.test.d.ts.map +1 -0
  13. package/dist/__tests__/schema.test.js +822 -0
  14. package/dist/__tests__/schema.test.js.map +1 -0
  15. package/dist/__tests__/type-errors.d.ts +2 -0
  16. package/dist/__tests__/type-errors.d.ts.map +1 -0
  17. package/dist/__tests__/type-errors.js +128 -0
  18. package/dist/__tests__/type-errors.js.map +1 -0
  19. package/dist/cli/generate.d.ts +2 -0
  20. package/dist/cli/generate.d.ts.map +1 -0
  21. package/dist/cli/generate.js +250 -0
  22. package/dist/cli/generate.js.map +1 -0
  23. package/dist/codegen/commands.d.ts +4 -0
  24. package/dist/codegen/commands.d.ts.map +1 -0
  25. package/dist/codegen/commands.js +25 -0
  26. package/dist/codegen/commands.js.map +1 -0
  27. package/dist/codegen/decisions-evaluate.d.ts +3 -0
  28. package/dist/codegen/decisions-evaluate.d.ts.map +1 -0
  29. package/dist/codegen/decisions-evaluate.js +50 -0
  30. package/dist/codegen/decisions-evaluate.js.map +1 -0
  31. package/dist/codegen/decisions-table.d.ts +3 -0
  32. package/dist/codegen/decisions-table.d.ts.map +1 -0
  33. package/dist/codegen/decisions-table.js +27 -0
  34. package/dist/codegen/decisions-table.js.map +1 -0
  35. package/dist/codegen/decisions-tests.d.ts +3 -0
  36. package/dist/codegen/decisions-tests.d.ts.map +1 -0
  37. package/dist/codegen/decisions-tests.js +43 -0
  38. package/dist/codegen/decisions-tests.js.map +1 -0
  39. package/dist/codegen/e2e-stubs.d.ts +4 -0
  40. package/dist/codegen/e2e-stubs.d.ts.map +1 -0
  41. package/dist/codegen/e2e-stubs.js +21 -0
  42. package/dist/codegen/e2e-stubs.js.map +1 -0
  43. package/dist/codegen/events.d.ts +8 -0
  44. package/dist/codegen/events.d.ts.map +1 -0
  45. package/dist/codegen/events.js +36 -0
  46. package/dist/codegen/events.js.map +1 -0
  47. package/dist/codegen/index.d.ts +18 -0
  48. package/dist/codegen/index.d.ts.map +1 -0
  49. package/dist/codegen/index.js +18 -0
  50. package/dist/codegen/index.js.map +1 -0
  51. package/dist/codegen/invariants.d.ts +4 -0
  52. package/dist/codegen/invariants.d.ts.map +1 -0
  53. package/dist/codegen/invariants.js +53 -0
  54. package/dist/codegen/invariants.js.map +1 -0
  55. package/dist/codegen/mermaid.d.ts +4 -0
  56. package/dist/codegen/mermaid.d.ts.map +1 -0
  57. package/dist/codegen/mermaid.js +21 -0
  58. package/dist/codegen/mermaid.js.map +1 -0
  59. package/dist/codegen/orchestrators.d.ts +7 -0
  60. package/dist/codegen/orchestrators.d.ts.map +1 -0
  61. package/dist/codegen/orchestrators.js +32 -0
  62. package/dist/codegen/orchestrators.js.map +1 -0
  63. package/dist/codegen/policy-index.d.ts +7 -0
  64. package/dist/codegen/policy-index.d.ts.map +1 -0
  65. package/dist/codegen/policy-index.js +40 -0
  66. package/dist/codegen/policy-index.js.map +1 -0
  67. package/dist/codegen/policy-mermaid.d.ts +7 -0
  68. package/dist/codegen/policy-mermaid.d.ts.map +1 -0
  69. package/dist/codegen/policy-mermaid.js +30 -0
  70. package/dist/codegen/policy-mermaid.js.map +1 -0
  71. package/dist/codegen/policy-tests.d.ts +8 -0
  72. package/dist/codegen/policy-tests.d.ts.map +1 -0
  73. package/dist/codegen/policy-tests.js +167 -0
  74. package/dist/codegen/policy-tests.js.map +1 -0
  75. package/dist/codegen/policy-validator.d.ts +8 -0
  76. package/dist/codegen/policy-validator.d.ts.map +1 -0
  77. package/dist/codegen/policy-validator.js +69 -0
  78. package/dist/codegen/policy-validator.js.map +1 -0
  79. package/dist/codegen/tests.d.ts +4 -0
  80. package/dist/codegen/tests.d.ts.map +1 -0
  81. package/dist/codegen/tests.js +125 -0
  82. package/dist/codegen/tests.js.map +1 -0
  83. package/dist/codegen/transitions.d.ts +4 -0
  84. package/dist/codegen/transitions.d.ts.map +1 -0
  85. package/dist/codegen/transitions.js +41 -0
  86. package/dist/codegen/transitions.js.map +1 -0
  87. package/dist/codegen/types.d.ts +4 -0
  88. package/dist/codegen/types.d.ts.map +1 -0
  89. package/dist/codegen/types.js +48 -0
  90. package/dist/codegen/types.js.map +1 -0
  91. package/dist/codegen/utils.d.ts +85 -0
  92. package/dist/codegen/utils.d.ts.map +1 -0
  93. package/dist/codegen/utils.js +357 -0
  94. package/dist/codegen/utils.js.map +1 -0
  95. package/dist/codegen/zod.d.ts +4 -0
  96. package/dist/codegen/zod.d.ts.map +1 -0
  97. package/dist/codegen/zod.js +32 -0
  98. package/dist/codegen/zod.js.map +1 -0
  99. package/dist/examples/generated/customer.types.d.ts +7 -0
  100. package/dist/examples/generated/customer.types.d.ts.map +1 -0
  101. package/dist/examples/generated/customer.types.js +2 -0
  102. package/dist/examples/generated/customer.types.js.map +1 -0
  103. package/dist/examples/generated/order.types.d.ts +9 -0
  104. package/dist/examples/generated/order.types.d.ts.map +1 -0
  105. package/dist/examples/generated/order.types.js +2 -0
  106. package/dist/examples/generated/order.types.js.map +1 -0
  107. package/dist/examples/generated/pet.types.d.ts +11 -0
  108. package/dist/examples/generated/pet.types.d.ts.map +1 -0
  109. package/dist/examples/generated/pet.types.js +2 -0
  110. package/dist/examples/generated/pet.types.js.map +1 -0
  111. package/dist/examples/pet-store.d.ts +137 -0
  112. package/dist/examples/pet-store.d.ts.map +1 -0
  113. package/dist/examples/pet-store.js +139 -0
  114. package/dist/examples/pet-store.js.map +1 -0
  115. package/dist/examples/src/customer.e2e.d.ts +2 -0
  116. package/dist/examples/src/customer.e2e.d.ts.map +1 -0
  117. package/dist/examples/src/customer.e2e.js +17 -0
  118. package/dist/examples/src/customer.e2e.js.map +1 -0
  119. package/dist/examples/src/customer.orchestrators.d.ts +5 -0
  120. package/dist/examples/src/customer.orchestrators.d.ts.map +1 -0
  121. package/dist/examples/src/customer.orchestrators.js +5 -0
  122. package/dist/examples/src/customer.orchestrators.js.map +1 -0
  123. package/dist/examples/src/order.e2e.d.ts +2 -0
  124. package/dist/examples/src/order.e2e.d.ts.map +1 -0
  125. package/dist/examples/src/order.e2e.js +22 -0
  126. package/dist/examples/src/order.e2e.js.map +1 -0
  127. package/dist/examples/src/order.orchestrators.d.ts +9 -0
  128. package/dist/examples/src/order.orchestrators.d.ts.map +1 -0
  129. package/dist/examples/src/order.orchestrators.js +10 -0
  130. package/dist/examples/src/order.orchestrators.js.map +1 -0
  131. package/dist/examples/src/pet.e2e.d.ts +2 -0
  132. package/dist/examples/src/pet.e2e.d.ts.map +1 -0
  133. package/dist/examples/src/pet.e2e.js +17 -0
  134. package/dist/examples/src/pet.e2e.js.map +1 -0
  135. package/dist/examples/src/pet.orchestrators.d.ts +8 -0
  136. package/dist/examples/src/pet.orchestrators.d.ts.map +1 -0
  137. package/dist/examples/src/pet.orchestrators.js +9 -0
  138. package/dist/examples/src/pet.orchestrators.js.map +1 -0
  139. package/dist/schema/actions.d.ts +37 -0
  140. package/dist/schema/actions.d.ts.map +1 -0
  141. package/dist/schema/actions.js +35 -0
  142. package/dist/schema/actions.js.map +1 -0
  143. package/dist/schema/constraints.d.ts +20 -0
  144. package/dist/schema/constraints.d.ts.map +1 -0
  145. package/dist/schema/constraints.js +15 -0
  146. package/dist/schema/constraints.js.map +1 -0
  147. package/dist/schema/decisions.d.ts +29 -0
  148. package/dist/schema/decisions.d.ts.map +1 -0
  149. package/dist/schema/decisions.js +56 -0
  150. package/dist/schema/decisions.js.map +1 -0
  151. package/dist/schema/index.d.ts +17 -0
  152. package/dist/schema/index.d.ts.map +1 -0
  153. package/dist/schema/index.js +8 -0
  154. package/dist/schema/index.js.map +1 -0
  155. package/dist/schema/model.d.ts +65 -0
  156. package/dist/schema/model.d.ts.map +1 -0
  157. package/dist/schema/model.js +30 -0
  158. package/dist/schema/model.js.map +1 -0
  159. package/dist/schema/policy.d.ts +25 -0
  160. package/dist/schema/policy.d.ts.map +1 -0
  161. package/dist/schema/policy.js +53 -0
  162. package/dist/schema/policy.js.map +1 -0
  163. package/dist/schema/props.d.ts +57 -0
  164. package/dist/schema/props.d.ts.map +1 -0
  165. package/dist/schema/props.js +53 -0
  166. package/dist/schema/props.js.map +1 -0
  167. package/dist/schema/relations.d.ts +9 -0
  168. package/dist/schema/relations.d.ts.map +1 -0
  169. package/dist/schema/relations.js +9 -0
  170. package/dist/schema/relations.js.map +1 -0
  171. package/dist/schema/transitions.d.ts +34 -0
  172. package/dist/schema/transitions.d.ts.map +1 -0
  173. package/dist/schema/transitions.js +25 -0
  174. package/dist/schema/transitions.js.map +1 -0
  175. package/dist/schema/types.d.ts +18 -0
  176. package/dist/schema/types.d.ts.map +1 -0
  177. package/dist/schema/types.js +11 -0
  178. package/dist/schema/types.js.map +1 -0
  179. package/package.json +55 -0
@@ -0,0 +1,713 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import ts from "typescript";
5
+ import { describe, expect, it } from "vitest";
6
+ import { z } from "zod";
7
+ import { generateCommands, generateDecisionEvaluate, generateDecisionTable, generateDecisionTests, generateE2EStubs, generateEvents, generateInvariants, generateMermaid, generateOrchestrators, generatePolicyIndex, generatePolicyMermaid, generatePolicyTests, generatePolicyValidator, generateTests, generateTransitions, generateTypes, generateZod, } from "../codegen/index.js";
8
+ import { guardToSource, relationIdField, resolvePolicyGuardBody, valueToSource, } from "../codegen/utils.js";
9
+ import { CreditTier, Customer, NoSuspendedCustomerOrders, Order, Pet, } from "../examples/pet-store.js";
10
+ import { action } from "../schema/actions.js";
11
+ import { decisions } from "../schema/decisions.js";
12
+ import { model } from "../schema/model.js";
13
+ import { boolean, number, oneOf, optional, string } from "../schema/props.js";
14
+ // Minimal model with no optional fields
15
+ const Minimal = model("Minimal", {});
16
+ // Model with actions but no fields metadata
17
+ const NoFields = model("NoFields", {
18
+ actions: {
19
+ doSomething: action(),
20
+ },
21
+ });
22
+ // Model with action that has explicit empty fields
23
+ const EmptyFields = model("EmptyFields", {
24
+ actions: {
25
+ ping: action({}),
26
+ },
27
+ });
28
+ // Model with an optional oneOf prop
29
+ const WithOptionalEnum = model("Widget", {
30
+ props: {
31
+ color: optional(oneOf(["red", "blue", "green"])),
32
+ name: string(),
33
+ },
34
+ });
35
+ // --- generateTypes ---
36
+ describe("generateTypes", () => {
37
+ it("generates status union and props interface for Order", () => {
38
+ const output = generateTypes(Order);
39
+ expect(output).toContain("OrderStatus");
40
+ expect(output).toContain("'pending'");
41
+ expect(output).toContain("'paid'");
42
+ expect(output).toContain("'shipped'");
43
+ expect(output).toContain("'delivered'");
44
+ expect(output).toContain("'cancelled'");
45
+ expect(output).toContain("OrderProps");
46
+ expect(output).toContain("total: number");
47
+ expect(output).toContain("createdAt: Date");
48
+ expect(output).toContain("status: OrderStatus");
49
+ });
50
+ it("generates oneOf types (not lifecycle) for Pet", () => {
51
+ const output = generateTypes(Pet);
52
+ expect(output).toContain("PetStatus");
53
+ expect(output).toContain("'available'");
54
+ expect(output).toContain("vaccinated: boolean");
55
+ });
56
+ it("includes relation fields in Order props", () => {
57
+ const output = generateTypes(Order);
58
+ expect(output).toContain("customerId: string; // belongsTo Customer");
59
+ expect(output).toContain("itemIds: string[]; // hasMany Pet");
60
+ });
61
+ it("documents singular relation keys for clean output", () => {
62
+ // Verify pet-store uses singular key "item" not "items"
63
+ const output = generateTypes(Order);
64
+ expect(output).not.toContain("itemsIds");
65
+ });
66
+ it("returns empty string for minimal model", () => {
67
+ expect(generateTypes(Minimal)).toBe("");
68
+ });
69
+ it("generates optional prop with ? marker", () => {
70
+ const output = generateTypes(Pet);
71
+ expect(output).toContain("nickname?: string");
72
+ });
73
+ it("generates required props without ? marker", () => {
74
+ const output = generateTypes(Pet);
75
+ expect(output).toMatch(/\bname: string/);
76
+ expect(output).toMatch(/\bprice: number/);
77
+ });
78
+ it("generates optional oneOf prop with ? and union type", () => {
79
+ const output = generateTypes(WithOptionalEnum);
80
+ expect(output).toContain("color?: WidgetColor");
81
+ expect(output).toContain("name: string");
82
+ });
83
+ });
84
+ // --- generateTransitions ---
85
+ describe("generateTransitions", () => {
86
+ it("generates transition functions with model-prefixed names", () => {
87
+ const output = generateTransitions(Order);
88
+ expect(output).toContain("function OrderPay");
89
+ expect(output).toContain("function OrderShip");
90
+ expect(output).toContain("function OrderCancel");
91
+ expect(output).toContain("function OrderDeliver");
92
+ expect(output).toContain("ctx.status !== 'pending'");
93
+ expect(output).toContain("status: 'paid'");
94
+ });
95
+ it("imports props type from convention path", () => {
96
+ const output = generateTransitions(Order);
97
+ expect(output).toContain("import type { OrderProps } from './order.types.js'");
98
+ });
99
+ it("includes guard check for guarded transitions", () => {
100
+ const output = generateTransitions(Order);
101
+ expect(output).toContain("ctx.total > 0");
102
+ expect(output).toContain("Guard failed");
103
+ });
104
+ it("returns empty string for minimal model", () => {
105
+ expect(generateTransitions(Minimal)).toBe("");
106
+ });
107
+ });
108
+ // --- generateEvents ---
109
+ describe("generateEvents", () => {
110
+ it("generates event types for Order transitions", () => {
111
+ const output = generateEvents(Order);
112
+ expect(output).toContain("OrderPayEvent");
113
+ expect(output).toContain("OrderShipEvent");
114
+ expect(output).toContain("OrderCancelEvent");
115
+ expect(output).toContain("OrderDeliverEvent");
116
+ expect(output).toContain("type: 'OrderPay'");
117
+ expect(output).toContain("from: 'pending'");
118
+ expect(output).toContain("to: 'paid'");
119
+ expect(output).toContain("timestamp: Date");
120
+ expect(output).toContain("OrderEvent");
121
+ });
122
+ it("imports props type from convention path", () => {
123
+ const output = generateEvents(Order);
124
+ expect(output).toContain("import type { OrderProps } from './order.types.js'");
125
+ });
126
+ it("returns empty string for minimal model", () => {
127
+ expect(generateEvents(Minimal)).toBe("");
128
+ });
129
+ });
130
+ // --- generateCommands ---
131
+ describe("generateCommands", () => {
132
+ it("generates model-prefixed command types with typed payloads", () => {
133
+ const output = generateCommands(Order);
134
+ expect(output).toContain("OrderAddItemCommand");
135
+ expect(output).toContain("OrderRemoveItemCommand");
136
+ expect(output).toContain("type: 'OrderAddItem'");
137
+ expect(output).toContain("productId: string");
138
+ expect(output).toContain("quantity: number");
139
+ expect(output).toContain("OrderCommand");
140
+ });
141
+ it("falls back to unknown payload when no fields", () => {
142
+ const output = generateCommands(NoFields);
143
+ expect(output).toContain("payload: unknown");
144
+ });
145
+ it("generates empty object payload for action({})", () => {
146
+ const output = generateCommands(EmptyFields);
147
+ expect(output).toContain("payload: {}");
148
+ });
149
+ it("returns empty string for minimal model", () => {
150
+ expect(generateCommands(Minimal)).toBe("");
151
+ });
152
+ });
153
+ // --- generateInvariants ---
154
+ describe("generateInvariants", () => {
155
+ it("generates validation functions for Order constraints", () => {
156
+ const output = generateInvariants(Order);
157
+ expect(output).toContain("validateCannotAddWhenCancelled");
158
+ expect(output).toContain("validateCannotRemoveWhenEmpty");
159
+ expect(output).toContain("validateOrder");
160
+ expect(output).toContain("violations");
161
+ });
162
+ it("imports props type from convention path", () => {
163
+ const output = generateInvariants(Order);
164
+ expect(output).toContain("import type { OrderProps } from './order.types.js'");
165
+ });
166
+ it("includes guard negation comment", () => {
167
+ const output = generateInvariants(Order);
168
+ expect(output).toContain("guard=true means violation");
169
+ });
170
+ it("returns empty string for minimal model", () => {
171
+ expect(generateInvariants(Minimal)).toBe("");
172
+ });
173
+ });
174
+ // --- generateOrchestrators ---
175
+ describe("generateOrchestrators", () => {
176
+ it("generates handler skeletons for Order actions", () => {
177
+ const output = generateOrchestrators(Order);
178
+ expect(output).toContain("function handleOrderAddItem");
179
+ expect(output).toContain("function handleOrderRemoveItem");
180
+ expect(output).toContain("productId: string");
181
+ expect(output).toContain("TODO: implement");
182
+ expect(output).toContain("return ctx");
183
+ });
184
+ it("imports props type from convention path", () => {
185
+ const output = generateOrchestrators(Order);
186
+ expect(output).toContain("import type { OrderProps } from '../generated/order.types.js'");
187
+ });
188
+ it("returns empty string for minimal model", () => {
189
+ expect(generateOrchestrators(Minimal)).toBe("");
190
+ });
191
+ });
192
+ // --- generateTests ---
193
+ describe("generateTests", () => {
194
+ it("generates vitest tests from Order scenarios", () => {
195
+ const output = generateTests(Order);
196
+ expect(output).toContain("describe('Order'");
197
+ expect(output).toContain("it('given");
198
+ expect(output).toContain("total");
199
+ expect(output).toContain("expect(");
200
+ expect(output).toContain("import { describe, it, expect } from 'vitest'");
201
+ });
202
+ it("imports transition functions", () => {
203
+ const output = generateTests(Order);
204
+ expect(output).toContain("import { OrderPay");
205
+ expect(output).toContain("from './order.transitions.js'");
206
+ });
207
+ it("uses model-prefixed transition function names", () => {
208
+ const output = generateTests(Order);
209
+ expect(output).toContain("OrderPay(ctx)");
210
+ });
211
+ it("includes relation field defaults in ctx setup", () => {
212
+ const output = generateTests(Order);
213
+ expect(output).toContain("customerId: ''");
214
+ expect(output).toContain("itemIds: []");
215
+ });
216
+ it("generates tests for Pet scenarios", () => {
217
+ const output = generateTests(Pet);
218
+ expect(output).toContain("describe('Pet'");
219
+ expect(output).toContain("price");
220
+ });
221
+ it("returns empty string for model with no scenarios", () => {
222
+ expect(generateTests(Customer)).toBe("");
223
+ });
224
+ it("returns empty string for minimal model", () => {
225
+ expect(generateTests(Minimal)).toBe("");
226
+ });
227
+ });
228
+ // --- generateE2EStubs ---
229
+ describe("generateE2EStubs", () => {
230
+ it("generates e2e stubs for Order transitions", () => {
231
+ const output = generateE2EStubs(Order);
232
+ expect(output).toContain("test('Order: pay flow");
233
+ expect(output).toContain("test('Order: ship flow");
234
+ expect(output).toContain("TODO: setup");
235
+ expect(output).toContain("TODO: act");
236
+ expect(output).toContain("TODO: assert");
237
+ expect(output).toContain("pending");
238
+ expect(output).toContain("paid");
239
+ });
240
+ it("returns empty string for minimal model", () => {
241
+ expect(generateE2EStubs(Minimal)).toBe("");
242
+ });
243
+ });
244
+ // --- generateZod ---
245
+ describe("generateZod", () => {
246
+ it("generates Zod schema for Order props", () => {
247
+ const output = generateZod(Order);
248
+ expect(output).toContain("import { z } from 'zod'");
249
+ expect(output).toContain("OrderSchema");
250
+ expect(output).toContain("z.object");
251
+ expect(output).toContain("z.number()");
252
+ expect(output).toContain("z.date()");
253
+ expect(output).toContain("z.enum(");
254
+ expect(output).toContain("'pending'");
255
+ });
256
+ it("includes relation fields in Zod schema", () => {
257
+ const output = generateZod(Order);
258
+ expect(output).toContain("customerId: z.string()");
259
+ expect(output).toContain("itemIds: z.array(z.string())");
260
+ });
261
+ it("returns empty string for minimal model", () => {
262
+ expect(generateZod(Minimal)).toBe("");
263
+ });
264
+ it("generates .optional() for optional props", () => {
265
+ const output = generateZod(Pet);
266
+ expect(output).toContain("nickname: z.string().optional()");
267
+ });
268
+ it("does not add .optional() to required props", () => {
269
+ const output = generateZod(Pet);
270
+ expect(output).toMatch(/\bname: z\.string\(\),/);
271
+ });
272
+ it("generates .optional() for optional oneOf prop", () => {
273
+ const output = generateZod(WithOptionalEnum);
274
+ expect(output).toContain("z.enum(['red', 'blue', 'green']).optional()");
275
+ });
276
+ });
277
+ // --- generateMermaid ---
278
+ describe("generateMermaid", () => {
279
+ it("generates Mermaid stateDiagram for Order", () => {
280
+ const output = generateMermaid(Order);
281
+ expect(output).toContain("stateDiagram-v2");
282
+ expect(output).toContain("[*] --> pending");
283
+ expect(output).toContain("pending --> paid: pay");
284
+ expect(output).toContain("paid --> shipped: ship");
285
+ expect(output).toContain("pending --> cancelled: cancel");
286
+ });
287
+ it("marks guarded transitions", () => {
288
+ const output = generateMermaid(Order);
289
+ expect(output).toContain("pay [guarded]");
290
+ });
291
+ it("returns empty string for minimal model", () => {
292
+ expect(generateMermaid(Minimal)).toBe("");
293
+ });
294
+ });
295
+ // --- action() fields ---
296
+ describe("action() fields", () => {
297
+ it("stores fields metadata at runtime", () => {
298
+ const a = action({ count: number() });
299
+ expect(a.fields).toBeDefined();
300
+ expect(a.fields?.["count"]?.kind).toBe("number");
301
+ });
302
+ it("works without fields (backwards compatible)", () => {
303
+ const a = action();
304
+ expect(a.fields).toBeUndefined();
305
+ });
306
+ it("accepts multiple field types", () => {
307
+ const a = action({
308
+ active: boolean(),
309
+ name: string(),
310
+ score: number(),
311
+ });
312
+ expect(a.fields?.["name"]?.kind).toBe("string");
313
+ expect(a.fields?.["score"]?.kind).toBe("number");
314
+ expect(a.fields?.["active"]?.kind).toBe("boolean");
315
+ });
316
+ it("throws on invalid field values at runtime", () => {
317
+ expect(() =>
318
+ // @ts-expect-error -- intentionally passing invalid value
319
+ action({ bad: "not-a-propdef" })).toThrow('action() field "bad" must be a PropDef');
320
+ });
321
+ it("throws on null field values at runtime", () => {
322
+ expect(() =>
323
+ // @ts-expect-error -- intentionally passing null
324
+ action({ bad: null })).toThrow('action() field "bad" must be a PropDef');
325
+ });
326
+ });
327
+ const asGuard = (fn) => fn;
328
+ describe("guardToSource", () => {
329
+ it("extracts body from single-expression arrow", () => {
330
+ const guard = asGuard((ctx) => ctx["total"] > 0);
331
+ expect(guardToSource(guard)).toContain("ctx");
332
+ });
333
+ it("throws on destructured parameter", () => {
334
+ const guard = ({ status }) => status === "active";
335
+ expect(() => guardToSource(guard)).toThrow("Guard must be a single-parameter arrow function");
336
+ });
337
+ it("throws on renamed parameter", () => {
338
+ const guard = (state) => state["total"] > 0;
339
+ expect(() => guardToSource(guard)).toThrow('Guard parameter must be named "ctx": (ctx) => expr');
340
+ });
341
+ it("handles arrow with string containing =>", () => {
342
+ const guard = asGuard((ctx) => ctx["label"] === "a => b");
343
+ const result = guardToSource(guard);
344
+ expect(result).toContain("ctx");
345
+ });
346
+ it("handles logical operators", () => {
347
+ const guard = asGuard((ctx) => ctx["total"] > 0 && ctx["status"] === "active");
348
+ const result = guardToSource(guard);
349
+ expect(result).toContain("ctx");
350
+ expect(result).toContain("active");
351
+ });
352
+ it("handles nested arrow in filter expression", () => {
353
+ const guard = asGuard((ctx) => ctx["items"].filter((i) => i.active)
354
+ .length > 0);
355
+ const result = guardToSource(guard);
356
+ expect(result).toContain("ctx");
357
+ expect(result).toContain("filter");
358
+ expect(result).toContain("active");
359
+ expect(result).toContain(".length > 0");
360
+ });
361
+ it("throws on block-body arrow", () => {
362
+ const guard = asGuard((ctx) => {
363
+ return ctx["total"] > 0;
364
+ });
365
+ expect(() => guardToSource(guard)).toThrow("Block-body arrow functions not supported");
366
+ });
367
+ it("throws on non-arrow function", () => {
368
+ const guard = function (ctx) {
369
+ return ctx["total"] > 0;
370
+ };
371
+ expect(() => guardToSource(guard)).toThrow("Guard must be a single-parameter arrow function");
372
+ });
373
+ it("throws when body does not reference ctx (minification detection)", () => {
374
+ // Simulate a minified guard where param was renamed
375
+ // We manually construct a function whose toString has "ctx =>" but body has "a"
376
+ const guard = {
377
+ toString: () => "(ctx) => a > 0",
378
+ };
379
+ expect(() => guardToSource(guard)).toThrow("does not reference 'ctx'");
380
+ });
381
+ it("handles property access containing => in body", () => {
382
+ const guard = {
383
+ toString: () => '(ctx) => ctx["=>"] === true',
384
+ };
385
+ const result = guardToSource(guard);
386
+ expect(result).toBe('ctx["=>"] === true');
387
+ });
388
+ });
389
+ // --- valueToSource ---
390
+ describe("valueToSource", () => {
391
+ it("escapes single quotes in strings", () => {
392
+ expect(valueToSource("O'Brien")).toBe("'O\\'Brien'");
393
+ });
394
+ it("escapes backslashes in strings", () => {
395
+ expect(valueToSource("a\\b")).toBe("'a\\\\b'");
396
+ });
397
+ it("serializes numbers and booleans", () => {
398
+ expect(valueToSource(42)).toBe("42");
399
+ expect(valueToSource(true)).toBe("true");
400
+ });
401
+ });
402
+ // --- relationIdField ---
403
+ describe("relationIdField", () => {
404
+ it("belongsTo appends Id", () => {
405
+ expect(relationIdField("customer", "belongsTo")).toBe("customerId");
406
+ });
407
+ it("hasMany appends Ids to key as-is", () => {
408
+ expect(relationIdField("item", "hasMany")).toBe("itemIds");
409
+ expect(relationIdField("lineItem", "hasMany")).toBe("lineItemIds");
410
+ });
411
+ });
412
+ // --- Generated code validity (issue #10) ---
413
+ describe("generated code validity", () => {
414
+ it("types + transitions compile as valid TypeScript", () => {
415
+ const source = generateTypes(Order) +
416
+ "\n" +
417
+ generateTransitions(Order);
418
+ const result = ts.transpileModule(source, {
419
+ compilerOptions: {
420
+ strict: true,
421
+ target: ts.ScriptTarget.ES2022,
422
+ },
423
+ });
424
+ expect(result.diagnostics ?? []).toHaveLength(0);
425
+ });
426
+ it("types + invariants compile as valid TypeScript", () => {
427
+ const source = generateTypes(Order) +
428
+ "\n" +
429
+ generateInvariants(Order);
430
+ const result = ts.transpileModule(source, {
431
+ compilerOptions: {
432
+ strict: true,
433
+ target: ts.ScriptTarget.ES2022,
434
+ },
435
+ });
436
+ expect(result.diagnostics ?? []).toHaveLength(0);
437
+ });
438
+ it("generated Zod schema parses valid data and rejects invalid data", () => {
439
+ const source = generateZod(Order);
440
+ // Strip import and export keywords — we provide z directly and return the schema
441
+ const body = source
442
+ .replace(/import.*from.*'zod';?\n?/, "")
443
+ .replace(/export /g, "");
444
+ // Evaluate the generated code with z in scope
445
+ // eslint-disable-next-line sonarjs/code-eval -- intentional: validating generated code at runtime
446
+ const factory = new Function("z", `${body}\nreturn OrderSchema;`);
447
+ // eslint-disable-next-line sonarjs/code-eval -- intentional: validating generated code at runtime
448
+ const OrderSchema = factory(z);
449
+ // Valid data should parse (includes relation id fields)
450
+ const valid = {
451
+ createdAt: new Date(),
452
+ customerId: "cust-123",
453
+ itemIds: ["pet-1", "pet-2"],
454
+ status: "pending",
455
+ total: 100,
456
+ };
457
+ expect(() => OrderSchema.parse(valid)).not.toThrow();
458
+ // Invalid data should throw
459
+ const invalid = {
460
+ createdAt: "not-a-date",
461
+ status: "nonexistent",
462
+ total: "not-a-number",
463
+ };
464
+ expect(() => OrderSchema.parse(invalid)).toThrow();
465
+ });
466
+ it("multi-file imports resolve: types + transitions as separate files", () => {
467
+ const typesSource = generateTypes(Order);
468
+ const transitionsSource = generateTransitions(Order);
469
+ const dir = mkdtempSync(join(tmpdir(), "dopespec-codegen-"));
470
+ try {
471
+ writeFileSync(join(dir, "package.json"), '{"type":"module"}');
472
+ writeFileSync(join(dir, "order.types.ts"), typesSource);
473
+ writeFileSync(join(dir, "order.transitions.ts"), transitionsSource);
474
+ const program = ts.createProgram([join(dir, "order.types.ts"), join(dir, "order.transitions.ts")], {
475
+ module: ts.ModuleKind.NodeNext,
476
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
477
+ strict: true,
478
+ target: ts.ScriptTarget.ES2022,
479
+ });
480
+ const diagnostics = ts.getPreEmitDiagnostics(program);
481
+ const errors = diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error);
482
+ if (errors.length > 0) {
483
+ const messages = errors.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
484
+ expect(messages).toEqual([]);
485
+ }
486
+ expect(errors).toHaveLength(0);
487
+ }
488
+ finally {
489
+ rmSync(dir, { recursive: true });
490
+ }
491
+ });
492
+ });
493
+ // --- generateDecisionEvaluate ---
494
+ describe("generateDecisionEvaluate", () => {
495
+ it("generates input/output types and evaluate function for CreditTier", () => {
496
+ const output = generateDecisionEvaluate(CreditTier);
497
+ expect(output).toContain("export type CreditTierInput");
498
+ expect(output).toContain("extraItemId: string");
499
+ expect(output).toContain("amount: number");
500
+ expect(output).toContain("export type CreditTierOutput");
501
+ expect(output).toContain("credits: number");
502
+ expect(output).toContain("function evaluateCreditTier");
503
+ expect(output).toContain("input.extraItemId === 'tier_3'");
504
+ expect(output).toContain("credits: 5");
505
+ expect(output).toContain("credits: 10");
506
+ expect(output).toContain("credits: 30");
507
+ expect(output).toContain("No matching rule");
508
+ });
509
+ it("generates multi-condition when clause", () => {
510
+ const d = decisions("Multi", {
511
+ inputs: { a: number(), b: string() },
512
+ outputs: { x: number() },
513
+ rules: [{ then: { x: 42 }, when: { a: 1, b: "yes" } }],
514
+ });
515
+ const output = generateDecisionEvaluate(d);
516
+ expect(output).toContain("input.a === 1 && input.b === 'yes'");
517
+ });
518
+ it("generates catch-all for empty when", () => {
519
+ const d = decisions("Fallback", {
520
+ inputs: { x: number() },
521
+ outputs: { y: number() },
522
+ rules: [{ then: { y: 0 }, when: {} }],
523
+ });
524
+ const output = generateDecisionEvaluate(d);
525
+ expect(output).toContain("return { y: 0 }");
526
+ expect(output).not.toContain("if ()");
527
+ });
528
+ it("generates oneOf union type for inputs", () => {
529
+ const d = decisions("Access", {
530
+ inputs: { role: oneOf(["admin", "user"]) },
531
+ outputs: { canEdit: boolean() },
532
+ rules: [
533
+ { then: { canEdit: true }, when: { role: "admin" } },
534
+ { then: { canEdit: false }, when: { role: "user" } },
535
+ ],
536
+ });
537
+ const output = generateDecisionEvaluate(d);
538
+ expect(output).toContain("role: 'admin' | 'user'");
539
+ });
540
+ it("compiles as valid TypeScript", () => {
541
+ const source = generateDecisionEvaluate(CreditTier);
542
+ const result = ts.transpileModule(source, {
543
+ compilerOptions: {
544
+ strict: true,
545
+ target: ts.ScriptTarget.ES2022,
546
+ },
547
+ });
548
+ expect(result.diagnostics ?? []).toHaveLength(0);
549
+ });
550
+ });
551
+ // --- generateDecisionTests ---
552
+ describe("generateDecisionTests", () => {
553
+ it("generates vitest tests for each rule", () => {
554
+ const output = generateDecisionTests(CreditTier);
555
+ expect(output).toContain("import { describe, it, expect } from 'vitest'");
556
+ expect(output).toContain("import { evaluateCreditTier }");
557
+ expect(output).toContain("from './credit-tier.evaluate.js'");
558
+ expect(output).toContain("describe('CreditTier'");
559
+ expect(output).toContain("when extraItemId");
560
+ expect(output).toContain("then credits");
561
+ expect(output).toContain("evaluateCreditTier(");
562
+ expect(output).toContain("expect(result).toEqual(");
563
+ });
564
+ it("uses default values for unmatched inputs", () => {
565
+ const output = generateDecisionTests(CreditTier);
566
+ // amount is not in when clause, so should use default 0
567
+ expect(output).toContain("amount: 0");
568
+ });
569
+ it("generates one test per rule", () => {
570
+ const output = generateDecisionTests(CreditTier);
571
+ const matches = output.match(/it\('/g);
572
+ expect(matches).toHaveLength(3);
573
+ });
574
+ });
575
+ // --- generateDecisionTable ---
576
+ describe("generateDecisionTable", () => {
577
+ it("generates markdown table for CreditTier", () => {
578
+ const output = generateDecisionTable(CreditTier);
579
+ expect(output).toContain("# CreditTier");
580
+ expect(output).toContain("amount");
581
+ expect(output).toContain("extraItemId");
582
+ expect(output).toContain("\u2192 credits");
583
+ expect(output).toContain("tier_3");
584
+ expect(output).toContain("tier_5");
585
+ expect(output).toContain("tier_12");
586
+ expect(output).toContain("5");
587
+ expect(output).toContain("10");
588
+ expect(output).toContain("30");
589
+ });
590
+ it("uses * for unmatched inputs", () => {
591
+ const output = generateDecisionTable(CreditTier);
592
+ // amount is not in when clause, so should show *
593
+ expect(output).toContain("*");
594
+ });
595
+ it("has correct number of rows (header + separator + rules)", () => {
596
+ const output = generateDecisionTable(CreditTier);
597
+ const lines = output.trim().split("\n");
598
+ // title, blank, header, separator, 3 data rows
599
+ expect(lines).toHaveLength(7);
600
+ });
601
+ });
602
+ // --- Policy generators ---
603
+ // Build model lookup for policy generators
604
+ const policyModelLookup = new Map();
605
+ policyModelLookup.set("Customer", Customer);
606
+ policyModelLookup.set("Order", Order);
607
+ policyModelLookup.set("Pet", Pet);
608
+ const petStorePolicy = NoSuspendedCustomerOrders;
609
+ describe("generatePolicyValidator", () => {
610
+ it("generates context type and validator function", () => {
611
+ const output = generatePolicyValidator([petStorePolicy], policyModelLookup);
612
+ expect(output).toContain("NoSuspendedCustomerOrdersContext");
613
+ expect(output).toContain("order: OrderProps");
614
+ expect(output).toContain("customer: CustomerProps");
615
+ expect(output).toContain("validateNoSuspendedCustomerOrders");
616
+ expect(output).toContain("violations");
617
+ expect(output).toContain("warnings");
618
+ });
619
+ it("imports types from convention paths", () => {
620
+ const output = generatePolicyValidator([petStorePolicy], policyModelLookup);
621
+ expect(output).toContain("import type { CustomerProps } from './customer.types.js'");
622
+ expect(output).toContain("import type { OrderProps } from './order.types.js'");
623
+ });
624
+ it("resolves closure references in guard bodies", () => {
625
+ const output = generatePolicyValidator([petStorePolicy], policyModelLookup);
626
+ // Should resolve customerStates.suspended → 'suspended'
627
+ expect(output).toContain("'suspended'");
628
+ expect(output).not.toContain("customerStates");
629
+ });
630
+ it("maps prevent to violations and warn to warnings", () => {
631
+ const output = generatePolicyValidator([petStorePolicy], policyModelLookup);
632
+ // prevent → violations with stable policyName:rule_N ID
633
+ expect(output).toContain("violations.push('NoSuspendedCustomerOrders:rule_0')");
634
+ // warn → warnings
635
+ expect(output).toContain("warnings.push('NoSuspendedCustomerOrders:rule_1')");
636
+ });
637
+ it("returns empty string for empty policies", () => {
638
+ expect(generatePolicyValidator([], policyModelLookup)).toBe("");
639
+ });
640
+ });
641
+ describe("generatePolicyIndex", () => {
642
+ it("generates index with model+action mapping", () => {
643
+ const output = generatePolicyIndex([petStorePolicy]);
644
+ expect(output).toContain("policyIndex");
645
+ expect(output).toContain("Order");
646
+ expect(output).toContain("addItem");
647
+ expect(output).toContain("NoSuspendedCustomerOrders");
648
+ expect(output).toContain("as const");
649
+ });
650
+ it("returns empty string for empty policies", () => {
651
+ expect(generatePolicyIndex([])).toBe("");
652
+ });
653
+ });
654
+ describe("generatePolicyTests", () => {
655
+ it("generates vitest integration tests", () => {
656
+ const output = generatePolicyTests("order", [petStorePolicy], policyModelLookup);
657
+ expect(output).toContain("import { describe, it, expect } from 'vitest'");
658
+ expect(output).toContain("validateNoSuspendedCustomerOrders");
659
+ expect(output).toContain("describe('NoSuspendedCustomerOrders'");
660
+ expect(output).toContain("expect(result");
661
+ });
662
+ it("generates one test per rule", () => {
663
+ const output = generatePolicyTests("order", [petStorePolicy], policyModelLookup);
664
+ const matches = output.match(/it\('/g);
665
+ expect(matches).toHaveLength(2);
666
+ });
667
+ it("tests prevent rules with valid=false", () => {
668
+ const output = generatePolicyTests("order", [petStorePolicy], policyModelLookup);
669
+ expect(output).toContain("expect(result.valid).toBe(false)");
670
+ expect(output).toContain("expect(result.violations).toContain('NoSuspendedCustomerOrders:rule_0')");
671
+ });
672
+ it("tests warn rules with warnings", () => {
673
+ const output = generatePolicyTests("order", [petStorePolicy], policyModelLookup);
674
+ expect(output).toContain("expect(result.warnings).toContain('NoSuspendedCustomerOrders:rule_1')");
675
+ });
676
+ it("returns empty string for empty policies", () => {
677
+ expect(generatePolicyTests("order", [], policyModelLookup)).toBe("");
678
+ });
679
+ });
680
+ describe("generatePolicyMermaid", () => {
681
+ it("generates Mermaid interaction diagram", () => {
682
+ const output = generatePolicyMermaid("order", [petStorePolicy]);
683
+ expect(output).toContain("graph LR");
684
+ expect(output).toContain("Customer");
685
+ expect(output).toContain("Order");
686
+ expect(output).toContain("NoSuspendedCustomerOrders");
687
+ expect(output).toContain("addItem");
688
+ });
689
+ it("shows relation kind", () => {
690
+ const output = generatePolicyMermaid("order", [petStorePolicy]);
691
+ expect(output).toContain("belongsTo");
692
+ });
693
+ it("shows effect type", () => {
694
+ const output = generatePolicyMermaid("order", [petStorePolicy]);
695
+ expect(output).toContain("prevent/warn");
696
+ });
697
+ it("returns empty string for empty policies", () => {
698
+ expect(generatePolicyMermaid("order", [])).toBe("");
699
+ });
700
+ });
701
+ describe("resolvePolicyGuardBody", () => {
702
+ it("resolves nested closure references", () => {
703
+ const states = { active: "active", suspended: "suspended" };
704
+ const guard = (ctx) => ctx.customer.status === states.suspended;
705
+ const body = guardToSource(guard);
706
+ const resolved = resolvePolicyGuardBody(guard, body, {
707
+ customer: Customer,
708
+ });
709
+ expect(resolved).toContain("'suspended'");
710
+ expect(resolved).not.toContain("states.suspended");
711
+ });
712
+ });
713
+ //# sourceMappingURL=codegen.test.js.map