@owlmeans/planning 0.1.18-rc.8 → 0.1.18

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 (295) hide show
  1. package/README.md +122 -29
  2. package/agent-meta/manifest.json +131 -4
  3. package/agent-meta/skills/planning/SKILL.md +78 -171
  4. package/agent-meta/skills/planning/parts/collaboration.md +67 -0
  5. package/agent-meta/skills/planning/parts/errors.md +51 -0
  6. package/agent-meta/skills/planning/parts/fold.md +69 -0
  7. package/agent-meta/skills/planning/parts/mounting.md +64 -0
  8. package/agent-meta/skills/planning/parts/types.md +141 -0
  9. package/build/aliases/types.d.ts +72 -0
  10. package/build/aliases/types.d.ts.map +1 -0
  11. package/build/aliases/types.js +2 -0
  12. package/build/aliases/types.js.map +1 -0
  13. package/build/aliases.d.ts +4 -0
  14. package/build/aliases.d.ts.map +1 -0
  15. package/build/aliases.js +28 -0
  16. package/build/aliases.js.map +1 -0
  17. package/build/consts.d.ts +43 -34
  18. package/build/consts.d.ts.map +1 -1
  19. package/build/consts.js +49 -25
  20. package/build/consts.js.map +1 -1
  21. package/build/entrypoints/types.d.ts +105 -0
  22. package/build/entrypoints/types.d.ts.map +1 -0
  23. package/build/entrypoints/types.js +2 -0
  24. package/build/entrypoints/types.js.map +1 -0
  25. package/build/entrypoints.d.ts +5 -1
  26. package/build/entrypoints.d.ts.map +1 -1
  27. package/build/entrypoints.js +24 -6
  28. package/build/entrypoints.js.map +1 -1
  29. package/build/errors.d.ts +41 -0
  30. package/build/errors.d.ts.map +1 -1
  31. package/build/errors.js +61 -0
  32. package/build/errors.js.map +1 -1
  33. package/build/helpers/app/consts.local.d.ts +5 -0
  34. package/build/helpers/app/consts.local.d.ts.map +1 -0
  35. package/build/helpers/app/consts.local.js +5 -0
  36. package/build/helpers/app/consts.local.js.map +1 -0
  37. package/build/helpers/app/types.d.ts +53 -0
  38. package/build/helpers/app/types.d.ts.map +1 -0
  39. package/build/helpers/app/types.js +2 -0
  40. package/build/helpers/app/types.js.map +1 -0
  41. package/build/helpers/app.d.ts +4 -0
  42. package/build/helpers/app.d.ts.map +1 -0
  43. package/build/helpers/app.js +139 -0
  44. package/build/helpers/app.js.map +1 -0
  45. package/build/helpers/apply/consts.local.d.ts +7 -0
  46. package/build/helpers/apply/consts.local.d.ts.map +1 -0
  47. package/build/helpers/apply/consts.local.js +9 -0
  48. package/build/helpers/apply/consts.local.js.map +1 -0
  49. package/build/helpers/apply/types.d.ts +32 -0
  50. package/build/helpers/apply/types.d.ts.map +1 -0
  51. package/build/helpers/apply/types.js +2 -0
  52. package/build/helpers/apply/types.js.map +1 -0
  53. package/build/helpers/apply.d.ts +5 -23
  54. package/build/helpers/apply.d.ts.map +1 -1
  55. package/build/helpers/apply.js +127 -144
  56. package/build/helpers/apply.js.map +1 -1
  57. package/build/helpers/card/types.d.ts +20 -0
  58. package/build/helpers/card/types.d.ts.map +1 -0
  59. package/build/helpers/card/types.js +2 -0
  60. package/build/helpers/card/types.js.map +1 -0
  61. package/build/helpers/card.d.ts +11 -7
  62. package/build/helpers/card.d.ts.map +1 -1
  63. package/build/helpers/card.js +41 -28
  64. package/build/helpers/card.js.map +1 -1
  65. package/build/helpers/changes/consts.local.d.ts +13 -0
  66. package/build/helpers/changes/consts.local.d.ts.map +1 -0
  67. package/build/helpers/changes/consts.local.js +13 -0
  68. package/build/helpers/changes/consts.local.js.map +1 -0
  69. package/build/helpers/changes/types.d.ts +58 -0
  70. package/build/helpers/changes/types.d.ts.map +1 -0
  71. package/build/helpers/changes/types.js +2 -0
  72. package/build/helpers/changes/types.js.map +1 -0
  73. package/build/helpers/changes.d.ts +11 -42
  74. package/build/helpers/changes.d.ts.map +1 -1
  75. package/build/helpers/changes.js +237 -251
  76. package/build/helpers/changes.js.map +1 -1
  77. package/build/helpers/code/types.d.ts +27 -0
  78. package/build/helpers/code/types.d.ts.map +1 -0
  79. package/build/helpers/code/types.js +2 -0
  80. package/build/helpers/code/types.js.map +1 -0
  81. package/build/helpers/code.d.ts +6 -17
  82. package/build/helpers/code.d.ts.map +1 -1
  83. package/build/helpers/code.js +39 -40
  84. package/build/helpers/code.js.map +1 -1
  85. package/build/helpers/index.d.ts +16 -0
  86. package/build/helpers/index.d.ts.map +1 -1
  87. package/build/helpers/index.js +4 -0
  88. package/build/helpers/index.js.map +1 -1
  89. package/build/helpers/query/types.d.ts +23 -0
  90. package/build/helpers/query/types.d.ts.map +1 -0
  91. package/build/helpers/query/types.js +2 -0
  92. package/build/helpers/query/types.js.map +1 -0
  93. package/build/helpers/query.d.ts +25 -17
  94. package/build/helpers/query.d.ts.map +1 -1
  95. package/build/helpers/query.js +108 -280
  96. package/build/helpers/query.js.map +1 -1
  97. package/build/helpers/reply/consts.d.ts +2 -0
  98. package/build/helpers/reply/consts.d.ts.map +1 -0
  99. package/build/helpers/reply/consts.js +2 -0
  100. package/build/helpers/reply/consts.js.map +1 -0
  101. package/build/helpers/reply/types.d.ts +10 -0
  102. package/build/helpers/reply/types.d.ts.map +1 -0
  103. package/build/helpers/reply/types.js +2 -0
  104. package/build/helpers/reply/types.js.map +1 -0
  105. package/build/helpers/reply.d.ts +5 -0
  106. package/build/helpers/reply.d.ts.map +1 -0
  107. package/build/helpers/reply.js +29 -0
  108. package/build/helpers/reply.js.map +1 -0
  109. package/build/helpers/scoped/types.d.ts +73 -0
  110. package/build/helpers/scoped/types.d.ts.map +1 -0
  111. package/build/helpers/scoped/types.js +2 -0
  112. package/build/helpers/scoped/types.js.map +1 -0
  113. package/build/helpers/scoped.d.ts +25 -0
  114. package/build/helpers/scoped.d.ts.map +1 -0
  115. package/build/helpers/scoped.js +223 -0
  116. package/build/helpers/scoped.js.map +1 -0
  117. package/build/helpers/specification/types.d.ts +24 -0
  118. package/build/helpers/specification/types.d.ts.map +1 -0
  119. package/build/helpers/specification/types.js +2 -0
  120. package/build/helpers/specification/types.js.map +1 -0
  121. package/build/helpers/specification.d.ts +9 -13
  122. package/build/helpers/specification.d.ts.map +1 -1
  123. package/build/helpers/specification.js +47 -44
  124. package/build/helpers/specification.js.map +1 -1
  125. package/build/helpers/status/types.d.ts +48 -0
  126. package/build/helpers/status/types.d.ts.map +1 -0
  127. package/build/helpers/status/types.js +2 -0
  128. package/build/helpers/status/types.js.map +1 -0
  129. package/build/helpers/status.d.ts +16 -29
  130. package/build/helpers/status.d.ts.map +1 -1
  131. package/build/helpers/status.js +77 -73
  132. package/build/helpers/status.js.map +1 -1
  133. package/build/helpers/types.local.d.ts +17 -0
  134. package/build/helpers/types.local.d.ts.map +1 -0
  135. package/build/helpers/types.local.js +2 -0
  136. package/build/helpers/types.local.js.map +1 -0
  137. package/build/helpers/validate/types.d.ts +31 -0
  138. package/build/helpers/validate/types.d.ts.map +1 -0
  139. package/build/helpers/validate/types.js +2 -0
  140. package/build/helpers/validate/types.js.map +1 -0
  141. package/build/helpers/validate.d.ts +8 -19
  142. package/build/helpers/validate.d.ts.map +1 -1
  143. package/build/helpers/validate.js +79 -79
  144. package/build/helpers/validate.js.map +1 -1
  145. package/build/helpers/wire/types.d.ts +28 -0
  146. package/build/helpers/wire/types.d.ts.map +1 -0
  147. package/build/helpers/wire/types.js +2 -0
  148. package/build/helpers/wire/types.js.map +1 -0
  149. package/build/helpers/wire.d.ts +4 -0
  150. package/build/helpers/wire.d.ts.map +1 -0
  151. package/build/helpers/wire.js +216 -0
  152. package/build/helpers/wire.js.map +1 -0
  153. package/build/i18n/be.json +5 -0
  154. package/build/i18n/de.json +5 -0
  155. package/build/i18n/en.json +5 -0
  156. package/build/i18n/es.json +5 -0
  157. package/build/i18n/fr.json +5 -0
  158. package/build/i18n/pl.json +5 -0
  159. package/build/i18n/ru.json +5 -0
  160. package/build/i18n/uk.json +5 -0
  161. package/build/i18n.js +3 -3
  162. package/build/i18n.js.map +1 -1
  163. package/build/index.d.ts +7 -0
  164. package/build/index.d.ts.map +1 -1
  165. package/build/index.js +3 -0
  166. package/build/index.js.map +1 -1
  167. package/build/models/index.d.ts +2 -3
  168. package/build/models/index.d.ts.map +1 -1
  169. package/build/models/index.js +1 -15
  170. package/build/models/index.js.map +1 -1
  171. package/build/models/model.d.ts +5 -0
  172. package/build/models/model.d.ts.map +1 -0
  173. package/build/models/model.js +16 -0
  174. package/build/models/model.js.map +1 -0
  175. package/build/models/project.d.ts +2 -1
  176. package/build/models/project.d.ts.map +1 -1
  177. package/build/models/project.js.map +1 -1
  178. package/build/models/specification.d.ts +2 -1
  179. package/build/models/specification.d.ts.map +1 -1
  180. package/build/models/specification.js.map +1 -1
  181. package/build/models/types.d.ts +69 -0
  182. package/build/models/types.d.ts.map +1 -0
  183. package/build/models/types.js +2 -0
  184. package/build/models/types.js.map +1 -0
  185. package/build/models/workcard.d.ts +2 -1
  186. package/build/models/workcard.d.ts.map +1 -1
  187. package/build/models/workcard.js +12 -12
  188. package/build/models/workcard.js.map +1 -1
  189. package/build/registry.d.ts +3 -2
  190. package/build/registry.d.ts.map +1 -1
  191. package/build/registry.js +51 -3
  192. package/build/registry.js.map +1 -1
  193. package/build/resources/mentions.d.ts +4 -0
  194. package/build/resources/mentions.d.ts.map +1 -0
  195. package/build/resources/mentions.js +15 -0
  196. package/build/resources/mentions.js.map +1 -0
  197. package/build/resources/protocol/types.d.ts +30 -0
  198. package/build/resources/protocol/types.d.ts.map +1 -0
  199. package/build/resources/protocol/types.js +2 -0
  200. package/build/resources/protocol/types.js.map +1 -0
  201. package/build/resources/protocol.d.ts +6 -0
  202. package/build/resources/protocol.d.ts.map +1 -0
  203. package/build/resources/protocol.js +31 -0
  204. package/build/resources/protocol.js.map +1 -0
  205. package/build/resources/types.d.ts +146 -0
  206. package/build/resources/types.d.ts.map +1 -0
  207. package/build/resources/types.js +2 -0
  208. package/build/resources/types.js.map +1 -0
  209. package/build/resources/types.local.d.ts +6 -0
  210. package/build/resources/types.local.d.ts.map +1 -0
  211. package/build/resources/types.local.js +2 -0
  212. package/build/resources/types.local.js.map +1 -0
  213. package/build/schemas.d.ts +21 -2
  214. package/build/schemas.d.ts.map +1 -1
  215. package/build/schemas.js +164 -2
  216. package/build/schemas.js.map +1 -1
  217. package/build/types.d.ts +330 -151
  218. package/build/types.d.ts.map +1 -1
  219. package/build/utils.d.ts +6 -0
  220. package/build/utils.d.ts.map +1 -0
  221. package/build/utils.js +6 -0
  222. package/build/utils.js.map +1 -0
  223. package/package.json +13 -13
  224. package/src/aliases/types.ts +41 -0
  225. package/src/aliases.ts +34 -0
  226. package/src/consts.ts +50 -25
  227. package/src/entrypoints/types.ts +77 -0
  228. package/src/entrypoints.ts +53 -24
  229. package/src/errors.ts +71 -0
  230. package/src/helpers/app/consts.local.ts +5 -0
  231. package/src/helpers/app/types.ts +54 -0
  232. package/src/helpers/app.ts +158 -0
  233. package/src/helpers/apply/consts.local.ts +10 -0
  234. package/src/helpers/apply/types.ts +32 -0
  235. package/src/helpers/apply.ts +141 -153
  236. package/src/helpers/card/types.ts +20 -0
  237. package/src/helpers/card.ts +59 -33
  238. package/src/helpers/changes/consts.local.ts +17 -0
  239. package/src/helpers/changes/types.ts +66 -0
  240. package/src/helpers/changes.ts +270 -280
  241. package/src/helpers/code/types.ts +31 -0
  242. package/src/helpers/code.ts +52 -48
  243. package/src/helpers/index.ts +17 -0
  244. package/src/helpers/query/types.ts +26 -0
  245. package/src/helpers/query.ts +144 -314
  246. package/src/helpers/reply/consts.ts +1 -0
  247. package/src/helpers/reply/types.ts +13 -0
  248. package/src/helpers/reply.ts +27 -0
  249. package/src/helpers/scoped/types.ts +78 -0
  250. package/src/helpers/scoped.ts +281 -0
  251. package/src/helpers/specification/types.ts +26 -0
  252. package/src/helpers/specification.ts +69 -51
  253. package/src/helpers/status/types.ts +46 -0
  254. package/src/helpers/status.ts +117 -93
  255. package/src/helpers/types.local.ts +18 -0
  256. package/src/helpers/validate/types.ts +31 -0
  257. package/src/helpers/validate.ts +89 -77
  258. package/src/helpers/wire/types.ts +31 -0
  259. package/src/helpers/wire.ts +252 -0
  260. package/src/i18n/be.json +5 -0
  261. package/src/i18n/de.json +5 -0
  262. package/src/i18n/en.json +5 -0
  263. package/src/i18n/es.json +5 -0
  264. package/src/i18n/fr.json +5 -0
  265. package/src/i18n/pl.json +5 -0
  266. package/src/i18n/ru.json +5 -0
  267. package/src/i18n/uk.json +5 -0
  268. package/src/i18n.ts +4 -3
  269. package/src/index.ts +8 -0
  270. package/src/models/index.ts +2 -17
  271. package/src/models/model.ts +18 -0
  272. package/src/models/project.ts +2 -1
  273. package/src/models/specification.ts +2 -1
  274. package/src/models/types.ts +70 -0
  275. package/src/models/workcard.ts +14 -16
  276. package/src/registry.ts +55 -4
  277. package/src/resources/mentions.ts +11 -0
  278. package/src/resources/protocol/types.ts +21 -0
  279. package/src/resources/protocol.ts +36 -0
  280. package/src/resources/types.local.ts +5 -0
  281. package/src/resources/types.ts +148 -0
  282. package/src/schemas.ts +179 -4
  283. package/src/types.ts +334 -130
  284. package/src/utils.ts +9 -0
  285. package/tests/app.spec.ts +188 -0
  286. package/tests/apply.spec.ts +95 -31
  287. package/tests/code.spec.ts +9 -9
  288. package/tests/csp.spec.ts +122 -0
  289. package/tests/entrypoints.spec.ts +18 -2
  290. package/tests/error-status.spec.ts +6 -1
  291. package/tests/models.spec.ts +24 -16
  292. package/tests/query.spec.ts +13 -15
  293. package/tests/schemas.spec.ts +8 -8
  294. package/tests/scoped.spec.ts +161 -0
  295. package/tests/status.spec.ts +31 -22
