@godv61/dsh-task-engine 0.23.9 → 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 +151 -0
- package/.assessment-batch1.mjs +423 -0
- package/.e2e-presets.mjs +230 -0
- package/.enforce-test.mjs +139 -0
- package/.evidence-test.mjs +119 -0
- package/.filter-test.mjs +93 -0
- package/.freeze-test.mjs +199 -0
- package/.hook-consistency.mjs +60 -0
- package/.hook-test.mjs +8 -3
- package/.p0-test.mjs +141 -50
- package/.revision-test.mjs +114 -0
- package/.roundtrip-test.mjs +63 -0
- package/.workflow-test.mjs +94 -65
- package/README.md +4 -2
- package/defaults/eng.json +45 -6
- package/docs/BRIEF-FOR-REVIEW.md +163 -0
- package/docs/CHANGELOG.md +96 -0
- package/docs/configuration.md +31 -6
- package/hooks/commit-msg +162 -65
- package/lib/client.js +638 -431
- package/lib/client.js.map +3 -3
- package/lib/controller.d.ts +18 -1
- package/lib/controller.js +64 -5
- package/lib/controller.js.map +1 -1
- package/lib/dev-task.js +363 -47
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +368 -6
- package/lib/engine.js +328 -13
- 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 +71 -4
- package/lib/skill-audit.js.map +1 -1
- package/lib/workflows.d.ts +75 -12
- package/lib/workflows.js +213 -77
- package/lib/workflows.js.map +1 -1
- package/package.json +14 -4
- package/scripts/verify-package.mjs +1 -1
package/.acceptance.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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} 项未通过`)
|
|
151
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch-1 reproductions for the 2026-09-23 external assessment.
|
|
3
|
+
*
|
|
4
|
+
* Each case asserts the CORRECT behaviour and is therefore expected to FAIL
|
|
5
|
+
* against 0.23.9, proving the defect exists before it is fixed. They reuse the
|
|
6
|
+
* existing simulated fixture pattern from .workflow-test.mjs: the real built
|
|
7
|
+
* `dev_task` tool and real engine functions run against an in-memory fs, so no
|
|
8
|
+
* assertion here is a source-string check.
|
|
9
|
+
*
|
|
10
|
+
* Not covered here: the real Harness Web pass, which the assessment also
|
|
11
|
+
* requires and which is reported separately.
|
|
12
|
+
*/
|
|
13
|
+
import test from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import { join, resolve } from 'node:path'
|
|
16
|
+
import { registerDevTask } from './lib/dev-task.js'
|
|
17
|
+
import { newTask, todosBlockers, completionBlockers, validateWorkflow, formatResourceRef } from './lib/engine.js'
|
|
18
|
+
import { adoptRecommendation, FLOW_PRESETS } from './lib/workflows.js'
|
|
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
|
+
|
|
35
|
+
|
|
36
|
+
/** Build a fixture on one preset, mirroring .workflow-test.mjs's contract. */
|
|
37
|
+
function fixture(preset = 'standard', stage, extra = {}, services = {}) {
|
|
38
|
+
const cwd = resolve('test-project')
|
|
39
|
+
const config = adoptedFlow(preset)
|
|
40
|
+
const state = newTask({
|
|
41
|
+
id: 'LIVE-1', title: 'assessment', branch: 'test', work_size: 'standard',
|
|
42
|
+
risk_level: 'standard', flow: { flow: preset, version: config.version ?? 1, config }, root: cwd,
|
|
43
|
+
})
|
|
44
|
+
Object.assign(state, {
|
|
45
|
+
stage, execution_version: 1, files: ['app.js'],
|
|
46
|
+
requirement_confirmed: true, solution_confirmed: true, ...extra,
|
|
47
|
+
})
|
|
48
|
+
const records = new Map([
|
|
49
|
+
[join(cwd, '.dsh/task-LIVE-1.json'), JSON.stringify(state)],
|
|
50
|
+
[join(cwd, 'app.js'), 'source'],
|
|
51
|
+
])
|
|
52
|
+
const events = [], runs = []
|
|
53
|
+
const session = { id: 'test-session', header: { cwd }, snapshotEvents: () => events }
|
|
54
|
+
const policy = { mode: 'workspace-write', workspaceRoot: cwd, sessionId: session.id }
|
|
55
|
+
const abort = new AbortController()
|
|
56
|
+
let execute
|
|
57
|
+
const key = (path, options) => join(options?.cwd ?? cwd, path)
|
|
58
|
+
const fs = {
|
|
59
|
+
resolve: async (path, options) => ({ targetKey: key(path, options) }),
|
|
60
|
+
readText: async target => records.get(target.targetKey),
|
|
61
|
+
listDir: async () => [...records.keys()].filter(p => /task-.+\.json$/.test(p)).map(p => ({ name: p.split(/[\\/]/).at(-1) })),
|
|
62
|
+
lstat: async (path, options) => records.has(key(path, options)) ? { version: 'v' } : undefined,
|
|
63
|
+
writeText: async (target, content) => { records.set(target.targetKey, content) },
|
|
64
|
+
}
|
|
65
|
+
const ctx = {
|
|
66
|
+
fs,
|
|
67
|
+
tools: { register(tool) { execute = tool.execute; return () => {} } },
|
|
68
|
+
get(name) {
|
|
69
|
+
if (Object.hasOwn(services, name)) return services[name]
|
|
70
|
+
if (name === 'sandboxPolicy') return { resolve(request) { assert.equal(request.session, session); return policy } }
|
|
71
|
+
if (name === 'shell') return {
|
|
72
|
+
resolve(request) { runs.push(request); return request },
|
|
73
|
+
async run(request) {
|
|
74
|
+
const fail = request.command === 'fail'
|
|
75
|
+
return {
|
|
76
|
+
exitCode: fail ? 1 : 0, timedOut: false, aborted: false,
|
|
77
|
+
sandbox: { mode: 'workspace-write', denied: false },
|
|
78
|
+
stdout: { text: request.command.startsWith('git -c core.quotepath=false log')
|
|
79
|
+
? 'abcdef1234567890\n【LIVE-1】【TASK】done\n\napp.js\n'
|
|
80
|
+
: 'checks passed' },
|
|
81
|
+
stderr: { text: '' },
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
registerDevTask(ctx)
|
|
89
|
+
const exec = { agent: { session }, signal: abort.signal }
|
|
90
|
+
function load(name, success = true) {
|
|
91
|
+
const callId = `call-${events.length}`
|
|
92
|
+
events.push({ type: 'tool/call', data: { name: 'skill', callId, arguments: JSON.stringify({ name }) } })
|
|
93
|
+
events.push({ type: 'tool/result', data: { message: { content: [{ type: 'tool-result', toolCallId: callId, isError: !success }] } } })
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
call: args => execute(JSON.parse(JSON.stringify({ task_id: 'LIVE-1', ...args })), exec),
|
|
97
|
+
load, runs, policy, exec, records, cwd,
|
|
98
|
+
state: () => JSON.parse(records.get(join(cwd, '.dsh/task-LIVE-1.json'))),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Run `verify` with a command, optionally failing. */
|
|
103
|
+
async function verify(f, command) {
|
|
104
|
+
return f.call({ operation: 'verify', command, description: 'checks' })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------- P1: stale verification
|
|
108
|
+
|
|
109
|
+
test('P1: a failed re-verification must block the commit at the review stage', async () => {
|
|
110
|
+
// Standard flow: 交付 -> 代码审核 requires `verified`, 代码审核 -> 完成 requires only
|
|
111
|
+
// review_passed. Re-verifying with a failing command flips `passed` to false, and
|
|
112
|
+
// evidenceBlockers guards its staleness check behind `passed &&`, so nothing reports it.
|
|
113
|
+
const f = fixture('standard', '代码审核')
|
|
114
|
+
await verify(f, 'pass') // establish a passing receipt
|
|
115
|
+
await f.call({ operation: 'record', artifact: 'review', fields: { conclusion: 'ok', issues: 'none' } })
|
|
116
|
+
await f.call({ operation: 'review', outcome: 'pass' })
|
|
117
|
+
await verify(f, 'fail') // re-verify: now failing
|
|
118
|
+
const status = JSON.parse(await f.call({ operation: 'status' }))
|
|
119
|
+
const passed = f.state().verification.passed
|
|
120
|
+
await assert.rejects(
|
|
121
|
+
() => f.call({ operation: 'commit' }),
|
|
122
|
+
/verif/i,
|
|
123
|
+
`commit must be refused while verification is failing (passed=${passed}, commit.allowed=${status.commit?.allowed})`,
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('P1: completion must re-check verification for a task that once crossed it', async () => {
|
|
128
|
+
const f = fixture('standard', '代码审核')
|
|
129
|
+
await verify(f, 'pass')
|
|
130
|
+
await f.call({ operation: 'record', artifact: 'review', fields: { conclusion: 'ok', issues: 'none' } })
|
|
131
|
+
await f.call({ operation: 'review', outcome: 'pass' })
|
|
132
|
+
await verify(f, 'fail')
|
|
133
|
+
const status = JSON.parse(await f.call({ operation: 'status' }))
|
|
134
|
+
const blockers = [...(status.evidence_blockers ?? []), ...(status.commit_blockers ?? [])]
|
|
135
|
+
assert.ok(
|
|
136
|
+
blockers.some(b => /verif/i.test(b)),
|
|
137
|
+
`status must report verification as blocking; saw ${JSON.stringify(blockers)}`,
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- P1: agile label contract
|
|
142
|
+
|
|
143
|
+
test('P1: the agile checkpoint label must satisfy the agile message pattern', async () => {
|
|
144
|
+
// commitCheckpoint returns 'TASK' at a checkpoint stage while agile's pattern only
|
|
145
|
+
// accepts \d+, so the label the model is handed cannot produce an acceptable message.
|
|
146
|
+
const f = fixture('agile', '交付', { items: [{ id: 'A', title: 'a', status: 'done' }] })
|
|
147
|
+
const status = JSON.parse(await f.call({ operation: 'status' }))
|
|
148
|
+
const label = status.commit?.label
|
|
149
|
+
const pattern = adoptedFlow('agile').commit.message_pattern
|
|
150
|
+
assert.ok(label !== undefined, 'a commit must be due at the agile checkpoint')
|
|
151
|
+
assert.match(
|
|
152
|
+
`【LIVE-1】【${label}】did a thing`,
|
|
153
|
+
new RegExp(pattern, 'u'),
|
|
154
|
+
`label ${JSON.stringify(label)} must satisfy the preset's own pattern ${JSON.stringify(pattern)}`,
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------- verification scoping
|
|
159
|
+
|
|
160
|
+
test('P1: the verification requirement applies only after the flow reaches a verify gate', async () => {
|
|
161
|
+
// The fix must not overreach. `verified` guards the edge INTO a stage, so a task
|
|
162
|
+
// that has not crossed that edge has nothing to verify and must not be blocked
|
|
163
|
+
// by a requirement it has not yet met — otherwise the flow cannot even start.
|
|
164
|
+
const early = fixture('standard', '需求评审', {
|
|
165
|
+
artifacts: { requirement: { scope: 's', acceptance_criteria: 'a' } },
|
|
166
|
+
})
|
|
167
|
+
const earlyStatus = JSON.parse(await early.call({ operation: 'status' }))
|
|
168
|
+
assert.deepEqual(
|
|
169
|
+
earlyStatus.verification_blockers ?? [],
|
|
170
|
+
[],
|
|
171
|
+
'a task before the verify gate must not be reported as failing verification',
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// Past the gate it applies, and stays applied at every later stage.
|
|
175
|
+
for (const stage of ['代码审核', '完成']) {
|
|
176
|
+
const late = fixture('standard', stage)
|
|
177
|
+
const lateStatus = JSON.parse(await late.call({ operation: 'status' }))
|
|
178
|
+
assert.ok(
|
|
179
|
+
(lateStatus.verification_blockers ?? []).length > 0,
|
|
180
|
+
`stage ${stage} sits after the verify gate, so it must require verification`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('P1: a preset that never guards on verified must not demand verification', async () => {
|
|
186
|
+
// "Only what the user configured should be enforced." agile and minimal declare
|
|
187
|
+
// no `verified` edge, so the engine must not invent one.
|
|
188
|
+
for (const preset of ['agile', 'minimal']) {
|
|
189
|
+
const stage = preset === 'agile' ? '审查' : '交付'
|
|
190
|
+
const f = fixture(preset, stage)
|
|
191
|
+
const status = JSON.parse(await f.call({ operation: 'status' }))
|
|
192
|
+
assert.deepEqual(
|
|
193
|
+
status.verification_blockers ?? [],
|
|
194
|
+
[],
|
|
195
|
+
`${preset} declares no verification gate, so it must not report one`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------- P1: agile artifact contract
|
|
201
|
+
|
|
202
|
+
test('P1: every skill bound by a preset must be able to record what it is told to record', async () => {
|
|
203
|
+
// agile binds code-review at 审查, and that skill instructs the model to
|
|
204
|
+
// `record artifact=review`, but agile declares only the `requirement` artifact.
|
|
205
|
+
const f = fixture('agile', '审查', {
|
|
206
|
+
verification: { passed: true, evidence: ['checks passed'] },
|
|
207
|
+
})
|
|
208
|
+
await assert.doesNotReject(
|
|
209
|
+
() => f.call({ operation: 'record', artifact: 'review', fields: { conclusion: 'ok', issues: 'none' } }),
|
|
210
|
+
'the review artifact a bound skill is instructed to write must exist in the preset contract',
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------- P2: minimal must be light
|
|
215
|
+
|
|
216
|
+
test('P2: the minimal preset reviews each item once, and still reviews it', () => {
|
|
217
|
+
// This earlier asserted on status.skill_blockers/commit_blockers, which never
|
|
218
|
+
// contain todosBlockers output, so it passed against a completely unfixed
|
|
219
|
+
// engine. Call the function under test directly instead.
|
|
220
|
+
const minimal = adoptedFlow('minimal')
|
|
221
|
+
const standard = adoptedFlow('standard')
|
|
222
|
+
const specPassQualityFail = {
|
|
223
|
+
items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'fail' } } }],
|
|
224
|
+
}
|
|
225
|
+
assert.deepEqual(todosBlockers(specPassQualityFail, minimal), [],
|
|
226
|
+
'minimal asks for one verdict, so a missing quality verdict must not block')
|
|
227
|
+
assert.ok(todosBlockers(specPassQualityFail, standard).length > 0,
|
|
228
|
+
'the full flow still requires both verdicts')
|
|
229
|
+
|
|
230
|
+
// "Lighter" must mean fewer verdicts, never fewer checks.
|
|
231
|
+
const noReview = { items: [{ id: 'A', title: 'a', status: 'done' }] }
|
|
232
|
+
assert.ok(todosBlockers(noReview, minimal).length > 0,
|
|
233
|
+
'an unreviewed item must still block the lightest flow')
|
|
234
|
+
const specFail = {
|
|
235
|
+
items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'fail' }, quality: { outcome: 'pass' } } }],
|
|
236
|
+
}
|
|
237
|
+
assert.ok(todosBlockers(specFail, minimal).length > 0,
|
|
238
|
+
'a failing verdict must still block the lightest flow')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('P2: the three presets must not share one identical review burden', () => {
|
|
242
|
+
// The presets differ by task complexity, so their audit weight must differ too.
|
|
243
|
+
// Before this change all three returned byte-identical blockers for one state.
|
|
244
|
+
const state = {
|
|
245
|
+
items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'fail' } } }],
|
|
246
|
+
}
|
|
247
|
+
const byDepth = ['standard', 'agile', 'minimal'].map(id =>
|
|
248
|
+
todosBlockers(state, adoptedFlow(id)).length)
|
|
249
|
+
assert.ok(
|
|
250
|
+
byDepth[2] < byDepth[0],
|
|
251
|
+
`the fast-change flow must audit less per item than the full one; got ${JSON.stringify(byDepth)}`,
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------- P2: missing rule must surface
|
|
256
|
+
|
|
257
|
+
test('P2: a rule a skill declares but that resolves nowhere must be reported, not skipped', async () => {
|
|
258
|
+
// Resolution used to simply omit a name it could not find, so a binding silently
|
|
259
|
+
// stopped being disclosed. Rules now hang off the skill, so the reference to
|
|
260
|
+
// check lives on the skill binding.
|
|
261
|
+
const f = fixture('standard', '开发', {}, {})
|
|
262
|
+
const state = f.state()
|
|
263
|
+
state.flow.config.stage_bindings = {
|
|
264
|
+
开发: {
|
|
265
|
+
skills: [
|
|
266
|
+
{ skill: { source: 'bundled', name: 'code-implement' }, rules: [{ source: 'bundled', name: 'no-such-rule-anywhere' }] },
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
f.records.set(join(f.cwd, '.dsh/task-LIVE-1.json'), JSON.stringify(state))
|
|
271
|
+
const status = JSON.parse(await f.call({ operation: 'status' }))
|
|
272
|
+
const text = JSON.stringify(status)
|
|
273
|
+
assert.match(
|
|
274
|
+
text,
|
|
275
|
+
/no-such-rule-anywhere/u,
|
|
276
|
+
'a rule that resolves nowhere must appear in status rather than vanishing',
|
|
277
|
+
)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------- batch 3: rules belong to skills
|
|
281
|
+
|
|
282
|
+
test('batch3: a stage selects skills only, and a skill carries its own complete rule list', () => {
|
|
283
|
+
// The whole point of the model: opening a skill tells you every rule it runs
|
|
284
|
+
// under, and binding it anywhere does not add or override any rule.
|
|
285
|
+
const config = adoptedFlow('standard')
|
|
286
|
+
for (const [stage, binding] of Object.entries(config.stage_bindings ?? {})) {
|
|
287
|
+
assert.ok(
|
|
288
|
+
!('rules' in binding) || binding.rules === undefined,
|
|
289
|
+
`stage ${stage} must not carry a stage-level rule list`,
|
|
290
|
+
)
|
|
291
|
+
for (const entry of binding.skills ?? []) {
|
|
292
|
+
assert.ok(Array.isArray(entry.rules),
|
|
293
|
+
`skill ${entry.skill.name} on ${stage} must declare its own rules array`)
|
|
294
|
+
assert.ok(entry.skill.source === 'bundled',
|
|
295
|
+
`preset bindings are bundled resources; ${entry.skill.name} declares ${entry.skill.source}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('batch3: the same skill carries identical rules wherever it is bound', () => {
|
|
301
|
+
// "Same skill, same rules" is the acceptance criterion: copying the skill is how
|
|
302
|
+
// a user gets a different rule set, so the same reference must never differ.
|
|
303
|
+
const byRef = new Map()
|
|
304
|
+
for (const [id, preset] of Object.entries(FLOW_PRESETS)) {
|
|
305
|
+
for (const [stage, binding] of Object.entries(preset.config.stage_bindings ?? {})) {
|
|
306
|
+
for (const entry of binding.skills ?? []) {
|
|
307
|
+
const key = formatResourceRef(entry.skill)
|
|
308
|
+
const rules = entry.rules.map(formatResourceRef).sort().join(',')
|
|
309
|
+
if (byRef.has(key)) {
|
|
310
|
+
assert.equal(byRef.get(key).rules, rules,
|
|
311
|
+
`${key} carries different rules in ${byRef.get(key).where} and ${id}/${stage}`)
|
|
312
|
+
} else {
|
|
313
|
+
byRef.set(key, { rules, where: `${id}/${stage}` })
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('batch3: a rule shared by two skills is one resource, referenced twice', () => {
|
|
321
|
+
// Reuse must not duplicate the rule body: two skills referencing one rule point
|
|
322
|
+
// at the same source-qualified reference.
|
|
323
|
+
const config = adoptedFlow('standard')
|
|
324
|
+
const review = config.stage_bindings['代码审核'].skills
|
|
325
|
+
const reviewRules = review.find(entry => entry.skill.name === 'code-review').rules.map(formatResourceRef)
|
|
326
|
+
const implementRules = config.stage_bindings['开发'].skills
|
|
327
|
+
.find(entry => entry.skill.name === 'code-implement').rules.map(formatResourceRef)
|
|
328
|
+
const shared = reviewRules.filter(rule => implementRules.includes(rule))
|
|
329
|
+
assert.ok(shared.length > 0,
|
|
330
|
+
'coding-conventions and security-redlines apply to both implementing and reviewing code, so at least one rule must be shared by reference')
|
|
331
|
+
assert.ok(shared.includes('bundled:security-redlines'),
|
|
332
|
+
`security-redlines is the clear shared case; saw ${JSON.stringify(shared)}`)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('batch3: legacy stage-level rules stay in force and are named as unassigned', async () => {
|
|
336
|
+
// A pre-migration config put rules on the stage. Assigning them silently would
|
|
337
|
+
// invent an answer the config never had; dropping them would lose a constraint.
|
|
338
|
+
const f = fixture('standard', '开发', {}, {})
|
|
339
|
+
const state = f.state()
|
|
340
|
+
state.flow.config.stage_bindings = { 开发: { skills: [], legacy_rules: ['security-redlines'] } }
|
|
341
|
+
f.records.set(join(f.cwd, '.dsh/task-LIVE-1.json'), JSON.stringify(state))
|
|
342
|
+
const status = JSON.parse(await f.call({ operation: 'status' }))
|
|
343
|
+
assert.ok(
|
|
344
|
+
(status.unassigned_legacy_rules ?? []).includes('security-redlines'),
|
|
345
|
+
`the legacy rule must be reported as unassigned; saw ${JSON.stringify(status.unassigned_legacy_rules)}`,
|
|
346
|
+
)
|
|
347
|
+
// Still enforced: it resolves and appears among the rules in force.
|
|
348
|
+
assert.ok(
|
|
349
|
+
(status.rules ?? []).some(rule => rule.name === 'security-redlines'),
|
|
350
|
+
`a legacy rule must keep applying; saw ${JSON.stringify(status.rules)}`,
|
|
351
|
+
)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------- batch 2: completion
|
|
355
|
+
|
|
356
|
+
test('P2: reaching the last stage is not completion for any preset', () => {
|
|
357
|
+
// minimal's final stage is also its commit checkpoint, and the "commit before
|
|
358
|
+
// leaving a checkpoint" rule only fires on the way OUT of a stage — so that
|
|
359
|
+
// flow could stand on its last stage with no delivery record at all. Completion
|
|
360
|
+
// is now its own checked event, shared by all three presets.
|
|
361
|
+
for (const [preset, stage] of [['standard', '完成'], ['agile', '审查'], ['minimal', '交付']]) {
|
|
362
|
+
const config = adoptedFlow(preset)
|
|
363
|
+
const state = {
|
|
364
|
+
stage,
|
|
365
|
+
execution_version: 1,
|
|
366
|
+
items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
|
|
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' },
|
|
373
|
+
commits: [],
|
|
374
|
+
}
|
|
375
|
+
assert.ok(
|
|
376
|
+
completionBlockers(state, config).length > 0,
|
|
377
|
+
`${preset} must not complete without its delivery record`,
|
|
378
|
+
)
|
|
379
|
+
const committed = { ...state, commits: [{ label: 'TASK', hash: 'abc1234' }] }
|
|
380
|
+
assert.deepEqual(
|
|
381
|
+
completionBlockers(committed, config), [],
|
|
382
|
+
`${preset} must complete once the delivery record exists`,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('P2: completion refuses a task that has not reached a final stage', () => {
|
|
388
|
+
const config = adoptedFlow('standard')
|
|
389
|
+
const state = {
|
|
390
|
+
stage: '开发',
|
|
391
|
+
execution_version: 1,
|
|
392
|
+
items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
|
|
393
|
+
verification: { passed: true, evidence: [] },
|
|
394
|
+
commits: [{ label: 'TASK', hash: 'abc1234' }],
|
|
395
|
+
}
|
|
396
|
+
const blockers = completionBlockers(state, config)
|
|
397
|
+
assert.ok(blockers.some(b => /not a final stage/u.test(b)),
|
|
398
|
+
`an unfinished flow must not be completable; saw ${JSON.stringify(blockers)}`)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('P2: a flow may declare that its delivery mode needs no commit', () => {
|
|
402
|
+
// A Git commit is one delivery mode, not a universal finish line: a non-code
|
|
403
|
+
// task or a project outside version control has nothing to commit.
|
|
404
|
+
const withoutCommit = { ...adoptedFlow('minimal'), commit_required: false }
|
|
405
|
+
const state = {
|
|
406
|
+
stage: '交付',
|
|
407
|
+
execution_version: 1,
|
|
408
|
+
items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' } } }],
|
|
409
|
+
verification: { passed: true, evidence: [] },
|
|
410
|
+
commits: [],
|
|
411
|
+
}
|
|
412
|
+
assert.deepEqual(
|
|
413
|
+
completionBlockers(state, withoutCommit), [],
|
|
414
|
+
'a flow that does not require a commit must be completable without one',
|
|
415
|
+
)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('P2: an unknown review_depth is a configuration error, not a silent downgrade', () => {
|
|
419
|
+
const config = { ...adoptedFlow('minimal'), review_depth: 'thorough' }
|
|
420
|
+
const problems = validateWorkflow(config)
|
|
421
|
+
assert.ok(problems.some(p => /review_depth/u.test(p)),
|
|
422
|
+
`a typo must be reported; saw ${JSON.stringify(problems)}`)
|
|
423
|
+
})
|