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