@@ -0,0 +1,188 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { IntrinsicStatus, PlanningSchemaKind, PlanningUnsupported, SchemaInvalid, WorkcardKind } from '../src/index.js'
3
+ import type { PlanningAppDeclarations, ProjectTypeSchema, ScopedSchemaRecord, StatusFlowSchema, WorkcardTypeSchema } from '../src/index.js'
4
+ import { appSchemaHelper } from '../src/helpers/app.js'
5
+ import { scopedSchemaHelper } from '../src/helpers/scoped.js'
6
+ import { makeAjv, validateHelper } from '../src/helpers/validate.js'
7
+ import { makeSchemaRegistry } from '../src/registry.js'
8
+ import { PlanningSchemaBundleSchema, SchemaDefineRequestSchema } from '../src/schemas.js'
9
+
10
+ /** A repair shop: the app gives every type and flow a description and every job card a priority. */
11
+ const APP: PlanningAppDeclarations = {
12
+ type: { properties: { description: { type: 'string', maxLength: 200 } } },
13
+ flow: { type: 'object', properties: { description: { type: 'string' } }, required: ['description'] },
14
+ card: { properties: { priority: { type: 'string', enum: ['low', 'high'] } } },
15
+ }
16
+
17
+ const MEND: StatusFlowSchema = {
18
+ id: 'shop:mend', version: 1, overridable: true,
19
+ statuses: [{ key: 'queued', intrinsic: IntrinsicStatus.Planned, initial: true }, { key: 'done', intrinsic: IntrinsicStatus.Closed }],
20
+ transitions: [{ name: 'finish', from: ['queued'], to: 'done' }],
21
+ }
22
+
23
+ /** Closed fields, a local `$defs` reference — the shape a base field must not break. */
24
+ const JOB: WorkcardTypeSchema = {
25
+ type: 'shop:job', kind: WorkcardKind.Card, version: 1, flows: [MEND.id], specifications: [],
26
+ fields: {
27
+ type: 'object',
28
+ properties: { cost: { $ref: '#/$defs/money' } },
29
+ additionalProperties: false,
30
+ $defs: { money: { type: 'number', minimum: 0 } },
31
+ },
32
+ }
33
+
34
+ const SHOP: ProjectTypeSchema = {
35
+ type: 'shop:site', kind: WorkcardKind.Project, version: 1, fields: { type: 'object', additionalProperties: false },
36
+ flows: [MEND.id], specifications: [], cardTypes: [JOB.type], scopedCardTypes: true,
37
+ }
38
+
39
+ describe('appSchemaHelper — declarations', () => {
40
+ test('merges in the order given: properties united, a later name replacing whole, required united', () => {
41
+ const merged = appSchemaHelper.merge([
42
+ undefined,
43
+ { card: { properties: { priority: { type: 'string' }, size: { type: 'number' } }, required: ['size'] } },
44
+ { card: { properties: { priority: { type: 'string', enum: ['low'] } }, required: ['priority'] }, type: { properties: {} } },
45
+ ])
46
+ expect(merged).toEqual({
47
+ card: {
48
+ type: 'object',
49
+ properties: { priority: { type: 'string', enum: ['low'] }, size: { type: 'number' } },
50
+ required: ['size', 'priority'],
51
+ },
52
+ type: { type: 'object', properties: {} },
53
+ })
54
+ expect(appSchemaHelper.merge([])).toBeUndefined()
55
+ expect(appSchemaHelper.merge([undefined, {}])).toBeUndefined()
56
+ })
57
+
58
+ test('refuses a declaration a merge by name could not honour', () => {
59
+ const refused = (app: unknown, marker: string) =>
60
+ expect(() => appSchemaHelper.assertDeclarations(app as PlanningAppDeclarations)).toThrow(marker)
61
+ refused({ status: { properties: {} } }, 'app:kind:status')
62
+ refused({ card: { properties: {}, additionalProperties: false } }, 'app:card:keyword:additionalProperties')
63
+ refused({ type: { type: 'string', properties: {} } }, 'app:type:type')
64
+ refused({ flow: { properties: [] } }, 'app:flow:properties')
65
+ refused({ card: { properties: { 'a.b': {} } } }, 'app:card:name:a.b')
66
+ refused({ card: { properties: { $x: {} } } }, 'app:card:name:$x')
67
+ refused({ type: { properties: { a: {} }, required: ['b'] } }, 'app:type:required:b')
68
+ refused({ type: { properties: { a: { type: 'nope' } } } }, 'app:type:')
69
+ expect(() => appSchemaHelper.merge([{ card: { properties: {}, allOf: [] } as never }])).toThrow(SchemaInvalid)
70
+ expect(() => appSchemaHelper.assertDeclarations(APP)).not.toThrow()
71
+ })
72
+ })
73
+
74
+ describe('appSchemaHelper — definition bags', () => {
75
+ test('a bag is closed over the declared kind, and refused when the kind is undeclared', () => {
76
+ const ok = (kind: PlanningSchemaKind.Type | PlanningSchemaKind.Flow, bag: unknown, app = APP) =>
77
+ appSchemaHelper.assertBag(kind, 'k', bag, app)
78
+
79
+ expect(() => ok(PlanningSchemaKind.Type, { description: 'Jobs at the counter' })).not.toThrow()
80
+ expect(() => ok(PlanningSchemaKind.Type, undefined)).not.toThrow()
81
+ expect(() => ok(PlanningSchemaKind.Type, { colour: 'red' })).toThrow('type:k:app:')
82
+ expect(() => ok(PlanningSchemaKind.Type, { description: 5 })).toThrow(SchemaInvalid)
83
+ expect(() => ok(PlanningSchemaKind.Type, { description: 'x', nested: { 'a.b': 1 } })).toThrow('type:k:app:keys:nested.a.b')
84
+ // A required property makes the bag itself mandatory.
85
+ expect(() => ok(PlanningSchemaKind.Flow, undefined)).toThrow("flow:k:app:/ must have required property 'description'")
86
+ expect(() => ok(PlanningSchemaKind.Flow, { description: 'Bench work' })).not.toThrow()
87
+ expect(() => ok(PlanningSchemaKind.Flow, { description: 'x' }, { card: APP.card })).toThrow('flow:k:app:undeclared')
88
+ expect(() => ok(PlanningSchemaKind.Flow, undefined, {})).not.toThrow()
89
+ expect(appSchemaHelper.bagSchemaOf(APP, PlanningSchemaKind.Type)).toEqual({
90
+ type: 'object', properties: { description: { type: 'string', maxLength: 200 } }, additionalProperties: false,
91
+ })
92
+ expect(appSchemaHelper.bagSchemaOf({}, PlanningSchemaKind.Flow)).toBeUndefined()
93
+ })
94
+
95
+ test('the declaration schemas admit an app bag on the wire and in a bundle', () => {
96
+ const define = makeAjv().compile(SchemaDefineRequestSchema)
97
+ const bundle = makeAjv().compile(PlanningSchemaBundleSchema)
98
+
99
+ expect(define({ types: [{ ...JOB, app: { description: 'x' } }], flows: [{ ...MEND, app: { description: 'y' } }] })).toBe(true)
100
+ expect(define({ types: [{ ...JOB, app: 'x' }] })).toBe(false)
101
+ expect(bundle({ version: 1, types: [JOB], flows: [MEND], app: APP })).toBe(true)
102
+ expect(bundle({ version: 1, types: [JOB], flows: [MEND], app: { status: {} } })).toBe(false)
103
+ })
104
+ })
105
+
106
+ describe('appSchemaHelper — base card fields', () => {
107
+ test('a card type validates its own fields and the base ones; closed fields never refuse a base field', () => {
108
+ const registry = makeSchemaRegistry({ flows: [MEND], types: [JOB, SHOP], app: APP })
109
+ const valid = (fields: Record<string, unknown>) => registry.validator(JOB.type)(fields)
110
+
111
+ expect(valid({ cost: 12, priority: 'high' })).toBe(true)
112
+ expect(valid({ cost: 12 })).toBe(true)
113
+ expect(valid({ cost: -1, priority: 'high' })).toBe(false)
114
+ expect(valid({ cost: 1, priority: 'urgent' })).toBe(false)
115
+ expect(valid({ cost: 1, colour: 'red' })).toBe(false)
116
+ expect(() => validateHelper.validateFields(registry, JOB.type, { priority: 'urgent' })).toThrow('shop:job:')
117
+ // A project type is not a card type: its closed fields stay closed.
118
+ expect(registry.validator(SHOP.type)({ priority: 'high' })).toBe(false)
119
+ expect(registry.fieldsSchema(SHOP.type)).toBe(SHOP.fields)
120
+ })
121
+
122
+ test('a type may narrow a base field, and a required base field is required of every card type', () => {
123
+ const narrow: WorkcardTypeSchema = { ...JOB, type: 'shop:rush', fields: { type: 'object', properties: { priority: { const: 'high' } } } }
124
+ const registry = makeSchemaRegistry({
125
+ flows: [MEND], types: [narrow],
126
+ app: { card: { properties: { priority: { type: 'string', enum: ['low', 'high'] } }, required: ['priority'] } },
127
+ })
128
+
129
+ expect(registry.validator(narrow.type)({ priority: 'high' })).toBe(true)
130
+ expect(registry.validator(narrow.type)({ priority: 'low' })).toBe(false)
131
+ expect(registry.validator(narrow.type)({})).toBe(false)
132
+ })
133
+
134
+ test('without base fields the type\'s own fields object is what compiles', () => {
135
+ expect(appSchemaHelper.fieldsSchemaOf(JOB)).toBe(JOB.fields)
136
+ expect(appSchemaHelper.fieldsSchemaOf(JOB, { type: APP.type })).toBe(JOB.fields)
137
+ expect(makeSchemaRegistry({ flows: [MEND], types: [JOB] }).fieldsSchema(JOB.type)).toBe(JOB.fields)
138
+ })
139
+ })
140
+
141
+ describe('the schema registry and the scoped layers carry the declarations', () => {
142
+ test('registerApp replaces them and drops the cached validators; a bundle names them only when present', () => {
143
+ const registry = makeSchemaRegistry({ flows: [MEND], types: [JOB] })
144
+ expect('app' in registry.bundle()).toBe(false)
145
+ expect(registry.validator(JOB.type)({ priority: 'high' })).toBe(false)
146
+
147
+ registry.registerApp(APP)
148
+ expect(registry.app()).toEqual(APP)
149
+ expect(registry.bundle().app).toEqual(APP)
150
+ expect(registry.validator(JOB.type)({ priority: 'high' })).toBe(true)
151
+
152
+ registry.load({ version: 1, types: [JOB], flows: [MEND] })
153
+ expect(registry.app()).toBeUndefined()
154
+ expect(registry.validator(JOB.type)({ priority: 'high' })).toBe(false)
155
+ registry.load({ version: 1, types: [JOB], flows: [MEND], app: APP })
156
+ expect(registry.validator(JOB.type)({ priority: 'high' })).toBe(true)
157
+ expect(() => registry.registerApp({ card: { properties: { 'a.b': {} } } })).toThrow(SchemaInvalid)
158
+ })
159
+
160
+ test('a resolved layer passes the code declarations through; its registry refuses registerApp', () => {
161
+ const code = makeSchemaRegistry({ flows: [MEND], types: [JOB, SHOP], app: APP })
162
+ const record: ScopedSchemaRecord = {
163
+ entityId: 'org-1', kind: PlanningSchemaKind.Type, key: 'shop:quote', version: 1, createdAt: '2026-10-01T00:00:00.000Z',
164
+ definition: { ...JOB, type: 'shop:quote', fields: { type: 'object', additionalProperties: false }, app: { description: 'Quotes' } },
165
+ }
166
+ const layer = scopedSchemaHelper.scopedRegistryOf(scopedSchemaHelper.resolveScopedBundle(code.bundle(), [record], { entityId: 'org-1' }))
167
+
168
+ expect(layer.bundle().app).toEqual(APP)
169
+ expect((layer.type('shop:quote') as WorkcardTypeSchema).app).toEqual({ description: 'Quotes' })
170
+ expect(layer.validator('shop:quote')({ priority: 'low' })).toBe(true)
171
+ expect(() => layer.registerApp(undefined)).toThrow(PlanningUnsupported)
172
+ const plain = scopedSchemaHelper.resolveScopedBundle(makeSchemaRegistry({ flows: [MEND], types: [JOB] }).bundle(), [], { entityId: 'org-1' })
173
+ expect('app' in plain).toBe(false)
174
+ })
175
+
176
+ test('the closed-form checks of a data-defined type and flow apply the declarations', () => {
177
+ const flows = makeSchemaRegistry({ flows: [MEND] })
178
+ const quote: WorkcardTypeSchema = { ...JOB, type: 'shop:quote' }
179
+
180
+ expect(() => scopedSchemaHelper.assertTypeSchema({ ...quote, app: { description: 'Quotes' } }, flows, APP)).not.toThrow()
181
+ expect(() => scopedSchemaHelper.assertTypeSchema({ ...quote, app: { owner: 'me' } }, flows, APP)).toThrow('type:shop:quote:app:')
182
+ expect(() => scopedSchemaHelper.assertTypeSchema({ ...quote, app: { description: 'Quotes' } }, flows)).toThrow('type:shop:quote:app:undeclared')
183
+ expect(() => scopedSchemaHelper.assertTypeSchema(quote, flows)).not.toThrow()
184
+ expect(() => scopedSchemaHelper.assertFlowSchema({ ...MEND, app: { description: 'Bench' } }, APP)).not.toThrow()
185
+ expect(() => scopedSchemaHelper.assertFlowSchema(MEND, APP)).toThrow('flow:shop:mend:app:')
186
+ expect(() => scopedSchemaHelper.assertFlowSchema(MEND)).not.toThrow()
187
+ })
188
+ })
@@ -1,17 +1,17 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import { IntrinsicStatus, TransitionAction, WorkcardKind } from '../src/consts.js'
3
3
  import { IllegalTransition, PlanningError } from '../src/errors.js'
