@debonet/idd 0.0.0-stage → 0.1.0

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,50 @@
1
+ // @tested-by test/idd-page.test.js
2
+ // @implements s-tasgnp@85d6899
3
+ // @implements s-yl6bcx@6415b0e
4
+ // @implements s-irxy7e@fdc4d68
5
+ const asEntityPlain = {
6
+ lt: "<",
7
+ gt: ">",
8
+ quot: "\"",
9
+ amp: "&",
10
+ }
11
+
12
+ const sReference = /&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);/g
13
+
14
+ const asEntityBody = {
15
+ lt: "<",
16
+ gt: ">",
17
+ quot: "\"",
18
+ apos: "'",
19
+ nbsp: " ",
20
+ amp: "&",
21
+ }
22
+
23
+ function fsCharacterFrom( asEntity ){
24
+ return ( sMatch, sName ) => {
25
+ if( sName[ 0 ] == "#" ){
26
+ const nCode = sName[ 1 ].toLowerCase() == "x"
27
+ ? parseInt( sName.slice( 2 ), 16 )
28
+ : parseInt( sName.slice( 1 ), 10 )
29
+ return Number.isNaN( nCode ) ? sMatch : String.fromCodePoint( nCode )
30
+ }
31
+ return sName in asEntity ? asEntity[ sName ] : sMatch
32
+ }
33
+ }
34
+
35
+ function fsDecoded( sText ){
36
+ return sText.replace( sReference, fsCharacterFrom( asEntityPlain ))
37
+ }
38
+
39
+ function fsText( sHtml ){
40
+ return sHtml
41
+ .replace( /<code>([\s\S]*?)<\/code>/g, "`$1`" )
42
+ .replace( /<br\s*\/?>/gi, "\n\n" )
43
+ .replace( /<\/?p(\s[^>]*)?>/gi, "\n\n" )
44
+ .replace( /<\/?[a-zA-Z][^>]*>/g, "" )
45
+ .replace( sReference, fsCharacterFrom( asEntityBody ))
46
+ .replace( /\n{3,}/g, "\n\n" )
47
+ .trim()
48
+ }
49
+
50
+ module.exports = { fsText, fsDecoded }
@@ -0,0 +1,80 @@
1
+ // @tested-by test/idd-page.test.js
2
+ // @implements s-hnxl58@6be6d28
3
+ // @implements s-vhstia@aa7ed58
4
+ const vm = require( "vm" )
5
+ const { fsText, fsDecoded } = require( "./html-text" )
6
+ const { faQuestionOptions } = require( "./check" )
7
+
8
+ function fnArrayEnd( sSource, nStart ){
9
+ let nDepth = 0
10
+ let chQuote = null
11
+ for( let n = nStart; n < sSource.length; n++ ){
12
+ const ch = sSource[ n ]
13
+ if( chQuote ){
14
+ if( ch == "\\" ){
15
+ n++
16
+ } else if( ch == chQuote ){
17
+ chQuote = null
18
+ }
19
+ } else if( ch == "\"" || ch == "'" || ch == "`" ){
20
+ chQuote = ch
21
+ } else if( ch == "[" ){
22
+ nDepth++
23
+ } else if( ch == "]" && --nDepth == 0 ){
24
+ return n + 1
25
+ }
26
+ }
27
+ return -1
28
+ }
29
+
30
+ function fsArrayLiteral( sPage ){
31
+ const tMatch = /const\s+vaQuestion\s*=\s*(?=\[)/.exec( sPage )
32
+ if( !tMatch ){
33
+ throw new Error( "no vaQuestion array literal" )
34
+ }
35
+ const nStart = tMatch.index + tMatch[ 0 ].length
36
+ const nEnd = fnArrayEnd( sPage, nStart )
37
+ if( nEnd < 0 ){
38
+ throw new Error( "the vaQuestion array literal is not closed" )
39
+ }
40
+ return sPage.slice( nStart, nEnd )
41
+ }
42
+
43
+ function fvaEvaluated( sLiteral ){
44
+ let va
45
+ try {
46
+ va = new vm.Script( "(" + sLiteral + ")" ).runInNewContext(
47
+ Object.create( null ), { timeout: 1000 }
48
+ )
49
+ } catch( xError ){
50
+ throw new Error( "the vaQuestion array is unreadable: " + xError.message )
51
+ }
52
+ if( !Array.isArray( va )){
53
+ throw new Error( "vaQuestion is not an array" )
54
+ }
55
+ return va
56
+ }
57
+
58
+ function faOption( aOpt ){
59
+ const aNew = {
60
+ key: aOpt.sKey,
61
+ text: fsDecoded( String( aOpt.sText )),
62
+ }
63
+ const sTag = aOpt.sTag === undefined ? "" : fsDecoded( String( aOpt.sTag ))
64
+ return sTag ? { ...aNew, tag: sTag } : aNew
65
+ }
66
+
67
+ function faQuestion( aQ ){
68
+ return {
69
+ id: aQ.sId,
70
+ title: fsDecoded( String( aQ.sTitle )),
71
+ body: fsText( String( aQ.sBody )),
72
+ options: faQuestionOptions(( aQ.vaOpt || [] ).map( faOption )),
73
+ }
74
+ }
75
+
76
+ function fvaQuestionFromPage( sPage ){
77
+ return fvaEvaluated( fsArrayLiteral( sPage )).map( faQuestion )
78
+ }
79
+
80
+ module.exports = { fvaQuestionFromPage }
@@ -0,0 +1,365 @@
1
+ // @tested-by test/merge-hook.test.js
2
+ // @implements s-e4kj5b@8a40de4
3
+ // @implements s-t0r5nn@6e7ef46
4
+ // @implements s-sjb7rh@6c5ca7c
5
+ // @implements s-02z196@0c73769
6
+ // @implements s-sj7f9n@8b11936
7
+ // @implements s-wjofsg@d016fd7
8
+ // @implements s-fyoism@fc7783d
9
+ const fs = require( "fs" )
10
+ const path = require( "path" )
11
+ const { spawnSync } = require( "child_process" )
12
+
13
+ const sDirHooks = ".githooks"
14
+ const sLineGate = '"$HOME/@concord/bin/spec-map" --gate || exit 1'
15
+ const sLinePre = 'npm exec -- idd-merge-hook pre-commit || exit 1'
16
+ const sLineMerge = 'npm exec -- idd-merge-hook pre-merge-commit || exit 1'
17
+ const sLinePost = 'npm exec -- idd-merge-hook post-merge || exit 1'
18
+ const sUsage = "usage: idd-merge-hook install|check|reviewed|pre-commit|pre-merge-commit|post-merge\n"
19
+
20
+ class Git {
21
+ constructor( sRoot, sProgram ){
22
+ this.sRoot = sRoot
23
+ this.sProgram = sProgram
24
+ }
25
+
26
+ faRun( vsArgs, aOptions = {} ){
27
+ const aRun = spawnSync( this.sProgram, vsArgs, {
28
+ cwd: this.sRoot,
29
+ encoding: "utf8",
30
+ maxBuffer: 16 * 1024 * 1024,
31
+ ...aOptions,
32
+ })
33
+ if( aRun.error ) throw new Error( "cannot run git: " + aRun.error.message )
34
+ return { code: aRun.status, out: aRun.stdout || "", err: aRun.stderr || "" }
35
+ }
36
+
37
+ fs( vsArgs ){
38
+ const aRun = this.faRun( vsArgs )
39
+ if( aRun.code !== 0 ) throw new Error( aRun.err.trim() || "git " + vsArgs[ 0 ] + " failed" )
40
+ return aRun.out.trim()
41
+ }
42
+
43
+ fsTry( vsArgs ){
44
+ const aRun = this.faRun( vsArgs )
45
+ return aRun.code === 0 ? aRun.out.trim() : null
46
+ }
47
+
48
+ fRef( sRef ){
49
+ return this.fsTry([ "rev-parse", "--verify", "--quiet", sRef ])
50
+ }
51
+
52
+ fbAncestor( sOlder, sNewer ){
53
+ return this.faRun([ "merge-base", "--is-ancestor", sOlder, sNewer ]).code === 0
54
+ }
55
+ }
56
+
57
+ function fsAddLine( sText, sLine ){
58
+ if( sText.split( "\n" ).includes( sLine )) return sText.endsWith( "\n" ) ? sText : sText + "\n"
59
+ return ( sText && !sText.endsWith( "\n" ) ? sText + "\n" : sText ) + sLine + "\n"
60
+ }
61
+
62
+ function fWriteHook( sPath, sText ){
63
+ fs.mkdirSync( path.dirname( sPath ), { recursive: true })
64
+ fs.writeFileSync( sPath, sText, { mode: 0o755 })
65
+ fs.chmodSync( sPath, 0o755 )
66
+ }
67
+
68
+ function fInstall( git, sRoot, bConcord ){
69
+ const sHooksPath = git.fsTry([ "config", "--get", "core.hooksPath" ])
70
+ if( sHooksPath && sHooksPath !== sDirHooks ){
71
+ throw new Error( "core.hooksPath is " + sHooksPath + ", not " + sDirHooks )
72
+ }
73
+ if( !sHooksPath ){
74
+ const sCommon = git.fs([ "rev-parse", "--git-common-dir" ])
75
+ const sDefault = path.resolve( sRoot, sCommon, "hooks" )
76
+ for( const sName of [ "pre-commit", "pre-merge-commit", "post-merge" ] ){
77
+ if( fs.existsSync( path.join( sDefault, sName ))){
78
+ throw new Error( path.relative( sRoot, path.join( sDefault, sName )) +
79
+ " exists; refusing to disable that hook" )
80
+ }
81
+ }
82
+ }
83
+
84
+ const sDir = path.join( sRoot, sDirHooks )
85
+ const sPrePath = path.join( sDir, "pre-commit" )
86
+ let sPre = fs.existsSync( sPrePath ) ? fs.readFileSync( sPrePath, "utf8" ) : "#!/bin/sh\n"
87
+ if( bConcord ) sPre = fsAddLine( sPre, sLineGate )
88
+ sPre = fsAddLine( sPre, sLinePre )
89
+ fWriteHook( sPrePath, sPre )
90
+
91
+ for( const [ sName, sLine ] of [
92
+ [ "pre-merge-commit", sLineMerge ],
93
+ [ "post-merge", sLinePost ],
94
+ ]){
95
+ const sPath = path.join( sDir, sName )
96
+ const sText = fs.existsSync( sPath ) ? fs.readFileSync( sPath, "utf8" ) : "#!/bin/sh\n"
97
+ fWriteHook( sPath, fsAddLine( sText, sLine ))
98
+ }
99
+
100
+ if( sHooksPath !== sDirHooks ) git.fs([ "config", "core.hooksPath", sDirHooks ])
101
+ }
102
+
103
+ function fHookCheck( git, sRoot, bConcord ){
104
+ if( git.fsTry([ "config", "--get", "core.hooksPath" ]) !== sDirHooks ){
105
+ throw new Error( "core.hooksPath is not .githooks; run idd-merge-hook install" )
106
+ }
107
+ const aNeed = [
108
+ [ "pre-commit", sLinePre ],
109
+ [ "pre-merge-commit", sLineMerge ],
110
+ [ "post-merge", sLinePost ],
111
+ ]
112
+ if( bConcord ) aNeed.push([ "pre-commit", sLineGate ])
113
+ for( const [ sName, sLine ] of aNeed ){
114
+ const sPath = path.join( sRoot, sDirHooks, sName )
115
+ if( !fs.existsSync( sPath ) ) throw new Error( sPath + " is missing" )
116
+ if( !( fs.statSync( sPath ).mode & 0o111 )) throw new Error( sPath + " is not executable" )
117
+ const sText = fs.readFileSync( sPath, "utf8" )
118
+ if( !sText.split( "\n" ).includes( sLine )) throw new Error( sPath + " lacks its required IDD hook line" )
119
+ }
120
+ }
121
+
122
+ function fsReviewPath( git, sRoot ){
123
+ return path.resolve( sRoot, git.fs([ "rev-parse", "--git-path", "idd-merge-review.json" ]))
124
+ }
125
+
126
+ function fsCurrentBranch( git ){
127
+ return git.fsTry([ "symbolic-ref", "--quiet", "--short", "HEAD" ])
128
+ }
129
+
130
+ function faBranchesAt( git, sCommit, sExclude ){
131
+ const sRefs = git.fs([ "for-each-ref", "--format=%(refname:short)", "--points-at", sCommit,
132
+ "refs/heads", "refs/remotes" ])
133
+ const vsBranch = [ ...new Set( sRefs.split( "\n" ).filter( Boolean ).map(( sRef ) =>
134
+ sRef.startsWith( "origin/" ) ? sRef.slice( 7 ) : sRef
135
+ ).filter(( sBranch ) => !sBranch.startsWith( "idd-" ) && sBranch !== sExclude ))]
136
+ if( vsBranch.length ) return vsBranch
137
+ const sName = git.fsTry([ "name-rev", "--name-only", "--no-undefined", sCommit ])
138
+ if( !sName || sName === "undefined" ) return []
139
+ const sBranch = sName.replace( /~[0-9]+$/, "" ).replace( /\^[0-9]+$/, "" )
140
+ .replace( /^remotes\/origin\//, "" ).replace( /^origin\//, "" )
141
+ return sBranch && !sBranch.startsWith( "idd-" ) && sBranch !== sExclude
142
+ ? [ sBranch ] : []
143
+ }
144
+
145
+ function faIddRef( git, sCodeBranch ){
146
+ const sName = "idd-" + sCodeBranch
147
+ const sLocal = "refs/heads/" + sName
148
+ const sRemote = "refs/remotes/origin/" + sName
149
+ const sLocalSha = git.fRef( sLocal )
150
+ const sRemoteSha = git.fRef( sRemote )
151
+ if( !sLocalSha && !sRemoteSha ) return null
152
+ if( !sRemoteSha ){
153
+ return { problem: "the matching IDD branch " + sName + " is not published to origin" }
154
+ }
155
+ if( sLocalSha && !git.fbAncestor( sLocalSha, sRemoteSha )){
156
+ return { problem: "the local " + sName + " branch has commits that are not pushed to origin" }
157
+ }
158
+ return { name: sName, sha: sRemoteSha }
159
+ }
160
+
161
+ function faMergeContext( git, sSourceCommit, sTargetCommit = null ){
162
+ const sTargetBranch = fsCurrentBranch( git )
163
+ if( !sTargetBranch ) return { problem: "HEAD is detached; check out the target code branch first" }
164
+ const vsSource = faBranchesAt( git, sSourceCommit, sTargetBranch )
165
+ if( vsSource.length !== 1 ){
166
+ return { problem: "cannot identify the source code branch from " + sSourceCommit,
167
+ sourceCommit: sSourceCommit, targetBranch: sTargetBranch, sourceBranches: vsSource }
168
+ }
169
+ const sSourceBranch = vsSource[ 0 ]
170
+ const aSource = faIddRef( git, sSourceBranch )
171
+ const aTarget = faIddRef( git, sTargetBranch )
172
+ if( aSource && aSource.problem ) return { problem: aSource.problem,
173
+ sourceBranch: sSourceBranch, targetBranch: sTargetBranch }
174
+ if( aTarget && aTarget.problem ) return { problem: aTarget.problem,
175
+ sourceBranch: sSourceBranch, targetBranch: sTargetBranch }
176
+ if( !aSource && !aTarget ) return { none: true, sourceBranch: sSourceBranch, targetBranch: sTargetBranch }
177
+ if( !aSource || !aTarget ){
178
+ return { problem: "the matching IDD branch is missing; publish both `idd-" +
179
+ sSourceBranch + "` and `idd-" + sTargetBranch + "` before merging",
180
+ sourceBranch: sSourceBranch, targetBranch: sTargetBranch,
181
+ sourceIdd: aSource, targetIdd: aTarget }
182
+ }
183
+ return {
184
+ sourceBranch: sSourceBranch,
185
+ targetBranch: sTargetBranch,
186
+ sourceCommit: sSourceCommit,
187
+ targetCommit: sTargetCommit || git.fs([ "rev-parse", "HEAD" ]),
188
+ sourceIdd: aSource,
189
+ targetIdd: aTarget,
190
+ }
191
+ }
192
+
193
+ function faContextFromMerge( git, sPhase ){
194
+ let sSourceCommit = null
195
+ if( sPhase === "post-merge" ){
196
+ const vsParent = git.fs([ "rev-list", "--parents", "-n", "1", "HEAD" ]).split( " " )
197
+ sSourceCommit = vsParent.length > 2 ? vsParent[ 2 ] : null
198
+ if( sSourceCommit ) return faMergeContext( git, sSourceCommit, vsParent[ 1 ] )
199
+ } else {
200
+ sSourceCommit = git.fsTry([ "rev-parse", "--verify", "--quiet", "MERGE_HEAD" ])
201
+ }
202
+ if( !sSourceCommit ) return { none: true }
203
+ return faMergeContext( git, sSourceCommit )
204
+ }
205
+
206
+ function fsMergeSource( git, sPhase ){
207
+ if( sPhase === "post-merge" ){
208
+ const vsParent = git.fs([ "rev-list", "--parents", "-n", "1", "HEAD" ]).split( " " )
209
+ return vsParent.length > 2 ? vsParent[ 2 ] : null
210
+ }
211
+ return git.fsTry([ "rev-parse", "--verify", "--quiet", "MERGE_HEAD" ])
212
+ }
213
+
214
+ function faReview( git, sRoot ){
215
+ const aContext = faContextFromMerge( git, "reviewed" )
216
+ if( aContext.none ) return { already: true }
217
+ if( aContext.problem ) return { error: aContext.problem }
218
+ if( !git.fbAncestor( aContext.sourceIdd.sha, aContext.targetIdd.sha )){
219
+ return { error: "merge and reconcile the source IDD branch into the target IDD branch, then push it before recording the review" }
220
+ }
221
+ const sText = JSON.stringify({
222
+ sourceBranch: aContext.sourceBranch,
223
+ targetBranch: aContext.targetBranch,
224
+ sourceCommit: aContext.sourceCommit,
225
+ targetCommit: aContext.targetCommit,
226
+ sourceIdd: aContext.sourceIdd.sha,
227
+ targetIdd: aContext.targetIdd.sha,
228
+ }, null, 2 ) + "\n"
229
+ fs.writeFileSync( fsReviewPath( git, sRoot ), sText )
230
+ return { reviewed: true, context: aContext }
231
+ }
232
+
233
+ function fbReviewMatches( git, sRoot, aContext ){
234
+ try {
235
+ const aReview = JSON.parse( fs.readFileSync( fsReviewPath( git, sRoot ), "utf8" ))
236
+ return aReview.sourceBranch === aContext.sourceBranch &&
237
+ aReview.targetBranch === aContext.targetBranch &&
238
+ aReview.sourceCommit === aContext.sourceCommit &&
239
+ aReview.targetCommit === aContext.targetCommit &&
240
+ aReview.sourceIdd === aContext.sourceIdd.sha &&
241
+ aReview.targetIdd === aContext.targetIdd.sha
242
+ } catch( xError ){
243
+ return false
244
+ }
245
+ }
246
+
247
+ function fPrintMergeHold( fWriteErr, aContext, sDetail = "" ){
248
+ fWriteErr( "IDD branch reconciliation is required before this code merge.\n" )
249
+ fWriteErr( "Code source: " + ( aContext.sourceBranch || aContext.sourceCommit || "unknown" ) + "\n" )
250
+ fWriteErr( "Code target: " + ( aContext.targetBranch || "unknown" ) + "\n" )
251
+ if( aContext.sourceIdd ) fWriteErr( "IDD source: " + aContext.sourceIdd.name + " " + aContext.sourceIdd.sha + "\n" )
252
+ if( aContext.targetIdd ) fWriteErr( "IDD target: " + aContext.targetIdd.name + " " + aContext.targetIdd.sha + "\n" )
253
+ if( sDetail ) fWriteErr( sDetail + "\n" )
254
+ fWriteErr( "Compare both questionnaires and answers. Consolidate duplicates, obsolete questions and answer conflicts on the target IDD branch. Add and answer a clarifying question, and do its implementation, before resuming the code merge. If analysis finds no unresolved IDD work, run `npm exec -- idd-merge-hook reviewed` after the analysis.\n" )
255
+ return 1
256
+ }
257
+
258
+ function fCheckMerge( git, sRoot, sPhase, fWriteErr ){
259
+ if( !fsMergeSource( git, sPhase )) return 0
260
+ const aFetch = git.faRun([ "fetch", "origin" ])
261
+ if( aFetch.code !== 0 ){
262
+ fWriteErr( "IDD merge check could not fetch the latest questionnaire branches from origin.\n" +
263
+ aFetch.err )
264
+ return 1
265
+ }
266
+ const aContext = faContextFromMerge( git, sPhase )
267
+ if( aContext.none ) return 0
268
+ if( aContext.problem ) return fPrintMergeHold( fWriteErr, aContext )
269
+ if( !git.fbAncestor( aContext.sourceIdd.sha, aContext.targetIdd.sha )){
270
+ return fPrintMergeHold( fWriteErr, aContext,
271
+ "Merge and reconcile the source IDD branch into the target IDD branch, then push it." )
272
+ }
273
+ if( fbReviewMatches( git, sRoot, aContext )) return 0
274
+ return fPrintMergeHold( fWriteErr, aContext,
275
+ "The source IDD history is present, but an agent must review its meanings first." )
276
+ }
277
+
278
+ function fPostMerge( git, sRoot, fWriteErr ){
279
+ if( !fsMergeSource( git, "post-merge" )) return 0
280
+ const aFetch = git.faRun([ "fetch", "origin" ])
281
+ if( aFetch.code !== 0 ){
282
+ fWriteErr( "IDD post-merge could not fetch the latest questionnaire branches from origin.\n" +
283
+ aFetch.err )
284
+ return 1
285
+ }
286
+ const aContext = faContextFromMerge( git, "post-merge" )
287
+ if( aContext.problem ){
288
+ fWriteErr( "IDD post-merge could not identify the source and target branches: " + aContext.problem + "\n" )
289
+ return 1
290
+ }
291
+ if( aContext.none ) return 0
292
+ if( !git.fbAncestor( aContext.sourceIdd.sha, aContext.targetIdd.sha ) ||
293
+ !fbReviewMatches( git, sRoot, aContext )){
294
+ fWriteErr( "IDD post-merge found no matching reconciled and reviewed IDD state; reconcile " +
295
+ aContext.targetIdd.name + " manually.\n" )
296
+ return 1
297
+ }
298
+ try { fs.unlinkSync( fsReviewPath( git, sRoot )) } catch( xError ){}
299
+ return 0
300
+ }
301
+
302
+ function fvsUsage( fWriteErr ){
303
+ fWriteErr( sUsage )
304
+ return 2
305
+ }
306
+
307
+ function fzMain( vsArg, sCwd, fWrite, fWriteErr, aEnv = process.env ){
308
+ if( vsArg.length !== 1 || ![
309
+ "install", "check", "reviewed", "pre-commit", "pre-merge-commit", "post-merge",
310
+ ].includes( vsArg[ 0 ])) return fvsUsage( fWriteErr )
311
+ let sRoot
312
+ const git = new Git( sCwd, aEnv.IDD_GIT || "git" )
313
+ try {
314
+ sRoot = git.fs([ "rev-parse", "--show-toplevel" ])
315
+ git.sRoot = sRoot
316
+ } catch( xError ){
317
+ fWriteErr( "idd-merge-hook: " + xError.message + "\n" )
318
+ return 2
319
+ }
320
+ const bConcord = fs.existsSync( path.join( sRoot, "concord.json" ))
321
+ try {
322
+ switch( vsArg[ 0 ] ){
323
+ case "install":
324
+ fInstall( git, sRoot, bConcord )
325
+ fWrite( "IDD merge hooks installed in .githooks\n" )
326
+ return 0
327
+ case "check":
328
+ fHookCheck( git, sRoot, bConcord )
329
+ fWrite( "IDD merge hooks installed\n" )
330
+ return 0
331
+ case "reviewed": {
332
+ if( !fsMergeSource( git, "reviewed" )){
333
+ fWriteErr( "idd-merge-hook: reviewed can only be recorded while a code merge is in progress\n" )
334
+ return 1
335
+ }
336
+ const aFetch = git.faRun([ "fetch", "origin" ])
337
+ if( aFetch.code !== 0 ){
338
+ fWriteErr( "idd-merge-hook: could not fetch the latest questionnaire branches from origin\n" +
339
+ aFetch.err )
340
+ return 1
341
+ }
342
+ const aReview = faReview( git, sRoot )
343
+ if( aReview.error ){
344
+ fWriteErr( "idd-merge-hook: " + aReview.error + "\n" )
345
+ return 1
346
+ }
347
+ fWrite( aReview.already ? "IDD source data is already in the target branch\n"
348
+ : "IDD merge review recorded for the current merge\n" )
349
+ return 0
350
+ }
351
+ case "pre-commit":
352
+ case "pre-merge-commit":
353
+ return fCheckMerge( git, sRoot, vsArg[ 0 ], fWriteErr )
354
+ case "post-merge":
355
+ return fPostMerge( git, sRoot, fWriteErr )
356
+ default:
357
+ return fvsUsage( fWriteErr )
358
+ }
359
+ } catch( xError ){
360
+ fWriteErr( "idd-merge-hook: " + xError.message + "\n" )
361
+ return 2
362
+ }
363
+ }
364
+
365
+ module.exports = { fzMain }
@@ -0,0 +1,21 @@
1
+ // @tested-by test/idd-page.test.js
2
+ // @implements s-gcsqgg@1ad0f43
3
+ // @implements s-t2pr4c@023afd8
4
+ // @implements s-s1eg2z@e4283c0
5
+ const fs = require( "fs" )
6
+ const path = require( "path" )
7
+
8
+ const sPathTemplate = path.join(
9
+ __dirname, "..", "templates", "questions-page.html"
10
+ )
11
+
12
+ function fsHtml( aPage, vaQuestion ){
13
+ const sJson = JSON.stringify(
14
+ { title: aPage.title, questions: vaQuestion }
15
+ ).replace( /</g, "\\u003c" )
16
+ return fs.readFileSync( sPathTemplate, "utf8" ).split( "__PAGE__" ).join(
17
+ sJson
18
+ )
19
+ }
20
+
21
+ module.exports = { fsHtml }
@@ -0,0 +1,135 @@
1
+ // @tested-by test/idd-page.test.js
2
+ // @implements s-z8braj@8332190
3
+ // @implements s-mm6l60@288776b
4
+ // @implements s-qt9vvj@ddbf996
5
+ // @implements s-zb5ycj@d0a620d
6
+ const fs = require( "fs" )
7
+ const path = require( "path" )
8
+
9
+ const sDirLock = "tmp/idd"
10
+ const msWaitMax = 10000
11
+ const msPoll = 10
12
+ const msStealStale = 2000
13
+
14
+ function sleep( ms ){
15
+ Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 )), 0, 0, ms )
16
+ }
17
+
18
+ function sUnique( sName ){
19
+ return sName + "-" + process.pid + "-" + Math.random().toString( 36 ).slice( 2 )
20
+ }
21
+
22
+ function fbAlive( nPid ){
23
+ try {
24
+ process.kill( nPid, 0 )
25
+ return true
26
+ } catch( xError ){
27
+ return xError.code === "EPERM"
28
+ }
29
+ }
30
+
31
+ function fsOwner( sDir ){
32
+ try {
33
+ return fs.readFileSync( path.join( sDir, "owner" ), "utf8" )
34
+ } catch( xError ){
35
+ return null
36
+ }
37
+ }
38
+
39
+ function removeDir( sDir, sDirParent ){
40
+ const sGone = path.join( sDirParent, sUnique( "gone" ))
41
+ try {
42
+ fs.renameSync( sDir, sGone )
43
+ } catch( xError ){
44
+ return
45
+ }
46
+ fs.rmSync( sGone, { recursive: true, force: true })
47
+ }
48
+
49
+ function steal( sLock, sDirParent ){
50
+ const sMutex = path.join( sDirParent, "steal" )
51
+ try {
52
+ fs.mkdirSync( sMutex )
53
+ } catch( xError ){
54
+ try {
55
+ if( Date.now() - fs.statSync( sMutex ).mtimeMs > msStealStale ){
56
+ fs.rmSync( sMutex, { recursive: true, force: true })
57
+ }
58
+ } catch( xInner ){
59
+ void xInner
60
+ }
61
+ return
62
+ }
63
+ try {
64
+ const sOwner = fsOwner( sLock )
65
+ if( sOwner !== null && !fbAlive( Number( sOwner ))){
66
+ removeDir( sLock, sDirParent )
67
+ }
68
+ } finally {
69
+ fs.rmSync( sMutex, { recursive: true, force: true })
70
+ }
71
+ }
72
+
73
+ function fbHeld( xError ){
74
+ return xError.code === "EEXIST" || xError.code === "ENOTEMPTY" ||
75
+ xError.code === "ENOTDIR"
76
+ }
77
+
78
+ function acquire( sRoot ){
79
+ const sDirParent = path.join( sRoot, sDirLock )
80
+ const sLock = path.join( sDirParent, "lock" )
81
+ const sOwn = path.join( sDirParent, sUnique( "own" ))
82
+ fs.mkdirSync( sOwn, { recursive: true })
83
+ fs.writeFileSync( path.join( sOwn, "owner" ), String( process.pid ))
84
+ const dtmStart = Date.now()
85
+ try {
86
+ for( ;; ){
87
+ try {
88
+ fs.renameSync( sOwn, sLock )
89
+ return sLock
90
+ } catch( xError ){
91
+ if( !fbHeld( xError )){
92
+ throw xError
93
+ }
94
+ }
95
+ steal( sLock, sDirParent )
96
+ if( Date.now() - dtmStart > msWaitMax ){
97
+ removeDir( sOwn, sDirParent )
98
+ return null
99
+ }
100
+ sleep( msPoll )
101
+ }
102
+ } catch( xError ){
103
+ removeDir( sOwn, sDirParent )
104
+ throw xError
105
+ }
106
+ }
107
+
108
+ function fxWithLock( sRoot, fx, fxBusy ){
109
+ const sLock = acquire( sRoot )
110
+ if( sLock === null ){
111
+ return fxBusy()
112
+ }
113
+ try {
114
+ return fx()
115
+ } finally {
116
+ const sDirParent = path.dirname( sLock )
117
+ removeDir( sLock, sDirParent )
118
+ try {
119
+ fs.rmdirSync( sDirParent )
120
+ try {
121
+ fs.rmdirSync( path.dirname( sDirParent ))
122
+ } catch( xError ){
123
+ if( xError.code !== "ENOTEMPTY" && xError.code !== "ENOENT" ){
124
+ throw xError
125
+ }
126
+ }
127
+ } catch( xError ){
128
+ if( xError.code !== "ENOTEMPTY" && xError.code !== "ENOENT" ){
129
+ throw xError
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ module.exports = { fxWithLock }