@godv61/dsh-task-engine 0.24.0 → 0.25.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/.acceptance.mjs +150 -88
- package/.assessment-batch1.mjs +32 -12
- package/.e2e-presets.mjs +40 -5
- package/.enforce-test.mjs +139 -0
- package/.evidence-test.mjs +119 -0
- package/.filter-test.mjs +93 -0
- package/.freeze-test.mjs +110 -1
- package/.hook-test.mjs +8 -3
- package/.p0-test.mjs +141 -57
- package/.revision-test.mjs +17 -2
- package/.roundtrip-test.mjs +63 -0
- package/.workflow-test.mjs +31 -4
- package/README.md +4 -2
- package/docs/CHANGELOG.md +37 -0
- package/docs/configuration.md +3 -3
- package/hooks/commit-msg +93 -75
- package/lib/client.js +218 -81
- package/lib/client.js.map +1 -1
- package/lib/controller.d.ts +10 -2
- package/lib/controller.js +27 -2
- package/lib/controller.js.map +1 -1
- package/lib/dev-task.js +94 -20
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +90 -0
- package/lib/engine.js +64 -0
- package/lib/engine.js.map +1 -1
- package/lib/hook.js +14 -4
- package/lib/hook.js.map +1 -1
- package/lib/skill-audit.d.ts +14 -3
- package/lib/skill-audit.js +66 -3
- package/lib/skill-audit.js.map +1 -1
- package/lib/workflows.d.ts +75 -12
- package/lib/workflows.js +172 -96
- package/lib/workflows.js.map +1 -1
- package/package.json +9 -5
- package/scripts/verify-package.mjs +1 -1
package/.acceptance.mjs
CHANGED
|
@@ -1,89 +1,151 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Independent acceptance check
|
|
3
|
-
*
|
|
4
|
-
* Reads the SHIPPED config and the built engine, not the test files, so a pass
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
check(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
check(`${id}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Independent acceptance check for the skeleton/recommendation design.
|
|
3
|
+
*
|
|
4
|
+
* Reads the SHIPPED config and the built engine, not the test files, so a pass
|
|
5
|
+
* cannot come from an assertion written to match the implementation.
|
|
6
|
+
*
|
|
7
|
+
* The criteria are the product principles rather than implementation details:
|
|
8
|
+
* 1. a flow is state control only — adopting a setup is a separate act
|
|
9
|
+
* 2. adoption yields a config the user fully owns, editable and removable
|
|
10
|
+
* 3. a node references skills; a rule belongs to the skill that carries it
|
|
11
|
+
* 4. nothing is merged back after adoption, and an upgrade never overwrites
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { resolveFlow, FLOW_PRESETS, adoptRecommendation } from './lib/workflows.js'
|
|
15
|
+
import { validateWorkflow, formatResourceRef, completionBlockers, terminalRequirements, invalidatedBy } from './lib/engine.js'
|
|
16
|
+
|
|
17
|
+
let failures = 0
|
|
18
|
+
const check = (label, ok, detail = '') => {
|
|
19
|
+
console.log(' ' + (ok ? '✅' : '❌') + ' ' + label + (detail ? ' — ' + detail : ''))
|
|
20
|
+
if (!ok) failures++
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log('=== 1. 流程只做状态控制 ===')
|
|
24
|
+
for (const [id, preset] of Object.entries(FLOW_PRESETS)) {
|
|
25
|
+
const skeleton = resolveFlow(id, { flow: id }).config
|
|
26
|
+
check(`${id} 骨架不带技能`, Object.keys(skeleton.stage_bindings ?? {}).length === 0)
|
|
27
|
+
check(`${id} 骨架不带产物`, skeleton.artifacts.length === 0)
|
|
28
|
+
check(`${id} 骨架保留状态机`, preset.config.transitions.length > 0 && preset.config.stages.length > 0)
|
|
29
|
+
check(`${id} 提交文本约定不在骨架里`, skeleton.commit.message_pattern === '', JSON.stringify(skeleton.commit.message_pattern))
|
|
30
|
+
}
|
|
31
|
+
// When a commit gate fires and whether the file scope is protected are flow
|
|
32
|
+
// control, so they must stay on the skeleton: they are a safety floor, not a
|
|
33
|
+
// writing convention, and must not disappear because a user declined a setup.
|
|
34
|
+
check('standard 骨架保留提交检查点', FLOW_PRESETS.standard.config.commit.checkpoints.length > 0,
|
|
35
|
+
JSON.stringify(FLOW_PRESETS.standard.config.commit.checkpoints))
|
|
36
|
+
check('standard 骨架保留文件范围保护', FLOW_PRESETS.standard.config.commit.file_scope === true)
|
|
37
|
+
|
|
38
|
+
console.log('')
|
|
39
|
+
console.log('=== 2. 采用是显式动作 ===')
|
|
40
|
+
check('推荐配置单独存在', Object.values(FLOW_PRESETS).every(p => p.recommendation !== undefined))
|
|
41
|
+
check('选流程不产生绑定', Object.keys(resolveFlow('standard', { flow: 'standard' }).config.stage_bindings ?? {}).length === 0)
|
|
42
|
+
|
|
43
|
+
console.log('')
|
|
44
|
+
console.log('=== 3. 采用后完全可编辑、可删除 ===')
|
|
45
|
+
for (const id of ['standard', 'agile', 'minimal']) {
|
|
46
|
+
const adopted = adoptRecommendation(id)
|
|
47
|
+
check(`${id} 采用后带推荐技能`, Object.keys(adopted.stage_bindings ?? {}).length > 0)
|
|
48
|
+
const replaced = resolveFlow(id, { ...adopted, stage_bindings: {} }).config
|
|
49
|
+
check(`${id} 清空后为空(不补回)`, Object.keys(replaced.stage_bindings ?? {}).length === 0)
|
|
50
|
+
const mine = resolveFlow(id, {
|
|
51
|
+
...adopted,
|
|
52
|
+
stage_bindings: { 开发: { skills: [{ skill: { source: 'project', name: 'mine' }, rules: [] }] } },
|
|
53
|
+
}).config
|
|
54
|
+
const dev = mine.stage_bindings['开发'].skills ?? []
|
|
55
|
+
check(`${id} 用户技能被原样采纳`, dev.length === 1 && dev[0].skill.name === 'mine',
|
|
56
|
+
JSON.stringify(dev.map(s => s.skill.name)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('')
|
|
60
|
+
console.log('=== 4. 规则归属技能,节点只引用 ===')
|
|
61
|
+
for (const id of ['standard', 'agile', 'minimal']) {
|
|
62
|
+
const cfg = resolveFlow(id, adoptRecommendation(id)).config
|
|
63
|
+
let stageRuleList = false
|
|
64
|
+
let badSkill = false
|
|
65
|
+
for (const binding of Object.values(cfg.stage_bindings ?? {})) {
|
|
66
|
+
if (binding.rules !== undefined) stageRuleList = true
|
|
67
|
+
for (const entry of binding.skills ?? []) {
|
|
68
|
+
if (!Array.isArray(entry.rules)) badSkill = true
|
|
69
|
+
if (entry.skill?.source === undefined) badSkill = true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
check(`${id} 节点无规则列表`, !stageRuleList)
|
|
73
|
+
check(`${id} 每个技能自带规则数组`, !badSkill)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log('')
|
|
77
|
+
console.log('=== 5. 同一技能引用在任何节点规则一致 ===')
|
|
78
|
+
for (const id of ['standard', 'agile', 'minimal']) {
|
|
79
|
+
const cfg = resolveFlow(id, adoptRecommendation(id)).config
|
|
80
|
+
const bySkill = new Map()
|
|
81
|
+
for (const binding of Object.values(cfg.stage_bindings ?? {})) {
|
|
82
|
+
for (const entry of binding.skills ?? []) {
|
|
83
|
+
const key = formatResourceRef(entry.skill)
|
|
84
|
+
const sig = entry.rules.map(formatResourceRef).sort().join('|')
|
|
85
|
+
if (!bySkill.has(key)) bySkill.set(key, new Set())
|
|
86
|
+
bySkill.get(key).add(sig)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let ok = true
|
|
90
|
+
for (const [skill, sigs] of bySkill) if (sigs.size > 1) { ok = false; console.log(' ' + skill + ' 有 ' + sigs.size + ' 种规则集') }
|
|
91
|
+
check(`${id} 每个技能的规则集唯一`, ok, bySkill.size + ' 个技能')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log('')
|
|
95
|
+
console.log('=== 6. 配置可被引擎接受 ===')
|
|
96
|
+
for (const id of ['standard', 'agile', 'minimal']) {
|
|
97
|
+
const problems = validateWorkflow(resolveFlow(id, adoptRecommendation(id)).config)
|
|
98
|
+
check(`${id} 采用后配置无问题`, problems.length === 0, problems.join('; '))
|
|
99
|
+
const bare = validateWorkflow(resolveFlow(id, { flow: id }).config)
|
|
100
|
+
check(`${id} 裸骨架也合法`, bare.length === 0, bare.join('; '))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log('')
|
|
104
|
+
console.log('=== 7. defaults/eng.json 是采用后的配置示例 ===')
|
|
105
|
+
const doc = JSON.parse(readFileSync('defaults/eng.json', 'utf8'))
|
|
106
|
+
const merged = resolveFlow(doc.flow, doc)
|
|
107
|
+
check('defaults/eng.json 可解析', merged.ok)
|
|
108
|
+
if (merged.ok) {
|
|
109
|
+
check('defaults/eng.json 无验证问题', validateWorkflow(merged.config).length === 0)
|
|
110
|
+
check('defaults/eng.json 含技能(是采用后的样子)', Object.keys(merged.config.stage_bindings ?? {}).length > 0)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log('')
|
|
114
|
+
console.log('=== 8. 三预设都以 completed 收尾,且检查各自声明的节点结果 ===')
|
|
115
|
+
for (const id of ['standard', 'agile', 'minimal']) {
|
|
116
|
+
const cfg = resolveFlow(id, adoptRecommendation(id)).config
|
|
117
|
+
const terminals = cfg.stages.filter(s => !cfg.transitions.some(t => t.from === s))
|
|
118
|
+
// Every conclusion a flow could ask for, so each preset is judged on what IT
|
|
119
|
+
// declared rather than on what one preset happens to need.
|
|
120
|
+
const satisfied = {
|
|
121
|
+
stage: terminals[0], execution_version: 1,
|
|
122
|
+
items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
|
|
123
|
+
verification: { passed: true, evidence: [] },
|
|
124
|
+
review: { outcome: 'pass' },
|
|
125
|
+
commits: [{ label: 'TASK', hash: 'abc1234' }],
|
|
126
|
+
}
|
|
127
|
+
check(`${id} 未提交不可完成`, completionBlockers({ ...satisfied, commits: [] }, cfg).length > 0)
|
|
128
|
+
check(`${id} 齐全后可完成`, completionBlockers(satisfied, cfg).length === 0,
|
|
129
|
+
JSON.stringify(completionBlockers(satisfied, cfg)))
|
|
130
|
+
|
|
131
|
+
// The result the assessment found could be missing: a flow that declares a review
|
|
132
|
+
// must not complete with the review blocked, and a flow that declares none must
|
|
133
|
+
// not be made to demand one.
|
|
134
|
+
const wantsReview = terminalRequirements(cfg).includes('review_passed')
|
|
135
|
+
const blocked = completionBlockers({ ...satisfied, review: { outcome: 'blocked' } }, cfg)
|
|
136
|
+
check(`${id} ${wantsReview ? '审核未通过不可完成' : '不发明审核要求'}`,
|
|
137
|
+
wantsReview ? blocked.some(b => b.includes('passing review')) : blocked.length === 0,
|
|
138
|
+
JSON.stringify(blocked))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log('')
|
|
142
|
+
console.log('=== 9. 返工三种类型范围不同 ===')
|
|
143
|
+
const r = invalidatedBy('requirement'), s = invalidatedBy('solution'), d = invalidatedBy('defect')
|
|
144
|
+
check('requirement 失效需求确认', r.includes('requirement_confirmation'))
|
|
145
|
+
check('solution 保留需求确认', !s.includes('requirement_confirmation'))
|
|
146
|
+
check('defect 保留需求与方案确认', !d.includes('requirement_confirmation') && !d.includes('solution_confirmation'))
|
|
147
|
+
check('三者范围互不相同', new Set([r.join(), s.join(), d.join()]).size === 3)
|
|
148
|
+
|
|
149
|
+
console.log('')
|
|
150
|
+
console.log(failures === 0 ? '✅ 全部验收项通过' : `❌ ${failures} 项未通过`)
|
|
89
151
|
process.exit(failures === 0 ? 0 : 1)
|
package/.assessment-batch1.mjs
CHANGED
|
@@ -15,13 +15,28 @@ import assert from 'node:assert/strict'
|
|
|
15
15
|
import { join, resolve } from 'node:path'
|
|
16
16
|
import { registerDevTask } from './lib/dev-task.js'
|
|
17
17
|
import { newTask, todosBlockers, completionBlockers, validateWorkflow, formatResourceRef } from './lib/engine.js'
|
|
18
|
-
import { FLOW_PRESETS } from './lib/workflows.js'
|
|
18
|
+
import { adoptRecommendation, FLOW_PRESETS } from './lib/workflows.js'
|
|
19
19
|
import { resolveFlow } from './lib/workflows.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The preset as a project would actually run it: the skeleton plus the shipped
|
|
23
|
+
* recommendation, adopted exactly the way a user adopts it. A test that asserts
|
|
24
|
+
* on bindings, commit rules or artifacts wants this, because those no longer come
|
|
25
|
+
* from the preset itself.
|
|
26
|
+
* @param {string} id - preset id.
|
|
27
|
+
* @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
|
|
28
|
+
*/
|
|
29
|
+
function adoptedFlow(id, extra) {
|
|
30
|
+
const base = adoptRecommendation(id)
|
|
31
|
+
if (base === undefined) throw new Error('unknown preset: ' + id)
|
|
32
|
+
return resolveFlow(id, { ...base, ...extra }).config
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
|
|
21
36
|
/** Build a fixture on one preset, mirroring .workflow-test.mjs's contract. */
|
|
22
37
|
function fixture(preset = 'standard', stage, extra = {}, services = {}) {
|
|
23
38
|
const cwd = resolve('test-project')
|
|
24
|
-
const config =
|
|
39
|
+
const config = adoptedFlow(preset)
|
|
25
40
|
const state = newTask({
|
|
26
41
|
id: 'LIVE-1', title: 'assessment', branch: 'test', work_size: 'standard',
|
|
27
42
|
risk_level: 'standard', flow: { flow: preset, version: config.version ?? 1, config }, root: cwd,
|
|
@@ -131,7 +146,7 @@ test('P1: the agile checkpoint label must satisfy the agile message pattern', as
|
|
|
131
146
|
const f = fixture('agile', '交付', { items: [{ id: 'A', title: 'a', status: 'done' }] })
|
|
132
147
|
const status = JSON.parse(await f.call({ operation: 'status' }))
|
|
133
148
|
const label = status.commit?.label
|
|
134
|
-
const pattern =
|
|
149
|
+
const pattern = adoptedFlow('agile').commit.message_pattern
|
|
135
150
|
assert.ok(label !== undefined, 'a commit must be due at the agile checkpoint')
|
|
136
151
|
assert.match(
|
|
137
152
|
`【LIVE-1】【${label}】did a thing`,
|
|
@@ -202,8 +217,8 @@ test('P2: the minimal preset reviews each item once, and still reviews it', () =
|
|
|
202
217
|
// This earlier asserted on status.skill_blockers/commit_blockers, which never
|
|
203
218
|
// contain todosBlockers output, so it passed against a completely unfixed
|
|
204
219
|
// engine. Call the function under test directly instead.
|
|
205
|
-
const minimal =
|
|
206
|
-
const standard =
|
|
220
|
+
const minimal = adoptedFlow('minimal')
|
|
221
|
+
const standard = adoptedFlow('standard')
|
|
207
222
|
const specPassQualityFail = {
|
|
208
223
|
items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'fail' } } }],
|
|
209
224
|
}
|
|
@@ -230,7 +245,7 @@ test('P2: the three presets must not share one identical review burden', () => {
|
|
|
230
245
|
items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'fail' } } }],
|
|
231
246
|
}
|
|
232
247
|
const byDepth = ['standard', 'agile', 'minimal'].map(id =>
|
|
233
|
-
todosBlockers(state,
|
|
248
|
+
todosBlockers(state, adoptedFlow(id)).length)
|
|
234
249
|
assert.ok(
|
|
235
250
|
byDepth[2] < byDepth[0],
|
|
236
251
|
`the fast-change flow must audit less per item than the full one; got ${JSON.stringify(byDepth)}`,
|
|
@@ -267,7 +282,7 @@ test('P2: a rule a skill declares but that resolves nowhere must be reported, no
|
|
|
267
282
|
test('batch3: a stage selects skills only, and a skill carries its own complete rule list', () => {
|
|
268
283
|
// The whole point of the model: opening a skill tells you every rule it runs
|
|
269
284
|
// under, and binding it anywhere does not add or override any rule.
|
|
270
|
-
const config =
|
|
285
|
+
const config = adoptedFlow('standard')
|
|
271
286
|
for (const [stage, binding] of Object.entries(config.stage_bindings ?? {})) {
|
|
272
287
|
assert.ok(
|
|
273
288
|
!('rules' in binding) || binding.rules === undefined,
|
|
@@ -305,7 +320,7 @@ test('batch3: the same skill carries identical rules wherever it is bound', () =
|
|
|
305
320
|
test('batch3: a rule shared by two skills is one resource, referenced twice', () => {
|
|
306
321
|
// Reuse must not duplicate the rule body: two skills referencing one rule point
|
|
307
322
|
// at the same source-qualified reference.
|
|
308
|
-
const config =
|
|
323
|
+
const config = adoptedFlow('standard')
|
|
309
324
|
const review = config.stage_bindings['代码审核'].skills
|
|
310
325
|
const reviewRules = review.find(entry => entry.skill.name === 'code-review').rules.map(formatResourceRef)
|
|
311
326
|
const implementRules = config.stage_bindings['开发'].skills
|
|
@@ -344,12 +359,17 @@ test('P2: reaching the last stage is not completion for any preset', () => {
|
|
|
344
359
|
// flow could stand on its last stage with no delivery record at all. Completion
|
|
345
360
|
// is now its own checked event, shared by all three presets.
|
|
346
361
|
for (const [preset, stage] of [['standard', '完成'], ['agile', '审查'], ['minimal', '交付']]) {
|
|
347
|
-
const config =
|
|
362
|
+
const config = adoptedFlow(preset)
|
|
348
363
|
const state = {
|
|
349
364
|
stage,
|
|
350
365
|
execution_version: 1,
|
|
351
366
|
items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
|
|
352
367
|
verification: { passed: true, evidence: [] },
|
|
368
|
+
// The flows that declare a review guard need a recorded verdict. That is the
|
|
369
|
+
// point of deriving terminal requirements from the flow: standard and agile ask
|
|
370
|
+
// for one, minimal does not, so this supplies what is asked for rather than
|
|
371
|
+
// what one flow happens to need.
|
|
372
|
+
review: { outcome: 'pass' },
|
|
353
373
|
commits: [],
|
|
354
374
|
}
|
|
355
375
|
assert.ok(
|
|
@@ -365,7 +385,7 @@ test('P2: reaching the last stage is not completion for any preset', () => {
|
|
|
365
385
|
})
|
|
366
386
|
|
|
367
387
|
test('P2: completion refuses a task that has not reached a final stage', () => {
|
|
368
|
-
const config =
|
|
388
|
+
const config = adoptedFlow('standard')
|
|
369
389
|
const state = {
|
|
370
390
|
stage: '开发',
|
|
371
391
|
execution_version: 1,
|
|
@@ -381,7 +401,7 @@ test('P2: completion refuses a task that has not reached a final stage', () => {
|
|
|
381
401
|
test('P2: a flow may declare that its delivery mode needs no commit', () => {
|
|
382
402
|
// A Git commit is one delivery mode, not a universal finish line: a non-code
|
|
383
403
|
// task or a project outside version control has nothing to commit.
|
|
384
|
-
const withoutCommit = { ...
|
|
404
|
+
const withoutCommit = { ...adoptedFlow('minimal'), commit_required: false }
|
|
385
405
|
const state = {
|
|
386
406
|
stage: '交付',
|
|
387
407
|
execution_version: 1,
|
|
@@ -396,7 +416,7 @@ test('P2: a flow may declare that its delivery mode needs no commit', () => {
|
|
|
396
416
|
})
|
|
397
417
|
|
|
398
418
|
test('P2: an unknown review_depth is a configuration error, not a silent downgrade', () => {
|
|
399
|
-
const config = { ...
|
|
419
|
+
const config = { ...adoptedFlow('minimal'), review_depth: 'thorough' }
|
|
400
420
|
const problems = validateWorkflow(config)
|
|
401
421
|
assert.ok(problems.some(p => /review_depth/u.test(p)),
|
|
402
422
|
`a typo must be reported; saw ${JSON.stringify(problems)}`)
|
package/.e2e-presets.mjs
CHANGED
|
@@ -15,7 +15,22 @@ import assert from 'node:assert/strict'
|
|
|
15
15
|
import { join, resolve } from 'node:path'
|
|
16
16
|
import { registerDevTask } from './lib/dev-task.js'
|
|
17
17
|
import { newTask } from './lib/engine.js'
|
|
18
|
-
import { resolveFlow, FLOW_PRESETS } from './lib/workflows.js'
|
|
18
|
+
import { adoptRecommendation, resolveFlow, FLOW_PRESETS } from './lib/workflows.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The preset as a project would actually run it: the skeleton plus the shipped
|
|
22
|
+
* recommendation, adopted exactly the way a user adopts it. A test that asserts
|
|
23
|
+
* on bindings, commit rules or artifacts wants this, because those no longer come
|
|
24
|
+
* from the preset itself.
|
|
25
|
+
* @param {string} id - preset id.
|
|
26
|
+
* @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
|
|
27
|
+
*/
|
|
28
|
+
function adoptedFlow(id, extra) {
|
|
29
|
+
const base = adoptRecommendation(id)
|
|
30
|
+
if (base === undefined) throw new Error('unknown preset: ' + id)
|
|
31
|
+
return resolveFlow(id, { ...base, ...extra }).config
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
|
|
20
35
|
const HASH = 'abcdef1234567890'
|
|
21
36
|
|
|
@@ -26,16 +41,18 @@ const HASH = 'abcdef1234567890'
|
|
|
26
41
|
*/
|
|
27
42
|
async function runPreset(preset) {
|
|
28
43
|
const cwd = resolve('e2e-project')
|
|
29
|
-
const config =
|
|
44
|
+
const config = adoptedFlow(preset)
|
|
30
45
|
const state = newTask({
|
|
31
46
|
id: 'E2E-1', title: 'pipeline', branch: 'main', work_size: 'standard',
|
|
32
47
|
risk_level: 'standard', flow: { flow: preset, version: FLOW_PRESETS[preset].version, config }, root: cwd,
|
|
33
48
|
})
|
|
34
49
|
const records = new Map([
|
|
35
50
|
[join(cwd, '.dsh/task-E2E-1.json'), JSON.stringify(state)],
|
|
36
|
-
// `create` reads the project's
|
|
37
|
-
//
|
|
38
|
-
|
|
51
|
+
// `create` reads the project's config from here, so this writes what a real
|
|
52
|
+
// project has after adopting the shipped recommendation: the skeleton plus its
|
|
53
|
+
// recommended skills, commit rule and artifacts. Writing only `{flow}` would
|
|
54
|
+
// describe a project that adopted nothing — a different, equally valid case.
|
|
55
|
+
[join(cwd, '.dsh/eng.json'), JSON.stringify(adoptRecommendation(preset))],
|
|
39
56
|
[join(cwd, 'src/a.js'), 'source'],
|
|
40
57
|
])
|
|
41
58
|
const events = []
|
|
@@ -160,7 +177,25 @@ async function runPreset(preset) {
|
|
|
160
177
|
}
|
|
161
178
|
// The terminal stage's own obligations are checked before it can be left, and
|
|
162
179
|
// completion is the operation that leaves it.
|
|
180
|
+
//
|
|
181
|
+
// A flow may declare its final requirements as `completion_guards` rather than on
|
|
182
|
+
// an edge, because a terminal stage has no edge to carry them — the agile flow's
|
|
183
|
+
// 审查 IS the review. They are satisfied here, at the last stage, rather than
|
|
184
|
+
// during the loop: they describe what completion means, not how a stage is entered.
|
|
163
185
|
for (const binding of config.stage_bindings?.[current().stage]?.skills ?? []) load(binding.skill.name)
|
|
186
|
+
const terminalGuards = new Set(config.completion_guards ?? [])
|
|
187
|
+
if (terminalGuards.has('todos_done') && !current().items.some(item => item.status === 'done')) {
|
|
188
|
+
await call({ operation: 'items', items: [{ id: 'A', title: 'a', status: 'doing' }] })
|
|
189
|
+
await call({ operation: 'dispatch', item_id: 'A', description: 'implemented by a worker' })
|
|
190
|
+
await call({ operation: 'review_item', item_id: 'A', spec_outcome: 'pass', quality_outcome: 'pass' })
|
|
191
|
+
await call({ operation: 'items', items: [{ id: 'A', status: 'done' }] })
|
|
192
|
+
}
|
|
193
|
+
if (terminalGuards.has('verified')) {
|
|
194
|
+
await call({ operation: 'verify', command: 'npm test', description: 'run the suite' })
|
|
195
|
+
}
|
|
196
|
+
if (terminalGuards.has('review_passed')) {
|
|
197
|
+
await call({ operation: 'review', outcome: 'pass' })
|
|
198
|
+
}
|
|
164
199
|
if (config.commit.checkpoints.includes(current().stage)) {
|
|
165
200
|
const status = await call({ operation: 'status' })
|
|
166
201
|
await call({
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three enforcement gaps the assessment found, tested as behaviour.
|
|
3
|
+
*
|
|
4
|
+
* Each of these was previously DISCLOSED but not enforced, which is the same as not
|
|
5
|
+
* being enforced: status reported the problem and the task proceeded anyway.
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { join, resolve } from 'node:path'
|
|
10
|
+
import { registerDevTask } from './lib/dev-task.js'
|
|
11
|
+
import { adoptRecommendation, resolveFlow } from './lib/workflows.js'
|
|
12
|
+
import { completionBlockers, terminalRequirements, resourceBlockers } from './lib/engine.js'
|
|
13
|
+
|
|
14
|
+
/** A project on the standard flow, one skill carrying a project rule, at 开发. */
|
|
15
|
+
async function project() {
|
|
16
|
+
const cwd = resolve('enforce-project')
|
|
17
|
+
const adopted = adoptRecommendation('standard')
|
|
18
|
+
const skills = adopted.stage_bindings['开发'].skills.map(entry => ({
|
|
19
|
+
...entry,
|
|
20
|
+
rules: [...entry.rules, { source: 'project', name: 'mine' }],
|
|
21
|
+
}))
|
|
22
|
+
const stage_bindings = { ...adopted.stage_bindings, '开发': { skills } }
|
|
23
|
+
const records = new Map([
|
|
24
|
+
[join(cwd, '.dsh/eng.json'), JSON.stringify({ ...adopted, stage_bindings })],
|
|
25
|
+
[join(cwd, '.dsh/rules/mine.md'), 'RULE'],
|
|
26
|
+
[join(cwd, 'a.js'), 'source'],
|
|
27
|
+
])
|
|
28
|
+
const events = []
|
|
29
|
+
let execute
|
|
30
|
+
const key = (p, options) => join(options?.cwd ?? cwd, p)
|
|
31
|
+
const fs = {
|
|
32
|
+
resolve: async (p, options) => ({ targetKey: key(p, options) }),
|
|
33
|
+
readText: async target => records.get(target.targetKey),
|
|
34
|
+
listDir: async () => [],
|
|
35
|
+
lstat: async (p, options) => records.has(key(p, options)) ? { version: 'v' } : undefined,
|
|
36
|
+
writeText: async (target, content) => { records.set(target.targetKey, content) },
|
|
37
|
+
}
|
|
38
|
+
const ctx = {
|
|
39
|
+
fs,
|
|
40
|
+
tools: { register(tool) { execute = tool.execute; return () => {} } },
|
|
41
|
+
get: name => name === 'sandboxPolicy'
|
|
42
|
+
? { resolve: () => ({ mode: 'workspace-write', workspaceRoot: cwd, sessionId: 'e' }) }
|
|
43
|
+
: undefined,
|
|
44
|
+
}
|
|
45
|
+
registerDevTask(ctx)
|
|
46
|
+
const exec = { agent: { session: { id: 'e', header: { cwd }, snapshotEvents: () => events } }, signal: new AbortController().signal }
|
|
47
|
+
const call = async args => execute(JSON.parse(JSON.stringify({ task_id: 'E-1', ...args })), exec)
|
|
48
|
+
await call({ operation: 'create', title: 't', branch: 'main', files: ['a.js'] })
|
|
49
|
+
const state = JSON.parse(records.get(join(cwd, '.dsh/task-E-1.json')))
|
|
50
|
+
state.stage = '开发'
|
|
51
|
+
state.requirement_confirmed = true
|
|
52
|
+
state.solution_confirmed = true
|
|
53
|
+
records.set(join(cwd, '.dsh/task-E-1.json'), JSON.stringify(state))
|
|
54
|
+
const load = name => {
|
|
55
|
+
const callId = 'c' + events.length
|
|
56
|
+
events.push({ type: 'tool/call', data: { name: 'skill', callId, arguments: JSON.stringify({ name }) } })
|
|
57
|
+
events.push({ type: 'tool/result', data: { message: { content: [{ type: 'tool-result', toolCallId: callId, isError: false }] } } })
|
|
58
|
+
}
|
|
59
|
+
const current = () => JSON.parse(records.get(join(cwd, '.dsh/task-E-1.json')))
|
|
60
|
+
return { call, records, cwd, load, current, rule: () => join(cwd, '.dsh/rules/mine.md') }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Satisfy everything 开发 → 交付 needs except the resource question. */
|
|
64
|
+
async function satistfyDevelopment(f) {
|
|
65
|
+
await f.call({ operation: 'items', items: [{ id: 'A', title: 'a', status: 'doing' }] })
|
|
66
|
+
await f.call({ operation: 'dispatch', item_id: 'A', description: 'd' })
|
|
67
|
+
await f.call({ operation: 'review_item', item_id: 'A', spec_outcome: 'pass', quality_outcome: 'pass' })
|
|
68
|
+
await f.call({ operation: 'items', items: [{ id: 'A', status: 'done' }] })
|
|
69
|
+
for (const entry of f.current().flow.config.stage_bindings['开发'].skills) f.load(entry.skill.name)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('enforce: a task WITHOUT a snapshot cannot advance while a bound rule is unresolvable', async () => {
|
|
73
|
+
// The disclosed-but-unenforced case: status said the rule was missing and advance
|
|
74
|
+
// still succeeded, so the stage completed without the constraint.
|
|
75
|
+
const f = await project()
|
|
76
|
+
const state = f.current()
|
|
77
|
+
delete state.flow.resources
|
|
78
|
+
f.records.set(join(f.cwd, '.dsh/task-E-1.json'), JSON.stringify(state))
|
|
79
|
+
await satistfyDevelopment(f)
|
|
80
|
+
|
|
81
|
+
f.records.delete(f.rule())
|
|
82
|
+
await assert.rejects(
|
|
83
|
+
() => f.call({ operation: 'advance', target_stage: '交付' }),
|
|
84
|
+
/resolves nowhere/u,
|
|
85
|
+
'a stage whose rules cannot be resolved must not be passable',
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('enforce: a task WITH a snapshot still advances after its source rule is deleted', async () => {
|
|
90
|
+
// The other side of the same rule: the frozen copy IS the constraint, so a
|
|
91
|
+
// deleted source is drift to report, not a reason to block. Without this the
|
|
92
|
+
// freeze would be pointless — the task would be blocked by a file it no longer
|
|
93
|
+
// needs.
|
|
94
|
+
const f = await project()
|
|
95
|
+
await satistfyDevelopment(f)
|
|
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
|
+
})
|
|
100
|
+
|
|
101
|
+
test('enforce: completion requires the review and verification the flow declared', async () => {
|
|
102
|
+
// Arriving at the last stage used to be enough. The agile flow declares a review
|
|
103
|
+
// on the way into 审查, so a blocked review must now prevent completion —
|
|
104
|
+
// without hardcoding review into flows that never asked for it.
|
|
105
|
+
const agile = resolveFlow('agile', adoptRecommendation('agile')).config
|
|
106
|
+
assert.ok(terminalRequirements(agile).length >= 0)
|
|
107
|
+
const base = {
|
|
108
|
+
stage: '审查', execution_version: 1,
|
|
109
|
+
items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
|
|
110
|
+
verification: { passed: true, evidence: [] },
|
|
111
|
+
commits: [{ label: 'TASK', hash: 'abc1234' }],
|
|
112
|
+
}
|
|
113
|
+
assert.deepEqual(completionBlockers({ ...base, review: { outcome: 'pass' } }, agile), [],
|
|
114
|
+
'a passing review completes')
|
|
115
|
+
const blocked = completionBlockers({ ...base, review: { outcome: 'blocked' } }, agile)
|
|
116
|
+
assert.ok(blocked.some(b => b.includes('passing review')),
|
|
117
|
+
`a blocked review must prevent completion; got ${JSON.stringify(blocked)}`)
|
|
118
|
+
|
|
119
|
+
// And a flow that declares no verification gate is not made to demand one.
|
|
120
|
+
const minimal = resolveFlow('minimal', adoptRecommendation('minimal')).config
|
|
121
|
+
const minState = {
|
|
122
|
+
stage: '交付', execution_version: 1,
|
|
123
|
+
items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' } } }],
|
|
124
|
+
verification: { passed: false, evidence: [] },
|
|
125
|
+
commits: [{ label: 'TASK', hash: 'abc1234' }],
|
|
126
|
+
}
|
|
127
|
+
assert.deepEqual(completionBlockers(minState, minimal), [],
|
|
128
|
+
'minimal declares no verification gate, so a non-passing verification must not block it')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('enforce: the resource blocker names the stage, the reference and the fix', () => {
|
|
132
|
+
const blockers = resourceBlockers(['project:mine'], '开发')
|
|
133
|
+
assert.equal(blockers.length, 1)
|
|
134
|
+
assert.match(blockers[0], /开发/u)
|
|
135
|
+
assert.match(blockers[0], /project:mine/u)
|
|
136
|
+
assert.match(blockers[0], /Restore the file|remove the binding/u,
|
|
137
|
+
'a blocker must say what to do, not only what is wrong')
|
|
138
|
+
assert.deepEqual(resourceBlockers([], '开发'), [], 'a resolvable stage has no blocker')
|
|
139
|
+
})
|