@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.
- package/INSTRUCTIONS.md +159 -0
- package/README.md +89 -2
- package/bin/idd +16 -0
- package/bin/idd-merge-hook +13 -0
- package/docs/existing-page-migration.md +92 -0
- package/docs/hosted-viewer.md +164 -0
- package/package.json +57 -4
- package/scripts/check.js +593 -0
- package/scripts/commands.js +908 -0
- package/scripts/html-text.js +50 -0
- package/scripts/import-page.js +80 -0
- package/scripts/merge-hook.js +365 -0
- package/scripts/page-build.js +21 -0
- package/scripts/project-lock.js +135 -0
- package/specs/adoption.md +133 -0
- package/specs/build.md +201 -0
- package/specs/guidance/agent-merge.md +63 -0
- package/specs/guidance/answers.md +108 -0
- package/specs/guidance/claude-code.md +76 -0
- package/specs/guidance/codex.md +93 -0
- package/specs/guidance/session.md +238 -0
- package/specs/guidance/writing-questions.md +171 -0
- package/specs/hosted-questionnaire.md +793 -0
- package/specs/idd-merge-hook.md +42 -0
- package/specs/idd-page.md +367 -0
- package/specs/project-files.md +198 -0
- package/specs/questions-page.md +169 -0
- package/specs/themes.md +114 -0
- package/templates/questions-page.html +337 -0
package/scripts/check.js
ADDED
|
@@ -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
|
+
}
|