4
- import { applyRelationship, applyTransition } from '../src/helpers/apply.js'
5
- import { assertMutable, computeChanges, isEmptyChange } from '../src/helpers/changes.js'
6
- import type { Relationship, Specification, Workcard } from '../src/types.js'
4
+ import type { Relationship, Specification, Workcard, WorkcardChanges } from '../src/types.js'
7
5
  import {
8
6
  AT, ENTITY, LATER, PROJECT_TYPE, SPEC_TYPE, STORY_TYPE, TASK_TYPE, makeRegistry, transitionOf,
9
7
  } from './fixtures.js'
8
+ import { applyHelper } from '../src/helpers/apply.js'
9
+ import { changesHelper } from '../src/helpers/changes.js'
10
10
 
11
11
  const registry = makeRegistry()
12
12
 
13
13
  const created = (): Workcard => {
14
- const { changes } = computeChanges(undefined, {
14
+ const { changes } = changesHelper.computeChanges(undefined, {
15
15
  action: TransitionAction.Create,
16
16
  card: {
17
17
  kind: WorkcardKind.Card, type: STORY_TYPE.type, parent: 'p1', title: 'As a user I sign in',
@@ -19,7 +19,7 @@ const created = (): Workcard => {
19
19
  },
20
20
  }, STORY_TYPE, registry, AT)
21
21
 
22
- return applyTransition(undefined, transitionOf({ card: 'c1', seq: 1, action: TransitionAction.Create, changes }))!
22
+ return applyHelper.applyTransition(undefined, transitionOf({ card: 'c1', seq: 1, action: TransitionAction.Create, changes }))!
23
23
  }
24
24
 
25
25
  describe('applyTransition — the fold', () => {
@@ -38,7 +38,7 @@ describe('applyTransition — the fold', () => {
38
38
  test('update merges fields, replaces the rest, clears unset — and never mutates its input', () => {
39
39
  const card = created()
40
40
  const before = structuredClone(card)
41
- const next = applyTransition(card, transitionOf({
41
+ const next = applyHelper.applyTransition(card, transitionOf({
42
42
  card: 'c1', seq: 2, action: TransitionAction.Update, at: LATER,
43
43
  changes: { title: 'Renamed', fields: { warning: 'x' }, labels: ['ui'] },
44
44
  unset: ['code', 'fields.primary'],
@@ -55,23 +55,23 @@ describe('applyTransition — the fold', () => {
55
55
  test('an applied seq returns the card unchanged; a gap or a missing card is refused', () => {
56
56
  const card = { ...created(), head: 3 }
57
57
 
58
- expect(applyTransition(card, transitionOf({ card: 'c1', seq: 1, action: TransitionAction.Update }))).toBe(card)
59
- expect(() => applyTransition(card, transitionOf({ card: 'c1', seq: 3, action: TransitionAction.Update })))
58
+ expect(applyHelper.applyTransition(card, transitionOf({ card: 'c1', seq: 1, action: TransitionAction.Update }))).toBe(card)
59
+ expect(() => applyHelper.applyTransition(card, transitionOf({ card: 'c1', seq: 3, action: TransitionAction.Update })))
60
60
  .toThrow(PlanningError)
61
- expect(() => applyTransition(undefined, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Update })))
61
+ expect(() => applyHelper.applyTransition(undefined, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Update })))
62
62
  .toThrow('fold:out-of-order')
63
- expect(applyTransition({ ...card, seq: 1 }, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Link, at: LATER })))
63
+ expect(applyHelper.applyTransition({ ...card, seq: 1 }, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Link, at: LATER })))
64
64
  .toMatchObject({ seq: 2, head: 3, updatedAt: LATER, title: card.title })
65
65
  })
66
66
 
67
67
  test('delete answers null, and a revised document carries its new revision', () => {
68
68
  const card = created()
69
- expect(applyTransition(card, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Delete }))).toBeNull()
69
+ expect(applyHelper.applyTransition(card, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Delete }))).toBeNull()
70
70
 
71
71
  const spec = { ...card, kind: WorkcardKind.Specification, category: 'design', format: 'json', body: '{}', revision: 1 } as Specification
72
- const { changes } = computeChanges(spec, { card: 'c1', action: TransitionAction.Update, changes: { body: '{"a":1}' } },
72
+ const { changes } = changesHelper.computeChanges(spec, { card: 'c1', action: TransitionAction.Update, changes: { body: '{"a":1}' } },
73
73
  SPEC_TYPE, registry, LATER, { slot: STORY_TYPE.specifications[0] })
74
- const revised = applyTransition(spec, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Update, changes })) as Specification
74
+ const revised = applyHelper.applyTransition(spec, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Update, changes })) as Specification
75
75
 
76
76
  expect([revised.revision, revised.body, revised.bodyChars]).toEqual([2, '{"a":1}', 7])
77
77
  })
@@ -79,7 +79,7 @@ describe('applyTransition — the fold', () => {
79
79
 
80
80
  describe('applyRelationship', () => {
81
81
  test('a create folds its links, from the card unless a draft names another end', () => {
82
- const links = applyRelationship([], transitionOf({
82
+ const links = applyHelper.applyRelationship([], transitionOf({
83
83
  id: 't1', card: 'c2', seq: 1, action: TransitionAction.Create, project: 'p1',
84
84
  links: [{ type: 'follows', to: 'c1' }, { type: 'follows', from: 'c0', to: 'c2' }],
85
85
  }))
@@ -92,12 +92,12 @@ describe('applyRelationship', () => {
92
92
 
93
93
  test('link is idempotent and unlink removes only the matching edge', () => {
94
94
  const link = transitionOf({ card: 'c2', seq: 2, action: TransitionAction.Link, link: { type: 'follows', to: 'c1' } })
95
- const once = applyRelationship([], link)
96
- const twice = applyRelationship(once, link)
97
- const other = applyRelationship(twice, { ...link, link: { type: 'blocks', to: 'c1' } })
95
+ const once = applyHelper.applyRelationship([], link)
96
+ const twice = applyHelper.applyRelationship(once, link)
97
+ const other = applyHelper.applyRelationship(twice, { ...link, link: { type: 'blocks', to: 'c1' } })
98
98
 
99
99
  expect(twice).toHaveLength(1)
100
- expect(applyRelationship(other, { ...link, action: TransitionAction.Unlink }).map(edge => edge.type))
100
+ expect(applyHelper.applyRelationship(other, { ...link, action: TransitionAction.Unlink }).map(edge => edge.type))
101
101
  .toEqual(['blocks'])
102
102
  })
103
103
 
@@ -105,7 +105,7 @@ describe('applyRelationship', () => {
105
105
  const edge = (from: string, to: string): Relationship => ({ entityId: ENTITY, type: 'follows', from, to, createdAt: AT })
106
106
  const links = [edge('c1', 'c2'), edge('c3', 'c1'), edge('c3', 'c4')]
107
107
 
108
- expect(applyRelationship(links, transitionOf({ card: 'c1', seq: 5, action: TransitionAction.Delete })))
108
+ expect(applyHelper.applyRelationship(links, transitionOf({ card: 'c1', seq: 5, action: TransitionAction.Delete })))
109
109
  .toEqual([edge('c3', 'c4')])
110
110
  })
111
111
  })
@@ -113,23 +113,23 @@ describe('applyRelationship', () => {
113
113
  describe('computeChanges', () => {
114
114
  test('an update records only what differs, and one that changes nothing is empty', () => {
115
115
  const card = created()
116
- const set = computeChanges(card, {
116
+ const set = changesHelper.computeChanges(card, {
117
117
  card: 'c1', action: TransitionAction.Update,
118
118
  changes: { title: card.title, description: 'New', fields: { area: 'user', primary: true } },
119
119
  }, STORY_TYPE, registry, LATER)
120
120
 
121
121
  expect(set).toEqual({ changes: { description: 'New', fields: { primary: true } }, unset: [] })
122
- expect(isEmptyChange(computeChanges(card, {
122
+ expect(changesHelper.isEmptyChange(changesHelper.computeChanges(card, {
123
123
  card: 'c1', action: TransitionAction.Update, changes: { title: card.title, code: null as never },
124
124
  }, STORY_TYPE, registry, LATER))).toBe(false)
125
- expect(isEmptyChange(computeChanges(card, {
125
+ expect(changesHelper.isEmptyChange(changesHelper.computeChanges(card, {
126
126
  card: 'c1', action: TransitionAction.Update, changes: { title: card.title, description: null as never },
127
127
  }, STORY_TYPE, registry, LATER))).toBe(true)
128
128
  })
129
129
 
130
130
  test('a transit moves the flow, mirrors status and intrinsic, and sets or clears closedAt', () => {
131
131
  const card = { ...created(), status: 'in-progress', flows: { 'test:story': 'in-progress' }, intrinsic: IntrinsicStatus.InProgress }
132
- const completed = computeChanges(card, { card: 'c1', action: TransitionAction.Transit, transition: 'complete' }, STORY_TYPE, registry, LATER)
132
+ const completed = changesHelper.computeChanges(card, { card: 'c1', action: TransitionAction.Transit, transition: 'complete' }, STORY_TYPE, registry, LATER)
133
133
 
134
134
  expect(completed).toEqual({
135
135
  changes: { flows: { 'test:story': 'completed' }, status: 'completed', intrinsic: IntrinsicStatus.Closed, closedAt: LATER },
@@ -137,23 +137,23 @@ describe('computeChanges', () => {
137
137
  })
138
138
 
139
139
  const closed = { ...card, status: 'completed', flows: { 'test:story': 'completed' }, intrinsic: IntrinsicStatus.Closed, closedAt: LATER }
140
- expect(computeChanges(closed, { card: 'c1', action: TransitionAction.Transit, transition: 'reset' }, STORY_TYPE, registry, LATER).unset)
140
+ expect(changesHelper.computeChanges(closed, { card: 'c1', action: TransitionAction.Transit, transition: 'reset' }, STORY_TYPE, registry, LATER).unset)
141
141
  .toEqual(['closedAt'])
142
- expect(() => computeChanges(closed, { card: 'c1', action: TransitionAction.Transit, transition: 'start' }, STORY_TYPE, registry, LATER))
142
+ expect(() => changesHelper.computeChanges(closed, { card: 'c1', action: TransitionAction.Transit, transition: 'start' }, STORY_TYPE, registry, LATER))
143
143
  .toThrow(IllegalTransition)
144
144
  })
145
145
 
146
146
  test('a non-primary transit under IntrinsicPolicy.All keeps the card open until every flow closes', () => {
147
147
  const task = { ...created(), type: TASK_TYPE.type, status: 'completed', intrinsic: IntrinsicStatus.Planned,
148
148
  flows: { 'test:story': 'completed', 'test:review': 'reviewing' } }
149
- const set = computeChanges(task, { card: 'c1', action: TransitionAction.Transit, transition: 'approve', flow: 'test:review' },
149
+ const set = changesHelper.computeChanges(task, { card: 'c1', action: TransitionAction.Transit, transition: 'approve', flow: 'test:review' },
150
150
  TASK_TYPE, registry, LATER)
151
151
 
152
152
  expect(set.changes).toEqual({ flows: { 'test:review': 'approved' }, intrinsic: IntrinsicStatus.Closed, closedAt: LATER })
153
153
  })
154
154
 
155
155
  test('a create of a revisioned document starts at revision 1 with its body length', () => {
156
- const { changes } = computeChanges(undefined, {
156
+ const { changes } = changesHelper.computeChanges(undefined, {
157
157
  action: TransitionAction.Create,
158
158
  card: { kind: WorkcardKind.Specification, type: SPEC_TYPE.type, parent: 'p1', title: 'scaffold', category: 'scaffold', body: '{}' },
159
159
  }, SPEC_TYPE, registry, AT, { slot: PROJECT_TYPE.specifications[1] })
@@ -164,13 +164,77 @@ describe('computeChanges', () => {
164
164
 
165
165
  describe('assertMutable', () => {
166
166
  test('status moves only through transit, a fixed code stays fixed, derived fields are never supplied', () => {
167
- expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { status: 'completed' } }, STORY_TYPE))
167
+ expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { status: 'completed' } }, STORY_TYPE))
168
168
  .toThrow('immutable:status')
169
- expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'US-X' } }, STORY_TYPE))
169
+ expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'US-X' } }, STORY_TYPE))
170
170
  .toThrow('immutable:code')
171
- expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, unset: ['title'] }, STORY_TYPE))
171
+ expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, unset: ['title'] }, STORY_TYPE))
172
172
  .toThrow('immutable:title')
173
- expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'free' } }, TASK_TYPE))
173
+ expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'free' } }, TASK_TYPE))
174
174
  .not.toThrow()
175
175
  })
176
+
177
+ test('provenance is never written after the create: createdBy and createdAt in changes or unset, on any action', () => {
178
+ const draft = { kind: WorkcardKind.Card, type: STORY_TYPE.type, parent: 'p1', title: 'Owned', createdBy: 'owner-1' }
179
+ const refused = [
180
+ { card: 'c1', action: TransitionAction.Update, changes: { createdBy: 'intruder' } },
181
+ { card: 'c1', action: TransitionAction.Update, changes: { createdBy: null } },
182
+ { card: 'c1', action: TransitionAction.Update, unset: ['createdBy'] },
183
+ { card: 'c1', action: TransitionAction.Transit, transition: 'start', changes: { createdBy: 'intruder' } },
184
+ { card: 'c1', action: TransitionAction.Link, link: { type: 'follows', to: 'c2' }, unset: ['createdBy'] },
185
+ { card: draft, action: TransitionAction.Create, changes: { createdBy: 'intruder' } },
186
+ ] as Parameters<typeof changesHelper.assertMutable>[0][]
187
+
188
+ for (const exec of refused) {
189
+ expect(() => changesHelper.assertMutable(exec, STORY_TYPE)).toThrow('planning:immutable:createdBy')
190
+ }
191
+ expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { createdAt: LATER } as WorkcardChanges }, STORY_TYPE))
192
+ .toThrow('planning:immutable:createdAt')
193
+ expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, unset: ['createdAt'] }, STORY_TYPE))
194
+ .toThrow('planning:immutable:createdAt')
195
+ expect(() => changesHelper.assertMutable({ card: draft, action: TransitionAction.Create }, STORY_TYPE)).not.toThrow()
196
+ expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { createdBy: undefined, title: 'T' } }, STORY_TYPE))
197
+ .not.toThrow()
198
+ })
199
+ })
200
+
201
+ describe('createdBy — written by the create alone', () => {
202
+ const owned = (): Workcard => ({ ...created(), createdBy: 'owner-1' })
203
+
204
+ test('computeChanges takes it from the draft and never from changes or unset', () => {
205
+ const create = changesHelper.computeChanges(undefined, {
206
+ action: TransitionAction.Create,
207
+ card: { kind: WorkcardKind.Card, type: STORY_TYPE.type, parent: 'p1', title: 'Owned', createdBy: 'owner-1' },
208
+ changes: { createdBy: 'intruder' },
209
+ }, STORY_TYPE, registry, AT)
210
+ expect(create.changes.createdBy).toBe('owner-1')
211
+
212
+ const update = changesHelper.computeChanges(owned(), {
213
+ card: 'c1', action: TransitionAction.Update, changes: { createdBy: 'intruder', title: 'Renamed' }, unset: ['createdBy'],
214
+ }, STORY_TYPE, registry, LATER)
215
+ expect(update).toEqual({ changes: { title: 'Renamed' }, unset: [] })
216
+
217
+ const cleared = changesHelper.computeChanges(owned(), { card: 'c1', action: TransitionAction.Update, changes: { createdBy: null } as unknown as WorkcardChanges },
218
+ STORY_TYPE, registry, LATER)
219
+ expect(changesHelper.isEmptyChange(cleared)).toBe(true)
220
+
221
+ const transit = changesHelper.computeChanges(owned(), {
222
+ card: 'c1', action: TransitionAction.Transit, transition: 'start', changes: { createdBy: 'intruder' }, unset: ['createdBy'],
223
+ }, STORY_TYPE, registry, LATER)
224
+ expect(transit.changes.createdBy).toBeUndefined()
225
+ expect(transit.unset).not.toContain('createdBy')
226
+ })
227
+
228
+ test('the fold replays a legacy transition that named it, as written — the refusal is at admission', () => {
229
+ const card = owned()
230
+ const renamed = applyHelper.applyTransition(card, transitionOf({
231
+ card: 'c1', seq: 2, action: TransitionAction.Update, at: LATER, changes: { title: 'Legacy', createdBy: 'intruder' },
232
+ }))!
233
+ const cleared = applyHelper.applyTransition(renamed, transitionOf({ card: 'c1', seq: 3, action: TransitionAction.Update, unset: ['createdBy'] }))!
234
+
235
+ expect([renamed.seq, renamed.title, renamed.createdBy, renamed.createdAt]).toEqual([2, 'Legacy', 'intruder', AT])
236
+ expect([cleared.seq, cleared.title, cleared.createdBy]).toEqual([3, 'Legacy', undefined])
237
+ expect(applyHelper.applyTransition(cleared, transitionOf({ card: 'c1', seq: 4, action: TransitionAction.Update, changes: { title: 'After' } }))!
238
+ .title).toBe('After')
239
+ })
176
240
  })
