@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
|
@@ -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 }
|