@godv61/dsh-task-engine 0.26.0 → 0.27.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.
- package/.enforce-test.mjs +8 -12
- package/.evidence-test.mjs +5 -5
- package/.freeze-test.mjs +24 -29
- package/.revision-test.mjs +16 -16
- package/.roundtrip-test.mjs +259 -120
- package/.workflow-test.mjs +113 -94
- package/README.md +9 -9
- package/defaults/eng.json +97 -97
- package/docs/CHANGELOG.md +26 -13
- package/docs/configuration.md +20 -16
- package/docs/development.md +1 -1
- package/docs/faq.md +5 -5
- package/docs/manual.html +33 -33
- package/docs/roadmap.md +2 -2
- package/lib/client.js +303 -139
- package/lib/client.js.map +2 -2
- package/lib/controller.d.ts +15 -0
- package/lib/controller.js +120 -22
- package/lib/controller.js.map +1 -1
- package/lib/dev-task.js +105 -60
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +8 -10
- package/lib/engine.js +1 -1
- package/lib/engine.js.map +1 -1
- package/lib/user-skill-profiles.d.ts +7 -0
- package/lib/user-skill-profiles.js +69 -0
- package/lib/user-skill-profiles.js.map +1 -0
- package/lib/workflows.d.ts +21 -0
- package/lib/workflows.js +55 -0
- package/lib/workflows.js.map +1 -1
- package/package.json +1 -1
- package/preset/agent.cordis.yml +3 -3
- package/preset/persona.md +2 -2
- package/skills/code-implement/SKILL.md +1 -1
- package/skills/eng-delivery/SKILL.md +17 -38
package/.enforce-test.mjs
CHANGED
|
@@ -86,17 +86,13 @@ test('enforce: a task WITHOUT a snapshot cannot advance while a bound rule is un
|
|
|
86
86
|
)
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
-
test('enforce: a task WITH a snapshot
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
f.records.delete(f.rule())
|
|
97
|
-
await f.call({ operation: 'advance', target_stage: '交付' })
|
|
98
|
-
assert.equal(f.current().stage, '交付', 'the frozen task advances on its own copy')
|
|
99
|
-
})
|
|
89
|
+
test('enforce: a task WITH a snapshot blocks when its live source rule is deleted', async () => {
|
|
90
|
+
const f = await project()
|
|
91
|
+
await satistfyDevelopment(f)
|
|
92
|
+
f.records.delete(f.rule())
|
|
93
|
+
await assert.rejects(() => f.call({ operation: 'advance', target_stage: '交付' }), /resolves nowhere/u)
|
|
94
|
+
assert.equal(f.current().stage, '开发')
|
|
95
|
+
})
|
|
100
96
|
|
|
101
97
|
test('enforce: completion requires the review and verification the flow declared', async () => {
|
|
102
98
|
// Arriving at the last stage used to be enough. The agile flow declares a review
|
|
@@ -136,4 +132,4 @@ test('enforce: the resource blocker names the stage, the reference and the fix',
|
|
|
136
132
|
assert.match(blockers[0], /Restore the file|remove the binding/u,
|
|
137
133
|
'a blocker must say what to do, not only what is wrong')
|
|
138
134
|
assert.deepEqual(resourceBlockers([], '开发'), [], 'a resolvable stage has no blocker')
|
|
139
|
-
})
|
|
135
|
+
})
|
package/.evidence-test.mjs
CHANGED
|
@@ -56,8 +56,8 @@ test('evidence: a document-producing skill is satisfied by a recorded artifact',
|
|
|
56
56
|
// The blocker is the point: without an artifact it blocks, and with one it does
|
|
57
57
|
// not — so the declared kind is genuinely enforced rather than merely accepted.
|
|
58
58
|
//
|
|
59
|
-
// Mounted on 设计, which the adopted setup declares an artifact for. Missing
|
|
60
|
-
// declarations and incomplete fields must both remain blockers.
|
|
59
|
+
// Mounted on 设计, which the adopted setup declares an artifact for. Missing
|
|
60
|
+
// declarations and incomplete fields must both remain blockers.
|
|
61
61
|
const adopted = adoptRecommendation('standard')
|
|
62
62
|
const config = resolveFlow('standard', {
|
|
63
63
|
...adopted,
|
|
@@ -78,7 +78,7 @@ test('evidence: a document-producing skill is satisfied by a recorded artifact',
|
|
|
78
78
|
// Record what the stage declares and the blocker clears.
|
|
79
79
|
const declared = config.artifacts.find(artifact => artifact.stage === '设计')
|
|
80
80
|
assert.ok(declared !== undefined, 'the standard flow declares an artifact at 设计')
|
|
81
|
-
const cleared = skillBlockers(atDesign({ artifacts: { [declared.id]: Object.fromEntries(declared.fields.map(field => [field, 'checked'])) } }), config, session)
|
|
81
|
+
const cleared = skillBlockers(atDesign({ artifacts: { [declared.id]: Object.fromEntries(declared.fields.map(field => [field, 'checked'])) } }), config, session)
|
|
82
82
|
assert.deepEqual(cleared.filter(b => b.includes('doc-skill')), [],
|
|
83
83
|
'a recorded artifact satisfies artifact evidence')
|
|
84
84
|
})
|
|
@@ -98,7 +98,7 @@ test('evidence: manual evidence needs an explicit statement, none needs nothing'
|
|
|
98
98
|
const session = sessionWith(['code-implement', 'doc-skill'])
|
|
99
99
|
assert.ok(skillBlockers(state(), manual, session).some(b => b.includes('manual evidence')),
|
|
100
100
|
'manual evidence is absent until something is recorded')
|
|
101
|
-
const recorded = state({ skill_results: { '开发': { 'doc-skill': { evidence: ['reviewed by the lead'], approved: true } } } })
|
|
101
|
+
const recorded = state({ skill_results: { '开发': { 'doc-skill': { evidence: ['reviewed by the lead'], approved: true } } } })
|
|
102
102
|
assert.deepEqual(skillBlockers(recorded, manual, session).filter(b => b.includes('doc-skill')), [],
|
|
103
103
|
'a recorded statement satisfies manual evidence')
|
|
104
104
|
|
|
@@ -115,4 +115,4 @@ test('evidence: an undeclared extra skill still owes a command receipt', () => {
|
|
|
115
115
|
const blockers = skillBlockers(state(), config, session)
|
|
116
116
|
assert.ok(blockers.some(b => b.includes('validation command')),
|
|
117
117
|
`an undeclared skill must still owe a command receipt; got ${JSON.stringify(blockers)}`)
|
|
118
|
-
})
|
|
118
|
+
})
|
package/.freeze-test.mjs
CHANGED
|
@@ -150,34 +150,29 @@ async function atDevelopmentWithProjectRule() {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
test('
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
assert.deepEqual(status.missing_rules, [],
|
|
177
|
-
'a frozen rule is not missing; the task already holds its content')
|
|
178
|
-
assert.ok((status.stale_source_rules ?? []).some(r => r.includes('deleted')),
|
|
179
|
-
'the deletion is reported as drift')
|
|
180
|
-
})
|
|
153
|
+
test('live: a running task reads the latest rule body at the same reference', async () => {
|
|
154
|
+
const f = await atDevelopmentWithProjectRule()
|
|
155
|
+
const before = await f.disclosed()
|
|
156
|
+
assert.equal(before.mine?.content, 'ORIGINAL RULE')
|
|
157
|
+
|
|
158
|
+
f.records.set(f.rule(), 'CHANGED RULE')
|
|
159
|
+
const after = await f.disclosed()
|
|
160
|
+
assert.equal(after.mine?.content, 'CHANGED RULE',
|
|
161
|
+
'the next interaction must disclose the edited body')
|
|
162
|
+
assert.ok((after.status.stale_source_rules ?? []).some(r => r.includes('edited')),
|
|
163
|
+
'the difference from the creation-time audit copy must be visible')
|
|
164
|
+
assert.match(after.status.resource_update_notice, /current source body is in force/u)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('live: deleting a source rule makes the binding unresolved', async () => {
|
|
168
|
+
const f = await atDevelopmentWithProjectRule()
|
|
169
|
+
f.records.delete(f.rule())
|
|
170
|
+
const { status, mine } = await f.disclosed()
|
|
171
|
+
assert.equal(mine, undefined)
|
|
172
|
+
assert.ok(status.missing_rules.includes('project:mine'))
|
|
173
|
+
assert.ok((status.stale_source_rules ?? []).some(r => r.includes('deleted')),
|
|
174
|
+
'the deletion is also reported against the creation-time audit copy')
|
|
175
|
+
})
|
|
181
176
|
|
|
182
177
|
test('freeze: an unfrozen task falls back to live resolution and reports a missing file', async () => {
|
|
183
178
|
// Tasks created before freezing have no snapshot. They must keep working, and a
|
|
@@ -196,4 +191,4 @@ test('freeze: an unfrozen task falls back to live resolution and reports a missi
|
|
|
196
191
|
assert.equal(mine, undefined, 'with no snapshot and no file, the rule cannot resolve')
|
|
197
192
|
assert.ok((status.missing_rules ?? []).includes('project:mine'),
|
|
198
193
|
'it is reported as missing so the gate can block on it')
|
|
199
|
-
})
|
|
194
|
+
})
|
package/.revision-test.mjs
CHANGED
|
@@ -92,7 +92,7 @@ test('rework: fixing a defect keeps the agreed requirement and solution', () =>
|
|
|
92
92
|
assert.deepEqual(state.commits, [], 'the commit described a superseded tree')
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
test('rework: clearing evidence returns an item to unfinished, not to done-without-audit', () => {
|
|
95
|
+
test('rework: clearing evidence returns an item to unfinished, not to done-without-audit', () => {
|
|
96
96
|
// A cleared review means the item is no longer proven done. Leaving it `done`
|
|
97
97
|
// would let todos_done pass on work whose audit had just been discarded.
|
|
98
98
|
const state = loadedTask()
|
|
@@ -111,18 +111,18 @@ test('rework: history records what was invalidated, for audit', () => {
|
|
|
111
111
|
assert.equal(state.revisions[0].from, '代码审核')
|
|
112
112
|
assert.equal(state.revisions[0].to, '设计')
|
|
113
113
|
assert.deepEqual(state.revisions[0].invalidated, invalidatedBy('solution'))
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
test('rework: skill results from revised stages are invalidated', () => {
|
|
117
|
-
const state = loadedTask()
|
|
118
|
-
state.flow = { flow: 'standard', version: 3, config: adoptedFlow('standard') }
|
|
119
|
-
state.skill_results = {
|
|
120
|
-
'需求评审': { analysis: { evidence: ['still agreed'] } },
|
|
121
|
-
'开发': { implementation: { evidence: ['old implementation'] } },
|
|
122
|
-
'代码审核': { audit: { evidence: ['old audit'] } },
|
|
123
|
-
}
|
|
124
|
-
applyRevision(state, rework('defect', '开发'))
|
|
125
|
-
assert.ok(state.skill_results['需求评审'])
|
|
126
|
-
assert.equal(state.skill_results['开发'], undefined)
|
|
127
|
-
assert.equal(state.skill_results['代码审核'], undefined)
|
|
128
|
-
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('rework: skill results from revised stages are invalidated', () => {
|
|
117
|
+
const state = loadedTask()
|
|
118
|
+
state.flow = { flow: 'standard', version: 3, config: adoptedFlow('standard') }
|
|
119
|
+
state.skill_results = {
|
|
120
|
+
'需求评审': { analysis: { evidence: ['still agreed'] } },
|
|
121
|
+
'开发': { implementation: { evidence: ['old implementation'] } },
|
|
122
|
+
'代码审核': { audit: { evidence: ['old audit'] } },
|
|
123
|
+
}
|
|
124
|
+
applyRevision(state, rework('defect', '开发'))
|
|
125
|
+
assert.ok(state.skill_results['需求评审'])
|
|
126
|
+
assert.equal(state.skill_results['开发'], undefined)
|
|
127
|
+
assert.equal(state.skill_results['代码审核'], undefined)
|
|
128
|
+
})
|
package/.roundtrip-test.mjs
CHANGED
|
@@ -5,127 +5,266 @@
|
|
|
5
5
|
* commit rule, artifact declarations or a review depth were resolved for the preview
|
|
6
6
|
* and then dropped on save. A user could set them, see them applied, and lose them.
|
|
7
7
|
*/
|
|
8
|
-
import test from 'node:test'
|
|
9
|
-
import assert from 'node:assert/strict'
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
13
|
+
import { resolveFlow, adoptRecommendation, compactProjectConfig, materializeBundledReferences } from './lib/workflows.js'
|
|
14
|
+
import Controller from './lib/controller.js'
|
|
15
|
+
import { assertAdvance, validateWorkflow } from './lib/engine.js'
|
|
16
|
+
import { TYPERT_REMOTE } from './src/client/remote.ts'
|
|
13
17
|
|
|
14
18
|
/** The fields a project config can carry, so a round-trip is checked on all of them. */
|
|
15
|
-
const FIELDS = ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth', 'commit_required']
|
|
16
|
-
|
|
17
|
-
test('
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
assert.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
'
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
19
|
+
const FIELDS = ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth', 'commit_required']
|
|
20
|
+
|
|
21
|
+
test('browser remote codec retains source-qualified bindings and all saved fields', () => {
|
|
22
|
+
const write = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'write')
|
|
23
|
+
const read = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'read')
|
|
24
|
+
const adopted = compactProjectConfig(adoptRecommendation('minimal'))
|
|
25
|
+
const request = { path: 'project', ...adopted, review_depth: 'single', commit_required: false,
|
|
26
|
+
materialize_bundled: 'project' }
|
|
27
|
+
assert.deepEqual(write.parameters[0].codec.schema.parse(request), request)
|
|
28
|
+
const resolved = resolveFlow('minimal', request).config
|
|
29
|
+
const view = { ok: true, source: 'project', flow: 'minimal', config: resolved, problems: [] }
|
|
30
|
+
assert.deepEqual(read.result.schema.parse(view), view)
|
|
31
|
+
const catalog = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'listSkills')
|
|
32
|
+
const skill = { name: 'mine', description: 'test', source: 'project',
|
|
33
|
+
ref: { source: 'project', name: 'mine' }, sourceLabel: '项目' }
|
|
34
|
+
assert.deepEqual(catalog.result.schema.parse({ skills: [skill] }), { skills: [skill] })
|
|
35
|
+
const saveUserProfile = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'writeUserSkillProfile')
|
|
36
|
+
const profileRequest = { name: 'mine', profile: { rules: [{ source: 'user', name: 'my-rule' }], evidence: 'manual' } }
|
|
37
|
+
assert.deepEqual(saveUserProfile.parameters[0].codec.schema.parse(profileRequest), profileRequest)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('one user skill profile is shared across workspaces and cannot depend on a project rule', async () => {
|
|
41
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-user-profile-'))
|
|
42
|
+
assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep))
|
|
43
|
+
const priorHome = process.env.DSH_HOME
|
|
44
|
+
const userHome = join(root, 'user-home')
|
|
45
|
+
process.env.DSH_HOME = userHome
|
|
46
|
+
const skill = { source: 'user', name: 'my-skill' }
|
|
47
|
+
const config = { flow: 'minimal', stage_bindings: {
|
|
48
|
+
'开发': { skill_refs: [skill] }, '交付': { skill_refs: [skill] },
|
|
49
|
+
} }
|
|
50
|
+
const fs = {
|
|
51
|
+
resolve: async (path, { cwd }) => join(cwd, path),
|
|
52
|
+
readText: async path => { try { return readFileSync(path, 'utf8') } catch { return undefined } },
|
|
53
|
+
writeText: async (path, body) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, body) },
|
|
54
|
+
}
|
|
55
|
+
const receiver = { authorizedPath: async path => path, fs: () => fs }
|
|
56
|
+
try {
|
|
57
|
+
const skillDir = join(userHome, 'skills', skill.name)
|
|
58
|
+
mkdirSync(skillDir, { recursive: true })
|
|
59
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: my-skill\ndescription: test\n---\nbody\n')
|
|
60
|
+
const profile = { rules: [{ source: 'user', name: 'my-rule' }], evidence: 'manual' }
|
|
61
|
+
const saved = await Controller.prototype.writeUserSkillProfile.call(receiver, { name: skill.name, profile })
|
|
62
|
+
assert.equal(saved.ok, true)
|
|
63
|
+
for (const projectName of ['one', 'two']) {
|
|
64
|
+
const project = join(root, projectName)
|
|
65
|
+
mkdirSync(join(project, '.dsh'), { recursive: true })
|
|
66
|
+
writeFileSync(join(project, '.dsh', 'eng.json'), JSON.stringify(config))
|
|
67
|
+
const loaded = await Controller.prototype.read.call(receiver, project)
|
|
68
|
+
assert.equal(loaded.ok, true)
|
|
69
|
+
assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, profile.rules)
|
|
70
|
+
}
|
|
71
|
+
const updatedProfile = { rules: [{ source: 'user', name: 'other-rule' }], evidence: 'none' }
|
|
72
|
+
const updated = await Controller.prototype.writeUserSkillProfile.call(receiver,
|
|
73
|
+
{ name: skill.name, profile: updatedProfile })
|
|
74
|
+
assert.equal(updated.ok, true)
|
|
75
|
+
for (const projectName of ['one', 'two']) {
|
|
76
|
+
const loaded = await Controller.prototype.read.call(receiver, join(root, projectName))
|
|
77
|
+
assert.equal(loaded.ok, true)
|
|
78
|
+
assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, updatedProfile.rules)
|
|
79
|
+
}
|
|
80
|
+
const bad = await Controller.prototype.writeUserSkillProfile.call(receiver, { name: skill.name,
|
|
81
|
+
profile: { rules: [{ source: 'project', name: 'local-rule' }] } })
|
|
82
|
+
assert.equal(bad.ok, false)
|
|
83
|
+
assert.deepEqual(JSON.parse(readFileSync(join(skillDir, 'profile.json'), 'utf8')), updatedProfile)
|
|
84
|
+
} finally {
|
|
85
|
+
if (priorHome === undefined) delete process.env.DSH_HOME
|
|
86
|
+
else process.env.DSH_HOME = priorHome
|
|
87
|
+
rmSync(root, { recursive: true, force: true })
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('recommendation adoption copies each shared bundled rule once and rewrites every reference', () => {
|
|
92
|
+
const { config, copies } = materializeBundledReferences(adoptRecommendation('standard'), 'project')
|
|
93
|
+
assert.equal(copies.filter(copy => copy.kind === 'skill').length, 6)
|
|
94
|
+
assert.equal(copies.filter(copy => copy.kind === 'rule').length, 3)
|
|
95
|
+
const refs = Object.values(config.stage_bindings).flatMap(binding => binding.skill_refs)
|
|
96
|
+
assert.ok(refs.every(ref => ref.source === 'project'))
|
|
97
|
+
const rules = Object.values(config.skill_profiles).flatMap(profile => profile.rules)
|
|
98
|
+
assert.ok(rules.every(ref => ref.source === 'project'))
|
|
99
|
+
const shared = rules.filter(ref => ref.name === 'security-redlines-copy')
|
|
100
|
+
assert.equal(shared.length, 3)
|
|
101
|
+
assert.equal(copies.filter(copy => copy.kind === 'rule' && copy.name === 'security-redlines').length, 1)
|
|
102
|
+
assert.deepEqual(validateWorkflow(resolveFlow('standard', config).config), [])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('project migration leaves a global user skill profile and its bundled rules untouched', () => {
|
|
106
|
+
const userSkill = { source: 'user', name: 'global-check' }
|
|
107
|
+
const bundledRule = { source: 'bundled', name: 'security-redlines' }
|
|
108
|
+
const project = { flow: 'minimal', stage_bindings: { '开发': { skill_refs: [userSkill] } },
|
|
109
|
+
skill_profiles: { 'user:global-check': { rules: [bundledRule] } } }
|
|
110
|
+
const { config, copies } = materializeBundledReferences(project, 'project')
|
|
111
|
+
assert.deepEqual(config.skill_profiles['user:global-check'].rules, [bundledRule])
|
|
112
|
+
assert.deepEqual(copies, [])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('adoption saves editable project copies, preserves edited files on repeat, and leaves legacy bundled configs alone', async () => {
|
|
116
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-adopt-'))
|
|
117
|
+
assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep))
|
|
118
|
+
const fs = {
|
|
119
|
+
resolve: async (path, { cwd }) => join(cwd, path),
|
|
120
|
+
readText: async path => { try { return readFileSync(path, 'utf8') } catch { return undefined } },
|
|
121
|
+
writeText: async (path, text) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, text) },
|
|
122
|
+
}
|
|
123
|
+
const receiver = { authorizedPath: async path => path, fs: () => fs }
|
|
124
|
+
try {
|
|
125
|
+
const recommended = adoptRecommendation('standard')
|
|
126
|
+
const first = await Controller.prototype.write.call(receiver,
|
|
127
|
+
{ path: root, ...recommended, materialize_bundled: 'project' })
|
|
128
|
+
assert.equal(first.ok, true)
|
|
129
|
+
assert.equal(first.adoption.created.length, 9)
|
|
130
|
+
const file = join(root, '.dsh', 'eng.json')
|
|
131
|
+
const saved = JSON.parse(readFileSync(file, 'utf8'))
|
|
132
|
+
assert.equal(JSON.stringify(saved).includes('"bundled"'), false)
|
|
133
|
+
const shared = join(root, '.dsh', 'rules', 'security-redlines-copy.md')
|
|
134
|
+
assert.ok(readFileSync(shared, 'utf8').length > 0)
|
|
135
|
+
writeFileSync(shared, 'user edited shared rule\n')
|
|
136
|
+
const second = await Controller.prototype.write.call(receiver,
|
|
137
|
+
{ path: root, ...recommended, materialize_bundled: 'project' })
|
|
138
|
+
assert.equal(second.ok, true)
|
|
139
|
+
assert.equal(second.adoption.created.length, 0)
|
|
140
|
+
assert.equal(second.adoption.reused.length, 9)
|
|
141
|
+
assert.equal(readFileSync(shared, 'utf8'), 'user edited shared rule\n')
|
|
142
|
+
const loaded = await Controller.prototype.read.call(receiver, root)
|
|
143
|
+
assert.equal(loaded.ok, true)
|
|
144
|
+
const sharedRefs = Object.values(loaded.config.skill_profiles).flatMap(profile => profile.rules)
|
|
145
|
+
.filter(rule => rule.name === 'security-redlines-copy')
|
|
146
|
+
assert.equal(sharedRefs.length, 3)
|
|
147
|
+
writeFileSync(file, JSON.stringify(compactProjectConfig(recommended)))
|
|
148
|
+
const legacy = await Controller.prototype.read.call(receiver, root)
|
|
149
|
+
assert.equal(legacy.ok, true)
|
|
150
|
+
assert.ok(JSON.stringify(legacy.config).includes('"bundled"'))
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(root, { recursive: true, force: true })
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('round-trip: stages store references while skill rules have one owner', () => {
|
|
157
|
+
const adopted = adoptRecommendation('standard')
|
|
158
|
+
const compact = compactProjectConfig(adopted)
|
|
159
|
+
assert.ok(compact.stage_bindings['开发'].skill_refs.length > 0)
|
|
160
|
+
assert.equal(compact.stage_bindings['开发'].skills, undefined)
|
|
161
|
+
assert.ok(compact.skill_profiles['bundled:code-implement'].rules.length > 0)
|
|
162
|
+
const readBack = resolveFlow('standard', JSON.parse(JSON.stringify(compact)))
|
|
163
|
+
assert.deepEqual(readBack.config.stage_bindings, resolveFlow('standard', adopted).config.stage_bindings)
|
|
164
|
+
const custom = { flow: 'standard', stage_bindings: {
|
|
165
|
+
'开发': { skills: [{ skill: { source: 'project', name: 'same' }, rules: [{ source: 'project', name: 'r1' }] }] },
|
|
166
|
+
'交付': { skills: [{ skill: { source: 'project', name: 'same' }, rules: [{ source: 'project', name: 'r2' }] }] },
|
|
167
|
+
} }
|
|
168
|
+
const invalid = resolveFlow('standard', custom)
|
|
169
|
+
assert.ok(invalid.config.configuration_errors.some(problem => problem.includes('different rules')))
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('remote write stores canonical skill profiles and read expands them', async () => {
|
|
173
|
+
let stored
|
|
174
|
+
const fs = {
|
|
175
|
+
resolve: async path => path,
|
|
176
|
+
readText: async () => stored,
|
|
177
|
+
writeText: async (_target, text) => { stored = text },
|
|
178
|
+
}
|
|
179
|
+
const receiver = { authorizedPath: async path => path, fs: () => fs }
|
|
180
|
+
const adopted = adoptRecommendation('standard')
|
|
181
|
+
const written = await Controller.prototype.write.call(receiver, { path: 'project', ...adopted })
|
|
182
|
+
assert.equal(written.ok, true)
|
|
183
|
+
const onDisk = JSON.parse(stored)
|
|
184
|
+
assert.ok(onDisk.stage_bindings['开发'].skill_refs.length > 0)
|
|
185
|
+
assert.equal(onDisk.stage_bindings['开发'].skills, undefined)
|
|
186
|
+
assert.ok(onDisk.skill_profiles['bundled:code-implement'].rules.length > 0)
|
|
187
|
+
const loaded = await Controller.prototype.read.call(receiver, 'project')
|
|
188
|
+
assert.equal(loaded.ok, true)
|
|
189
|
+
assert.deepEqual(loaded.config.stage_bindings, written.config.stage_bindings)
|
|
190
|
+
assert.deepEqual(loaded.config.skill_profiles, written.config.skill_profiles)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('an explicit skill profile governs every stage even when inline bindings are stale', () => {
|
|
194
|
+
const skill = { source: 'project', name: 'my-skill' }
|
|
195
|
+
const rule = { source: 'project', name: 'my-rule' }
|
|
196
|
+
const project = { flow: 'minimal',
|
|
197
|
+
stage_bindings: {
|
|
198
|
+
'开发': { skills: [{ skill, rules: [] }] },
|
|
199
|
+
'交付': { skills: [{ skill, rules: [] }] },
|
|
200
|
+
},
|
|
201
|
+
skill_profiles: { 'project:my-skill': { rules: [rule] } },
|
|
202
|
+
}
|
|
203
|
+
const resolved = resolveFlow('minimal', project)
|
|
204
|
+
assert.equal(resolved.ok, true)
|
|
205
|
+
assert.deepEqual(validateWorkflow(resolved.config), [])
|
|
206
|
+
assert.deepEqual(resolved.config.stage_bindings['开发'].skills[0].rules, [rule])
|
|
207
|
+
assert.deepEqual(resolved.config.stage_bindings['交付'].skills[0].rules, [rule])
|
|
208
|
+
const compact = compactProjectConfig(project)
|
|
209
|
+
assert.deepEqual(compact.stage_bindings['开发'].skill_refs, [skill])
|
|
210
|
+
assert.deepEqual(resolveFlow('minimal', compact).config.stage_bindings['交付'].skills[0].rules, [rule])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('canonical references to one skill share its rule list without a false conflict', () => {
|
|
214
|
+
const skill = { source: 'project', name: 'my-skill' }
|
|
215
|
+
const rule = { source: 'project', name: 'my-rule' }
|
|
216
|
+
const resolved = resolveFlow('minimal', { flow: 'minimal', stage_bindings: {
|
|
217
|
+
'开发': { skill_refs: [skill] },
|
|
218
|
+
'交付': { skill_refs: [skill] },
|
|
219
|
+
}, skill_profiles: { 'project:my-skill': { rules: [rule] } } })
|
|
220
|
+
assert.deepEqual(validateWorkflow(resolved.config), [])
|
|
221
|
+
assert.deepEqual(resolved.config.stage_bindings['开发'].skills[0].rules, [rule])
|
|
222
|
+
assert.deepEqual(resolved.config.stage_bindings['交付'].skills[0].rules, [rule])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('skill editor save persists one changed profile and read expands it into every stage', async () => {
|
|
226
|
+
let stored
|
|
227
|
+
const fs = { resolve: async path => path, readText: async () => stored,
|
|
228
|
+
writeText: async (_target, content) => { stored = content } }
|
|
229
|
+
const receiver = { authorizedPath: async path => path, fs: () => fs }
|
|
230
|
+
const skill = { source: 'project', name: 'my-skill' }
|
|
231
|
+
const rule = { source: 'project', name: 'my-rule' }
|
|
232
|
+
const initial = compactProjectConfig({ flow: 'minimal', stage_bindings: {
|
|
233
|
+
'开发': { skills: [{ skill, rules: [] }] },
|
|
234
|
+
'交付': { skills: [{ skill, rules: [] }] },
|
|
235
|
+
} })
|
|
236
|
+
const edited = { ...initial, skill_profiles: { ...initial.skill_profiles,
|
|
237
|
+
'project:my-skill': { rules: [rule], evidence: 'none' } } }
|
|
238
|
+
const written = await Controller.prototype.write.call(receiver, { path: 'project', ...edited })
|
|
239
|
+
assert.equal(written.ok, true)
|
|
240
|
+
assert.deepEqual(JSON.parse(stored).stage_bindings['开发'].skill_refs, [skill])
|
|
241
|
+
const loaded = await Controller.prototype.read.call(receiver, 'project')
|
|
242
|
+
assert.equal(loaded.ok, true)
|
|
243
|
+
assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, [rule])
|
|
244
|
+
assert.deepEqual(loaded.config.stage_bindings['交付'].skills[0].rules, [rule])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('adopting a recommendation retains the user-owned skill profiles', () => {
|
|
248
|
+
const custom = { 'project:my-skill': { rules: [{ source: 'project', name: 'my-rule' }], evidence: 'manual' } }
|
|
249
|
+
const adopted = adoptRecommendation('standard', { flow: 'standard', skill_profiles: custom })
|
|
250
|
+
assert.deepEqual(adopted.skill_profiles, custom)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('malformed canonical skill profiles fail validation without crashing resolution', () => {
|
|
254
|
+
const malformed = resolveFlow('standard', { flow: 'standard',
|
|
255
|
+
stage_bindings: { '开发': { skill_refs: [{ source: 'project', name: 'my-skill' }] } },
|
|
256
|
+
skill_profiles: { 'project:my-skill': { rules: 'not-an-array' } },
|
|
257
|
+
})
|
|
258
|
+
assert.ok(validateWorkflow(malformed.config).some(problem => problem.includes('rules array')))
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('commit_required false also removes the checkpoint exit requirement', () => {
|
|
262
|
+
const adopted = adoptRecommendation('standard')
|
|
263
|
+
const config = resolveFlow('standard', { ...adopted, commit_required: false }).config
|
|
264
|
+
const state = { stage: '代码审核', execution_version: 1, review: { outcome: 'pass' },
|
|
265
|
+
artifacts: { review: { conclusion: 'pass', issues: 'none' } }, commits: [] }
|
|
266
|
+
assert.equal(assertAdvance(state, '完成', config).ok, true)
|
|
267
|
+
})
|
|
129
268
|
|
|
130
269
|
test('round-trip: every configurable field survives being written and read back', () => {
|
|
131
270
|
// The save path serialises the payload and the read path parses it back through
|
|
@@ -175,4 +314,4 @@ test('round-trip: the payload the panel sends contains every edited field', () =
|
|
|
175
314
|
// And the whole payload must be accepted by the resolver the write path uses.
|
|
176
315
|
const resolved = resolveFlow(payload.flow, payload)
|
|
177
316
|
assert.equal(resolved.ok, true, 'the payload the panel sends must resolve without problems')
|
|
178
|
-
})
|
|
317
|
+
})
|