@debonet/idd 0.0.0-stage → 0.1.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.
@@ -0,0 +1,593 @@
1
+ // @tested-by test/idd-page.test.js
2
+ // @implements s-hcsr5l@76b1b4a
3
+ // @implements s-2b5w6i@84ae053
4
+ // @implements s-r2o7ne@b4bd635
5
+ // @implements s-2bfbql@55fab0b
6
+ // @implements s-pmnqlm@bd37ac7
7
+ // @implements s-a1zj4t@07d0ac9
8
+ // @implements s-ikua3t@86a2f26
9
+ // @implements s-qvff9k@3d4e66c
10
+ // @implements s-hri503@c92744d
11
+ // @implements s-g7mf6n@20749b5
12
+ // @implements s-hgnnfg@dde63b5
13
+ // @implements s-kwn5lt@8da600a
14
+ // @implements s-0ra8q5@62f6cf4
15
+ // @implements s-ejxfi4@df4cd34
16
+ // @implements s-u52jq6@07a03f3
17
+ // @implements s-f6ji64@78163fb
18
+ // @implements s-rhzch5@7a9a9df
19
+ // @implements s-0yj34e@d161f49
20
+ // @implements s-bjouc5@55ab271
21
+ // @implements s-s0obkh@481f803
22
+ // @implements s-twjma0@49ee5c9
23
+ // @implements s-wznig4@d4f6d37
24
+ // @implements s-0yp4n2@d8f6b98
25
+ // @implements s-fvhi3x@f836795
26
+ // @implements s-r40lfj@d88f3b2
27
+ // @implements s-g5gxep@784430f
28
+ // @implements s-61lmfb@6451bc6
29
+ // @implements s-0u0b06@9b94e8d
30
+ // @implements s-8ffele@5deea72
31
+ // @implements s-upxu0m@00fd9f6
32
+ const crypto = require( "crypto" )
33
+ const sFilePage = "idd/page.json"
34
+ const sFileQuestion = "idd/questions.json"
35
+ const sFileAnswer = "idd/answers.json"
36
+ const sFilePending = "idd/pending.json"
37
+ const sFileStatus = "idd/status.json"
38
+ const sFileHistory = "idd/history.json"
39
+ const sFileConflict = "idd/conflicts.json"
40
+ const sFileThoughts = "idd/thoughts.json"
41
+ const sFileClaims = "idd/claims.json"
42
+
43
+ function fbString( x ){
44
+ return typeof x == "string" && x.length > 0
45
+ }
46
+
47
+ function fbObject( x ){
48
+ return typeof x == "object" && x !== null && !Array.isArray( x )
49
+ }
50
+
51
+ function fvsMemberProblem( a, vsAllowed, vsRequired, sWhere ){
52
+ return [
53
+ ...vsRequired.filter(( s ) => !( s in a )).map(
54
+ ( s ) => sWhere + "member " + s + " is missing"
55
+ ),
56
+ ...Object.keys( a ).filter(( s ) => !vsAllowed.includes( s )).map(
57
+ ( s ) => sWhere + "member " + s + " is not allowed"
58
+ ),
59
+ ]
60
+ }
61
+
62
+ function fvsPageProblem( aPage ){
63
+ if( !fbObject( aPage )){
64
+ return [ "not a JSON object" ]
65
+ }
66
+ const vsMember = [ "title", "url", "branchHash" ]
67
+ return [
68
+ ...fvsMemberProblem( aPage, vsMember, vsMember, "" ),
69
+ ...( "title" in aPage && !fbString( aPage.title )
70
+ ? [ "title is not a non-empty string" ] : [] ),
71
+ ...( "url" in aPage && aPage.url !== null && !fbString( aPage.url )
72
+ ? [ "url is neither a non-empty string nor null" ] : [] ),
73
+ ...( "branchHash" in aPage && !/^[0-9a-f]{64}$/.test( aPage.branchHash )
74
+ ? [ "branchHash is not a lowercase SHA-256 digest" ] : [] ),
75
+ ]
76
+ }
77
+
78
+ function fvsOptionProblem( aOption, nOption ){
79
+ const sWhere = "option " + ( nOption + 1 ) + ": "
80
+ if( !fbObject( aOption )){
81
+ return [ sWhere + "not an object" ]
82
+ }
83
+ return [
84
+ ...fvsMemberProblem(
85
+ aOption, [ "key", "text", "tag" ], [ "key", "text" ], sWhere
86
+ ),
87
+ ...[ "key", "text" ].filter(
88
+ ( s ) => s in aOption && !fbString( aOption[ s ])
89
+ ).map(( s ) => sWhere + s + " is not a non-empty string" ),
90
+ ...( "tag" in aOption && !fbString( aOption.tag )
91
+ ? [ sWhere + "tag is not a non-empty string" ] : [] ),
92
+ ]
93
+ }
94
+
95
+ function fvsKeyRepeat( vaOption ){
96
+ const vsKey = vaOption.filter(
97
+ ( a ) => fbObject( a ) && fbString( a.key )
98
+ ).map(( a ) => a.key )
99
+ return [ ...new Set( vsKey.filter(
100
+ ( s, n ) => vsKey.indexOf( s ) != n
101
+ ))].map(( s ) => "option key " + s + " is held by two options" )
102
+ }
103
+
104
+ function fvsOptionsProblem( vaOption ){
105
+ if( !Array.isArray( vaOption )){
106
+ return [ "options is not an array" ]
107
+ }
108
+ const aLast = vaOption[ vaOption.length - 1 ]
109
+ const bHasX = fbReservedXOption( aLast )
110
+ const vaSubstantive = bHasX ? vaOption.slice( 0, -1 ) : vaOption
111
+ const bMisplacedX = vaOption.slice( 0, bHasX ? -1 : undefined )
112
+ .some(( a ) => fbObject( a ) && a.key === "X" ) ||
113
+ ( aLast && aLast.key === "X" && !bHasX )
114
+ return [
115
+ ...( vaSubstantive.length < 2 || vaSubstantive.length > 4
116
+ ? [ "options holds " + vaSubstantive.length + " substantive options, not two to four" ] : [] ),
117
+ ...( bMisplacedX ? [ "X is reserved for the final None of the above option" ] : [] ),
118
+ ...vaOption.flatMap( fvsOptionProblem ),
119
+ ...fvsKeyRepeat( vaOption ),
120
+ ]
121
+ }
122
+
123
+ const sXOptionText = "None of the above are right. Let me explain:"
124
+
125
+ function fbReservedXOption( aOption ){
126
+ return fbObject( aOption ) && aOption.key === "X" &&
127
+ aOption.text === sXOptionText &&
128
+ Object.keys( aOption ).length === 2 &&
129
+ Object.hasOwn( aOption, "key" ) && Object.hasOwn( aOption, "text" )
130
+ }
131
+
132
+ function faQuestionOptions( vaOption ){
133
+ if( !Array.isArray( vaOption )) return []
134
+ return fbReservedXOption( vaOption[ vaOption.length - 1 ] )
135
+ ? vaOption
136
+ : [ ...vaOption, { key: "X", text: sXOptionText } ]
137
+ }
138
+
139
+ function fsQuestionId( sBranchHash, aQuestion ){
140
+ const sMaterial = JSON.stringify([
141
+ sBranchHash,
142
+ aQuestion.title,
143
+ aQuestion.body,
144
+ aQuestion.options.map(( aOption ) => [
145
+ aOption.key, aOption.text, aOption.tag || "",
146
+ ]),
147
+ ])
148
+ return crypto.createHash( "sha256" ).update( sMaterial, "utf8" ).digest( "hex" )
149
+ }
150
+
151
+ function fsLegacyQuestionId( sBranchHash, aQuestion ){
152
+ const sMaterial = JSON.stringify([
153
+ sBranchHash,
154
+ aQuestion.title,
155
+ aQuestion.body,
156
+ aQuestion.options.filter(( aOption, n ) => !(
157
+ n === aQuestion.options.length - 1 && fbReservedXOption( aOption )
158
+ )).map(( aOption ) => [
159
+ aOption.key, aOption.text, aOption.tag || "",
160
+ ]),
161
+ ])
162
+ return crypto.createHash( "sha256" ).update( sMaterial, "utf8" ).digest( "hex" )
163
+ }
164
+
165
+ function fbQuestionId( sBranchHash, aQuestion ){
166
+ return aQuestion.id === fsQuestionId( sBranchHash, aQuestion ) ||
167
+ aQuestion.id === fsLegacyQuestionId( sBranchHash, aQuestion )
168
+ }
169
+
170
+ function fsBranchHash( sRepositoryId, sFullRef ){
171
+ const sMaterial = JSON.stringify([ String( sRepositoryId ), sFullRef ])
172
+ return crypto.createHash( "sha256" ).update( sMaterial, "utf8" ).digest( "hex" )
173
+ }
174
+
175
+ function fvsQuestionProblem( aQuestion ){
176
+ if( !fbObject( aQuestion )){
177
+ return [ "not an object" ]
178
+ }
179
+ const vsMember = [ "id", "branchHash", "title", "body", "options", "createdAt" ]
180
+ return [
181
+ ...fvsMemberProblem( aQuestion, vsMember, vsMember, "" ),
182
+ ...( "id" in aQuestion && !/^[0-9a-f]{64}$/.test( aQuestion.id )
183
+ ? [ "id is not a lowercase SHA-256 digest" ] : [] ),
184
+ ...( "branchHash" in aQuestion && !/^[0-9a-f]{64}$/.test( aQuestion.branchHash )
185
+ ? [ "branchHash is not a lowercase SHA-256 digest" ] : [] ),
186
+ ...[ "title", "body" ].filter(
187
+ ( s ) => s in aQuestion && !fbString( aQuestion[ s ])
188
+ ).map(( s ) => s + " is not a non-empty string" ),
189
+ ...( "options" in aQuestion
190
+ ? fvsOptionsProblem( aQuestion.options ) : [] ),
191
+ ...( "createdAt" in aQuestion && !fbIsoTime( aQuestion.createdAt )
192
+ ? [ "createdAt is not an ISO 8601 time" ] : [] ),
193
+ ]
194
+ }
195
+
196
+ function fvsAddProblem( aIn ){
197
+ if( !fbObject( aIn )){
198
+ return [ "stdin does not hold a JSON object" ]
199
+ }
200
+ const vsMember = [ "title", "body", "options" ]
201
+ return [
202
+ ...fvsMemberProblem( aIn, vsMember, vsMember, "" ),
203
+ ...fvsQuestionProblem({
204
+ ...aIn,
205
+ id: "0".repeat( 64 ),
206
+ branchHash: "0".repeat( 64 ),
207
+ createdAt: "2000-01-01T00:00:00.000Z",
208
+ }).filter(
209
+ ( s ) => !s.startsWith( "member " )
210
+ ),
211
+ ]
212
+ }
213
+
214
+ function fsLabel(aQuestion, n ){
215
+ return fbObject( aQuestion ) && typeof aQuestion.id == "string"
216
+ ? aQuestion.id : "question " + ( n + 1 )
217
+ }
218
+
219
+ function fvsIdRepeat( vaQuestion ){
220
+ const vsId = vaQuestion.filter( fbObject ).map(( a ) => a.id )
221
+ return [ ...new Set( vsId.filter(
222
+ ( s, n ) => typeof s == "string" && vsId.indexOf( s ) != n
223
+ ))].map(( s ) => s + ": id is held by two questions" )
224
+ }
225
+
226
+ function fvsQuestionsProblem( vaQuestion ){
227
+ if( !Array.isArray( vaQuestion )){
228
+ return [ "not a JSON array" ]
229
+ }
230
+ return [
231
+ ...vaQuestion.flatMap(( a, n ) => fvsQuestionProblem( a ).map(
232
+ ( s ) => fsLabel( a, n ) + ": " + s
233
+ )),
234
+ ...fvsIdRepeat( vaQuestion ),
235
+ ...vaQuestion.flatMap(( a, n ) => fbObject( a ) &&
236
+ typeof a.id === "string" && /^[0-9a-f]{64}$/.test( a.branchHash ) &&
237
+ Array.isArray( a.options ) &&
238
+ a.options.every(( aOption ) => fbObject( aOption ) &&
239
+ typeof aOption.key === "string" && typeof aOption.text === "string" ) &&
240
+ !fbQuestionId( a.branchHash, a )
241
+ ? [ fsLabel( a, n ) + ": id differs from its branch-scoped question hash" ]
242
+ : [] ),
243
+ ]
244
+ }
245
+
246
+ function fbIsoTime( x ){
247
+ return typeof x == "string" && Number.isFinite( Date.parse( x )) &&
248
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/
249
+ .test( x )
250
+ }
251
+
252
+ function fsThoughtId( aThought ){
253
+ const sMaterial = JSON.stringify([
254
+ aThought.branchHash, aThought.createdAt, aThought.text,
255
+ ])
256
+ return crypto.createHash( "sha256" ).update( sMaterial, "utf8" ).digest( "hex" )
257
+ }
258
+
259
+ function fvsThoughtsProblem( vaThought ){
260
+ if( !Array.isArray( vaThought )) return [ "not a JSON array" ]
261
+ const vsId = new Set()
262
+ return vaThought.flatMap(( aThought, nThought ) => {
263
+ const sWhere = "thought " + ( nThought + 1 ) + ": "
264
+ if( !fbObject( aThought )) return [ sWhere + "not an object" ]
265
+ const vsMember = [ "id", "branchHash", "text", "createdAt" ]
266
+ const vsProblem = fvsMemberProblem(
267
+ aThought, vsMember, vsMember, sWhere
268
+ )
269
+ if( typeof aThought.id !== "string" || !/^[0-9a-f]{64}$/.test( aThought.id )){
270
+ vsProblem.push( sWhere + "id is not a lowercase SHA-256 digest" )
271
+ } else if( vsId.has( aThought.id )){
272
+ vsProblem.push( sWhere + "id is duplicated" )
273
+ } else {
274
+ vsId.add( aThought.id )
275
+ }
276
+ if( typeof aThought.branchHash !== "string" ||
277
+ !/^[0-9a-f]{64}$/.test( aThought.branchHash )){
278
+ vsProblem.push( sWhere + "branchHash is not a lowercase SHA-256 digest" )
279
+ }
280
+ if( !fbString( aThought.text )){
281
+ vsProblem.push( sWhere + "text is not a non-empty string" )
282
+ }
283
+ if( !fbIsoTime( aThought.createdAt )){
284
+ vsProblem.push( sWhere + "createdAt is not an ISO 8601 time" )
285
+ }
286
+ if( /^[0-9a-f]{64}$/.test( aThought.id ) &&
287
+ /^[0-9a-f]{64}$/.test( aThought.branchHash ) &&
288
+ fbString( aThought.text ) && fbIsoTime( aThought.createdAt ) &&
289
+ aThought.id !== fsThoughtId( aThought )){
290
+ vsProblem.push( sWhere + "thought id differs from its digest" )
291
+ }
292
+ return vsProblem
293
+ })
294
+ }
295
+
296
+ function fvsAnswerProblem( aAnswer, aQuestion ){
297
+ if( !fbObject( aAnswer )){
298
+ return [ "not an object" ]
299
+ }
300
+ const vsMember = [ "choice", "note", "time" ]
301
+ const vaOption = aQuestion && Array.isArray( aQuestion.options )
302
+ ? faQuestionOptions( aQuestion.options ) : []
303
+ return [
304
+ ...fvsMemberProblem( aAnswer, vsMember, vsMember, "" ),
305
+ ...[ "choice", "note" ].filter(
306
+ ( s ) => s in aAnswer && typeof aAnswer[ s ] != "string"
307
+ ).map(( s ) => s + " is not a string" ),
308
+ ...( "time" in aAnswer && !fbIsoTime( aAnswer.time )
309
+ ? [ "time is not an ISO 8601 string" ] : [] ),
310
+ ...( typeof aAnswer.choice == "string" && aAnswer.choice !== "" &&
311
+ !vaOption.some(( a ) => fbObject( a ) && a.key === aAnswer.choice )
312
+ ? [ "choice is neither empty nor a key of its question's options" ]
313
+ : [] ),
314
+ ]
315
+ }
316
+
317
+ function faQuestionOfId( sTextQuestion, sTextHistory = null ){
318
+ let va
319
+ try {
320
+ va = JSON.parse( sTextQuestion )
321
+ } catch( xError ){
322
+ return {}
323
+ }
324
+ const aQuestion = {}
325
+ for( const a of Array.isArray( va ) ? va : [] ){
326
+ if( fbObject( a ) && typeof a.id == "string" && !( a.id in aQuestion )){
327
+ aQuestion[ a.id ] = a
328
+ }
329
+ }
330
+ let vaHistory
331
+ try { vaHistory = JSON.parse( sTextHistory ) } catch( xError ){ vaHistory = [] }
332
+ for( const aEntry of Array.isArray( vaHistory ) ? vaHistory : [] ){
333
+ const a = fbObject( aEntry ) ? aEntry.question : null
334
+ if( fbObject( a ) && typeof a.id === "string" && !( a.id in aQuestion )){
335
+ aQuestion[ a.id ] = a
336
+ }
337
+ }
338
+ return aQuestion
339
+ }
340
+
341
+ function fvsAnswersProblem( aAnswer, aQuestionOfId ){
342
+ if( !fbObject( aAnswer )){
343
+ return [ "not a JSON object" ]
344
+ }
345
+ return Object.keys( aAnswer ).flatMap(( sId ) => (
346
+ Object.prototype.hasOwnProperty.call( aQuestionOfId, sId )
347
+ ? fvsAnswerProblem( aAnswer[ sId ], aQuestionOfId[ sId ])
348
+ : [ "not the id of a question" ]
349
+ ).map(( s ) => sId + ": " + s ))
350
+ }
351
+
352
+ function fvsPendingProblem( aPending, aQuestionOfId, aAnswer ){
353
+ if( !fbObject( aPending )){
354
+ return [ "not a JSON object" ]
355
+ }
356
+ return Object.keys( aPending ).flatMap(( sId ) => {
357
+ const vsProblem = Object.prototype.hasOwnProperty.call( aQuestionOfId, sId )
358
+ ? fvsAnswerProblem( aPending[ sId ], aQuestionOfId[ sId ])
359
+ : [ "not the id of a question" ]
360
+ const aRecorded = fbObject( aAnswer ) ? aAnswer[ sId ] : null
361
+ if( !vsProblem.length && (!fbObject( aRecorded ) ||
362
+ [ "choice", "note", "time" ].some(
363
+ ( s ) => aPending[ sId ][ s ] !== aRecorded[ s ]
364
+ ))){
365
+ vsProblem.push( "differs from the answer file" )
366
+ }
367
+ return vsProblem.map(( s ) => sId + ": " + s )
368
+ })
369
+ }
370
+
371
+ function fvsStatusProblem( aStatus ){
372
+ if( !fbObject( aStatus )) return [ "not a JSON object" ]
373
+ return Object.entries( aStatus ).flatMap(([ sSessionId, aEntry ]) => {
374
+ const vsProblem = []
375
+ if( !/^[A-Za-z0-9._:-]{1,128}$/.test( sSessionId )){
376
+ vsProblem.push( sSessionId + ": invalid session id" )
377
+ }
378
+ if( !fbObject( aEntry )){
379
+ vsProblem.push( sSessionId + ": status is not an object" )
380
+ return vsProblem
381
+ }
382
+ vsProblem.push( ...fvsMemberProblem( aEntry,
383
+ [ "blocked", "updatedAt" ], [ "blocked", "updatedAt" ],
384
+ sSessionId + ": " ))
385
+ if( "blocked" in aEntry && typeof aEntry.blocked !== "boolean" ){
386
+ vsProblem.push( sSessionId + ": blocked is not a boolean" )
387
+ }
388
+ if( "updatedAt" in aEntry && !fbIsoTime( aEntry.updatedAt )){
389
+ vsProblem.push( sSessionId + ": updatedAt is not an ISO 8601 time" )
390
+ }
391
+ return vsProblem
392
+ })
393
+ }
394
+
395
+ function fvsClaimsProblem( aClaims, aWorkIds ){
396
+ if( !fbObject( aClaims )) return [ "not a JSON object" ]
397
+ return Object.entries( aClaims ).flatMap(([ sKey, aClaim ]) => {
398
+ const vsProblem = []
399
+ const aMatch = sKey.match( /^(answer|thought|conflict):([0-9a-f]{64})$/ )
400
+ if( !aMatch ) vsProblem.push( sKey + ": invalid claim key" )
401
+ if( !fbObject( aClaim )) return [ ...vsProblem, sKey + ": claim is not an object" ]
402
+ vsProblem.push( ...fvsMemberProblem( aClaim,
403
+ [ "sessionId", "claimedAt" ], [ "sessionId", "claimedAt" ], sKey + ": " ))
404
+ if( "sessionId" in aClaim && !/^[A-Za-z0-9._:-]{1,128}$/.test( aClaim.sessionId )){
405
+ vsProblem.push( sKey + ": invalid session id" )
406
+ }
407
+ if( "claimedAt" in aClaim && !fbIsoTime( aClaim.claimedAt )){
408
+ vsProblem.push( sKey + ": claimedAt is not an ISO 8601 time" )
409
+ }
410
+ if( aMatch && aWorkIds[ aMatch[ 1 ]] && !aWorkIds[ aMatch[ 1 ]].has( aMatch[ 2 ])){
411
+ vsProblem.push( sKey + ": item id is not present in its source file" )
412
+ }
413
+ return vsProblem
414
+ })
415
+ }
416
+
417
+ function faActiveQuestionIds( sTextQuestion ){
418
+ try {
419
+ const vaQuestion = JSON.parse( sTextQuestion )
420
+ return new Set( Array.isArray( vaQuestion ) ? vaQuestion.map(( a ) => a && a.id ) : [] )
421
+ } catch( xError ){
422
+ return new Set()
423
+ }
424
+ }
425
+
426
+ function fvsHistoryProblem( aHistory, aQuestionOfId, vsActiveQuestionId ){
427
+ if( !Array.isArray( aHistory )) return [ "not a JSON array" ]
428
+ const vsSeen = new Set()
429
+ return aHistory.flatMap(( aEntry, n ) => {
430
+ const sWhere = "history entry " + ( n + 1 ) + ": "
431
+ if( !fbObject( aEntry )) return [ sWhere + "not an object" ]
432
+ const vsProblem = fvsMemberProblem( aEntry,
433
+ [ "questionId", "question", "replacedBy", "time" ],
434
+ [ "questionId", "question", "replacedBy", "time" ], sWhere )
435
+ if( typeof aEntry.questionId !== "string" ||
436
+ !/^[0-9a-f]{64}$/.test( aEntry.questionId )){
437
+ vsProblem.push( sWhere + "questionId is not a lowercase SHA-256 digest" )
438
+ }
439
+ if( typeof aEntry.replacedBy !== "string" ||
440
+ !/^[0-9a-f]{64}$/.test( aEntry.replacedBy )){
441
+ vsProblem.push( sWhere + "replacedBy is not a lowercase SHA-256 digest" )
442
+ } else if( aEntry.replacedBy === aEntry.questionId ){
443
+ vsProblem.push( sWhere + "question cannot replace itself" )
444
+ } else if( !Object.prototype.hasOwnProperty.call( aQuestionOfId, aEntry.replacedBy )){
445
+ vsProblem.push( sWhere + "replacedBy is not an active or historical question id" )
446
+ }
447
+ if( !fbIsoTime( aEntry.time )){
448
+ vsProblem.push( sWhere + "time is not an ISO 8601 time" )
449
+ }
450
+ vsProblem.push( ...fvsQuestionProblem( aEntry.question )
451
+ .map(( s ) => sWhere + "question: " + s ))
452
+ if( fbObject( aEntry.question ) &&
453
+ aEntry.question.id !== aEntry.questionId ){
454
+ vsProblem.push( sWhere + "question id differs from questionId" )
455
+ }
456
+ if( vsActiveQuestionId.has( aEntry.questionId )){
457
+ vsProblem.push( sWhere + "questionId is still active" )
458
+ }
459
+ if( typeof aEntry.questionId === "string" && vsSeen.has( aEntry.questionId )){
460
+ vsProblem.push( sWhere + "questionId already appears in history" )
461
+ }
462
+ if( typeof aEntry.questionId === "string" ) vsSeen.add( aEntry.questionId )
463
+ return vsProblem
464
+ })
465
+ }
466
+
467
+ function fvsConflictsProblem( vaConflict, aQuestionOfId ){
468
+ if( !Array.isArray( vaConflict )) return [ "not a JSON array" ]
469
+ const vsId = new Set()
470
+ return vaConflict.flatMap(( a, n ) => {
471
+ const sWhere = "conflict " + ( n + 1 ) + ": "
472
+ if( !fbObject( a )) return [ sWhere + "not an object" ]
473
+ const vsProblem = fvsMemberProblem( a,
474
+ [ "id", "questionId", "choice", "note", "time", "submittedBy" ],
475
+ [ "id", "questionId", "choice", "note", "time", "submittedBy" ], sWhere )
476
+ if( typeof a.id !== "string" || !a.id ){
477
+ vsProblem.push( sWhere + "id is not a non-empty string" )
478
+ } else if( vsId.has( a.id )){
479
+ vsProblem.push( sWhere + "id already appears in conflicts" )
480
+ } else {
481
+ vsId.add( a.id )
482
+ }
483
+ const aQuestion = typeof a.questionId === "string" &&
484
+ /^[0-9a-f]{64}$/.test( a.questionId ) &&
485
+ Object.prototype.hasOwnProperty.call( aQuestionOfId, a.questionId )
486
+ ? aQuestionOfId[ a.questionId ] : null
487
+ if( !aQuestion ) vsProblem.push( sWhere + "questionId is not an active or historical question id" )
488
+ if( typeof a.choice !== "string" || !faQuestionOptions( aQuestion?.options ).some(( aOption ) =>
489
+ aOption.key === a.choice )){
490
+ vsProblem.push( sWhere + "choice is not an option of its question" )
491
+ }
492
+ if( typeof a.note !== "string" ) vsProblem.push( sWhere + "note is not a string" )
493
+ if( !fbIsoTime( a.time )) vsProblem.push( sWhere + "time is not an ISO 8601 time" )
494
+ if( typeof a.submittedBy !== "string" ){
495
+ vsProblem.push( sWhere + "submittedBy is not a string" )
496
+ }
497
+ return vsProblem
498
+ })
499
+ }
500
+
501
+ function faAnswerOrEmpty( sTextAnswer ){
502
+ try {
503
+ return JSON.parse( sTextAnswer )
504
+ } catch( xError ){
505
+ return {}
506
+ }
507
+ }
508
+
509
+ function fvsParsed( sFile, sText, fvsProblem ){
510
+ let x
511
+ try {
512
+ x = JSON.parse( sText )
513
+ } catch( xError ){
514
+ return [ sFile + ": not JSON (" + xError.message + ")" ]
515
+ }
516
+ return fvsProblem( x ).map(( s ) => sFile + ": " + s )
517
+ }
518
+
519
+ function fvsProblem(
520
+ sTextPage, sTextQuestion, sTextAnswer = null, sTextPending = null,
521
+ sTextStatus = null, sTextHistory = null, sTextConflict = null,
522
+ sTextThoughts = null, sTextClaims = null
523
+ ){
524
+ const aQuestionOfId = faQuestionOfId( sTextQuestion, sTextHistory )
525
+ const vsActiveQuestionId = faActiveQuestionIds( sTextQuestion )
526
+ return [
527
+ ...fvsParsed( sFilePage, sTextPage, fvsPageProblem ),
528
+ ...( sTextQuestion === null
529
+ ? [ sFileQuestion + ": file does not exist" ]
530
+ : fvsParsed( sFileQuestion, sTextQuestion, fvsQuestionsProblem )),
531
+ ...( sTextAnswer === null ? [] : fvsParsed(
532
+ sFileAnswer, sTextAnswer,
533
+ ( a ) => fvsAnswersProblem( a, aQuestionOfId )
534
+ )),
535
+ ...( sTextPending === null ? [] : fvsParsed(
536
+ sFilePending, sTextPending,
537
+ ( a ) => fvsPendingProblem(
538
+ a, aQuestionOfId,
539
+ sTextAnswer === null ? {} : faAnswerOrEmpty( sTextAnswer )
540
+ )
541
+ )),
542
+ ...( sTextStatus === null ? [] : fvsParsed(
543
+ sFileStatus, sTextStatus, fvsStatusProblem
544
+ )),
545
+ ...( sTextHistory === null ? [] : fvsParsed(
546
+ sFileHistory, sTextHistory,
547
+ ( a ) => fvsHistoryProblem( a, aQuestionOfId, vsActiveQuestionId )
548
+ )),
549
+ ...( sTextConflict === null ? [] : fvsParsed(
550
+ sFileConflict, sTextConflict,
551
+ ( a ) => fvsConflictsProblem( a, aQuestionOfId )
552
+ )),
553
+ ...( sTextThoughts === null ? [] : fvsParsed(
554
+ sFileThoughts, sTextThoughts, fvsThoughtsProblem
555
+ )),
556
+ ...( sTextClaims === null ? [] : fvsParsed(
557
+ sFileClaims, sTextClaims, ( aClaims ) => {
558
+ const faIds = ( sText, sName ) => {
559
+ try {
560
+ const aData = JSON.parse( sText || ( sName === "thought" ? "[]" : "{}" ))
561
+ return new Set( sName === "conflict"
562
+ ? Array.isArray( aData ) ? aData.map(( a ) => a && a.id ) : []
563
+ : sName === "thought"
564
+ ? Array.isArray( aData ) ? aData.map(( a ) => a && a.id ) : []
565
+ : Object.keys( fbObject( aData ) ? aData : {} ))
566
+ } catch( xError ){ return new Set() }
567
+ }
568
+ return fvsClaimsProblem( aClaims, {
569
+ answer: new Set([
570
+ ...( () => {
571
+ try {
572
+ const vaQuestion = JSON.parse( sTextQuestion || "[]" )
573
+ return Array.isArray( vaQuestion )
574
+ ? vaQuestion.map(( a ) => a && a.id ) : []
575
+ } catch( xError ){ return [] }
576
+ })(),
577
+ ...Object.keys( sTextAnswer === null ? {} : faAnswerOrEmpty( sTextAnswer )),
578
+ ]),
579
+ thought: faIds( sTextThoughts, "thought" ),
580
+ conflict: faIds( sTextConflict, "conflict" ),
581
+ })
582
+ }
583
+ )),
584
+ ]
585
+ }
586
+
587
+ module.exports = {
588
+ fvsProblem, fvsAddProblem, fbIsoTime, sFilePage, sFileQuestion, sFileAnswer,
589
+ sFilePending, fsQuestionId, fsLegacyQuestionId, fsBranchHash, faQuestionOptions, sXOptionText,
590
+ sFileStatus, sFileHistory, sFileConflict, fvsStatusProblem, faQuestionOfId,
591
+ sFileThoughts, fvsThoughtsProblem, fsThoughtId,
592
+ sFileClaims, fvsClaimsProblem,
593
+ }