@@ -1,14 +1,14 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import { CodeScope, CodeStyle } from '../src/consts.js'
3
3
  import { CodeTaken } from '../src/errors.js'
4
- import { codeScopeOf, mintCode } from '../src/helpers/code.js'
5
4
  import { STORY_TYPE } from './fixtures.js'
5
+ import { codeHelper } from '../src/helpers/code.js'
6
6
 
7
7
  const free = () => false
8
8
 
9
9
  describe('mintCode', () => {
10
10
  test('random: prefix plus an upper-cased Base58 part of the policy length', async () => {
11
- const code = await mintCode(STORY_TYPE.code!, free)
11
+ const code = await codeHelper.mintCode(STORY_TYPE.code!, free)
12
12
 
13
13
  expect(code).toMatch(/^US-[1-9A-Z]{5}$/)
14
14
  })
@@ -17,24 +17,24 @@ describe('mintCode', () => {
17
17
  const policy = { style: CodeStyle.Slug, uniqueWithin: CodeScope.Entity }
18
18
  const taken = new Set(['shop-dashboard', 'shop-dashboard-2'])
19
19
 
20
- expect(await mintCode(policy, free, 8, { seed: 'Shop Dashboard!' })).toBe('shop-dashboard')
21
- expect(await mintCode(policy, code => taken.has(code), 8, { seed: 'Shop Dashboard!' })).toBe('shop-dashboard-3')
22
- expect(await mintCode(policy, free)).toMatch(/^[a-z]+-[a-z]+$/)
20
+ expect(await codeHelper.mintCode(policy, free, 8, { seed: 'Shop Dashboard!' })).toBe('shop-dashboard')
21
+ expect(await codeHelper.mintCode(policy, code => taken.has(code), 8, { seed: 'Shop Dashboard!' })).toBe('shop-dashboard-3')
22
+ expect(await codeHelper.mintCode(policy, free)).toMatch(/^[a-z]+-[a-z]+$/)
23
23
  })
24
24
 
25
25
  test('sequential: counts on from the scope, zero-padded to the length', async () => {
26
26
  const policy = { prefix: 'REQ-', style: CodeStyle.Sequential, length: 3, uniqueWithin: CodeScope.Parent }
27
27
 
28
- expect(await mintCode(policy, free, 8, { count: 41 })).toBe('REQ-042')
29
- expect(await mintCode(policy, async code => code === 'REQ-042', 8, { count: 41 })).toBe('REQ-043')
28
+ expect(await codeHelper.mintCode(policy, free, 8, { count: 41 })).toBe('REQ-042')
29
+ expect(await codeHelper.mintCode(policy, async code => code === 'REQ-042', 8, { count: 41 })).toBe('REQ-043')
30
30
  })
31
31
 
32
32
  test('refuses with CodeTaken after the attempt cap, asking exactly that many times', async () => {
33
33
  let asked = 0
34
34
  const always = async () => { asked++; return true }
35
35
 
36
- await expect(mintCode(STORY_TYPE.code!, always, 3)).rejects.toThrow(CodeTaken)
36
+ await expect(codeHelper.mintCode(STORY_TYPE.code!, always, 3)).rejects.toThrow(CodeTaken)
37
37
  expect(asked).toBe(3)
38
- expect(codeScopeOf(STORY_TYPE.code!, { parent: 'p1' })).toEqual({ parent: 'p1' })
38
+ expect(codeHelper.codeScopeOf(STORY_TYPE.code!, { parent: 'p1' })).toEqual({ parent: 'p1' })
39
39
  })
40
40
  })
