@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.
@@ -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
+ })