@@ -0,0 +1,122 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { IntrinsicStatus, WorkcardKind } from '../src/index.js'
3
+ import type {
4
+ PlanningAppDeclarations, PlanningSchemaBundle, ProjectTypeSchema, StatusFlowSchema, WorkcardTypeSchema,
5
+ } from '../src/index.js'
6
+ import { appSchemaHelper } from '../src/helpers/app.js'
7
+ import { scopedSchemaHelper } from '../src/helpers/scoped.js'
8
+ import { makeSchemaRegistry } from '../src/registry.js'
9
+
10
+ /** What a browser says when a page's `script-src` lacks `'unsafe-eval'`. */
11
+ const REFUSAL = "Evaluating a string as JavaScript violates the following Content Security Policy directive because 'unsafe-eval' is not an allowed source of script"
12
+
13
+ /** The work-management seed's declarations: every type and flow may carry a description. */
14
+ const APP: PlanningAppDeclarations = {
15
+ type: { properties: { description: { type: 'string', maxLength: 2000 } } },
16
+ flow: { properties: { description: { type: 'string', maxLength: 2000 } } },
17
+ }
18
+
19
+ const WORK: StatusFlowSchema = {
20
+ id: 'wm:work-flow', version: 1, overridable: true, app: { description: 'Day-to-day work' },
21
+ statuses: [{ key: 'open', intrinsic: IntrinsicStatus.Planned, initial: true }, { key: 'done', intrinsic: IntrinsicStatus.Closed }],
22
+ transitions: [{ name: 'finish', from: ['open'], to: 'done' }],
23
+ }
24
+
25
+ const TASK: WorkcardTypeSchema = {
26
+ type: 'wm:task', kind: WorkcardKind.Card, version: 1, flows: [WORK.id], specifications: [], overridable: true,
27
+ app: { description: 'Work items' },
28
+ fields: { type: 'object', properties: { priority: { type: 'string', enum: ['low', 'high'] } }, required: ['priority'] },
29
+ }
30
+
31
+ const WORKSPACE: ProjectTypeSchema = {
32
+ type: 'wm:workspace', kind: WorkcardKind.Project, version: 1, fields: { type: 'object' },
33
+ flows: [WORK.id], specifications: [], cardTypes: [TASK.type],
34
+ }
35
+
36
+ /**
37
+ * A browser page under the publisher's policy: a `document` exists and every code generation is
38
+ * refused, the way a `script-src` without `'unsafe-eval'` refuses `new Function` and `eval`.
39
+ */
40
+ const makeStrictPage = () => {
41
+ const scope = globalThis as unknown as Record<string, unknown>
42
+ const saved = { Function: scope.Function as FunctionConstructor, eval: scope.eval as typeof eval, error: console.error }
43
+ const counts = { generated: 0, logged: 0 }
44
+ const refuse = (): never => {
45
+ counts.generated++
46
+ throw new EvalError(REFUSAL)
47
+ }
48
+ const refusing = { Function: new Proxy(saved.Function, { construct: refuse, apply: refuse }), eval: new Proxy(saved.eval, { apply: refuse }) }
49
+
50
+ return {
51
+ counts,
52
+ enter: () => {
53
+ scope.document = {}
54
+ scope.Function = refusing.Function
55
+ scope.eval = refusing.eval
56
+ console.error = () => { counts.logged++ }
57
+ },
58
+ leave: () => {
59
+ delete scope.document
60
+ scope.Function = saved.Function
61
+ scope.eval = saved.eval
62
+ console.error = saved.error
63
+ },
64
+ }
65
+ }
66
+
67
+ const page = makeStrictPage()
68
+
69
+ /** What a server serves: compiled there, then a JSON body — no object a page could share with it. */
70
+ const served = (): PlanningSchemaBundle => JSON.parse(JSON.stringify(makeSchemaRegistry({
71
+ version: 1, types: [WORKSPACE, TASK], flows: [WORK], app: appSchemaHelper.merge([APP]),
72
+ }).bundle())) as PlanningSchemaBundle
73
+
74
+ afterEach(() => page.leave())
75
+
76
+ describe('planning schemas — a page under a strict script-src', () => {
77
+ test('a registry builds, loads and merges served declarations without generating code', () => {
78
+ const bundle = served()
79
+ const scoped = JSON.parse(JSON.stringify(scopedSchemaHelper.resolveScopedBundle(bundle, [], { entityId: 'org-1' })))
80
+ page.counts.generated = 0
81
+ page.counts.logged = 0
82
+
83
+ page.enter()
84
+ const registry = makeSchemaRegistry(bundle)
85
+ expect(registry.app()).toEqual(bundle.app!)
86
+ registry.load(bundle)
87
+ expect(registry.bundle()).toEqual(bundle)
88
+ expect(appSchemaHelper.merge([bundle.app])).toEqual(bundle.app!)
89
+ appSchemaHelper.assertDeclarations(bundle.app!)
90
+ const layer = scopedSchemaHelper.scopedRegistryOf(scoped)
91
+ expect(layer.app()).toEqual(bundle.app!)
92
+ expect((layer.type(TASK.type) as WorkcardTypeSchema).app).toEqual({ description: 'Work items' })
93
+ expect(layer.fieldsSchema(TASK.type)).toEqual(TASK.fields)
94
+ page.leave()
95
+
96
+ expect(page.counts).toEqual({ generated: 0, logged: 0 })
97
+ })
98
+
99
+ test('the closed form is still refused in a page; only the compile is the server\'s', () => {
100
+ const bundle = served()
101
+ const uncompilable = { type: { properties: { a: { type: 'nope' } } } } as unknown as PlanningAppDeclarations
102
+ expect(() => appSchemaHelper.assertDeclarations(uncompilable)).toThrow('app:type:')
103
+
104
+ page.enter()
105
+ expect(() => appSchemaHelper.assertDeclarations({ card: { properties: { 'a.b': {} } } })).toThrow('app:card:name:a.b')
106
+ expect(() => makeSchemaRegistry({ ...bundle, app: { type: { properties: { a: {} }, required: ['b'] } } }))
107
+ .toThrow('app:type:required:b')
108
+ expect(() => appSchemaHelper.assertDeclarations(uncompilable)).not.toThrow()
109
+ page.leave()
110
+ })
111
+
112
+ test('the page really refuses code generation: a native validator cannot be compiled in it', () => {
113
+ const bundle = served()
114
+ page.counts.generated = 0
115
+
116
+ page.enter()
117
+ expect(() => makeSchemaRegistry(bundle).validator(TASK.type)).toThrow(EvalError)
118
+ page.leave()
119
+
120
+ expect(page.counts.generated).toBeGreaterThan(0)
121
+ })
122
+ })
@@ -2,14 +2,15 @@ import { describe, expect, test } from 'bun:test'
2
2
  import { protocols } from '@owlmeans/entrypoint'
3
3
  import { RouteMethod, RouteProtocols } from '@owlmeans/route'
4
4
  import { makePlanningProtocols } from '../src/entrypoints.js'
5
- import { PLANNING_PATH, planningAliases } from '../src/consts.js'
5
+ import { PLANNING_PATH } from '../src/consts.js'
6
+ import { planningAliasHelper } from '../src/aliases.js'
6
7
 
7
8
  const BASE = 'app:api:planning'
8
9
 
9
10
  describe('makePlanningProtocols', () => {
10
11
  test('declares every leaf once, each alias derived from the base alias', () => {
11
12
  const tree = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default' })
12
- const aliases = planningAliases(BASE)
13
+ const aliases = planningAliasHelper.planningAliases(BASE)
13
14
  const declared = protocols(tree as never).map(protocol => protocol.alias)
14
15
 
15
16
  expect(new Set(declared).size).toBe(declared.length)
@@ -46,6 +47,21 @@ describe('makePlanningProtocols', () => {
46
47
  expect(tree.card.list.contract?.requestSchemas.query).toBeDefined()
47
48
  })
48
49
 
50
+ test('definitions add the scoped schema list and the schema write; without them neither exists', () => {
51
+ const plain = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default' })
52
+ const scoped = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default', definitions: true })
53
+ const define = planningAliasHelper.planningDefinitionAliases(BASE).define
54
+
55
+ expect(plain.schema.define).toBeUndefined()
56
+ expect(plain.schema.list.contract?.requestSchemas.query).toBeUndefined()
57
+ expect(protocols(plain as never).some(protocol => protocol.alias === define)).toBe(false)
58
+
59
+ expect(scoped.schema.define?.alias).toBe(define)
60
+ expect(scoped.schema.define?.route.route).toMatchObject({ path: '/schemas', method: RouteMethod.POST, parent: BASE })
61
+ expect(scoped.schema.list.contract?.requestSchemas.query).toBeDefined()
62
+ expect(protocols(scoped as never).filter(protocol => protocol.alias === define)).toHaveLength(1)
63
+ })
64
+
49
65
  test('the commit feed is a socket under socketBase when given, under the planning base otherwise', () => {
50
66
  const own = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default' })
51
67
  const shared = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default', socketBase: 'app:update' })