@godv61/dsh-task-engine 0.24.0 → 0.26.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.
Files changed (60) hide show
  1. package/.acceptance.mjs +101 -39
  2. package/.assessment-batch1.mjs +32 -12
  3. package/.e2e-presets.mjs +40 -5
  4. package/.enforce-test.mjs +139 -0
  5. package/.evidence-test.mjs +118 -0
  6. package/.filter-test.mjs +93 -0
  7. package/.freeze-test.mjs +110 -1
  8. package/.hook-test.mjs +195 -190
  9. package/.p0-test.mjs +130 -46
  10. package/.preset-test.mjs +160 -160
  11. package/.revision-test.mjs +32 -3
  12. package/.roundtrip-test.mjs +178 -0
  13. package/.workflow-test.mjs +117 -4
  14. package/README.md +10 -8
  15. package/cordis.patch.yml +10 -10
  16. package/defaults/eng.json +70 -22
  17. package/docs/BRIEF-FOR-REVIEW.md +162 -162
  18. package/docs/CHANGELOG.md +49 -0
  19. package/docs/README.md +40 -40
  20. package/docs/configuration.md +14 -11
  21. package/docs/development.md +1 -1
  22. package/docs/faq.md +4 -4
  23. package/docs/listing/godv61__dsh-task-engine.yml +5 -5
  24. package/docs/listing/submission.md +83 -83
  25. package/docs/manual.html +31 -26
  26. package/docs/releases/0.23.2.md +21 -21
  27. package/docs/roadmap.md +34 -34
  28. package/docs/testing/0.23.2/R02/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +56 -56
  29. package/docs/testing/0.23.2/R03/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +38 -38
  30. package/docs/testing/0.23.2//346/265/213/350/257/225/346/212/245/345/221/212.md +65 -65
  31. package/hooks/commit-msg +159 -72
  32. package/lib/client.js +963 -571
  33. package/lib/client.js.map +3 -3
  34. package/lib/controller.d.ts +11 -2
  35. package/lib/controller.js +33 -5
  36. package/lib/controller.js.map +1 -1
  37. package/lib/dev-task.js +170 -28
  38. package/lib/dev-task.js.map +1 -1
  39. package/lib/engine.d.ts +105 -0
  40. package/lib/engine.js +93 -5
  41. package/lib/engine.js.map +1 -1
  42. package/lib/hook.js +17 -4
  43. package/lib/hook.js.map +1 -1
  44. package/lib/skill-audit.d.ts +14 -3
  45. package/lib/skill-audit.js +66 -3
  46. package/lib/skill-audit.js.map +1 -1
  47. package/lib/workflows.d.ts +78 -12
  48. package/lib/workflows.js +258 -94
  49. package/lib/workflows.js.map +1 -1
  50. package/package.json +9 -5
  51. package/preset/agent.cordis.yml +21 -21
  52. package/preset/enable.mjs +87 -87
  53. package/preset/persona.md +4 -4
  54. package/preset/preset.yml +1 -1
  55. package/rules/coding-conventions.md +6 -6
  56. package/rules/commit-conventions.md +6 -6
  57. package/rules/security-redlines.md +5 -5
  58. package/scripts/verify-dsh-compat.mjs +144 -144
  59. package/scripts/verify-package.mjs +1 -1
  60. package/skills/code-verify/SKILL.md +3 -3
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Catalog filtering: search and filters must narrow the list without ever hiding
3
+ * something that is currently in force.
4
+ *
5
+ * The panel shows the configuration; a filter that hid a bound skill would make the
6
+ * panel disagree with the config it is displaying, which is worse than a long list.
7
+ */
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { bindingsForStage, formatResourceRef } from './lib/engine.js'
11
+ import { adoptRecommendation, resolveFlow } from './lib/workflows.js'
12
+
13
+ /**
14
+ * The filter the panel applies, lifted so it can be tested without a DOM.
15
+ *
16
+ * Mirrors `visibleSkills` in TaskEngineSection: an entry that is bound or shipped
17
+ * always matches; otherwise the query must appear in its name, description or layer.
18
+ */
19
+ function visibleSkills(catalog, binding, presetSkills, query, onlySelected) {
20
+ const boundKeys = new Set((binding?.skills ?? []).map(entry => formatResourceRef(entry.skill)))
21
+ const presetKeys = new Set(presetSkills.map(entry => formatResourceRef(entry.skill)))
22
+ const needle = query.trim().toLowerCase()
23
+ return catalog.filter(entry => {
24
+ const raw = formatResourceRef(entry.ref)
25
+ const inForce = boundKeys.has(raw) || presetKeys.has(raw)
26
+ if (onlySelected && !inForce) return false
27
+ if (needle === '') return true
28
+ return inForce
29
+ || entry.name.toLowerCase().includes(needle)
30
+ || entry.description.toLowerCase().includes(needle)
31
+ || entry.sourceLabel.toLowerCase().includes(needle)
32
+ })
33
+ }
34
+
35
+ const catalog = [
36
+ { ref: { source: 'bundled', name: 'code-implement' }, name: 'code-implement', description: '实现', sourceLabel: '内置' },
37
+ { ref: { source: 'bundled', name: 'code-review' }, name: 'code-review', description: '评审变更', sourceLabel: '内置' },
38
+ { ref: { source: 'project', name: 'api-audit' }, name: 'api-audit', description: '审计接口契约', sourceLabel: '项目' },
39
+ { ref: { source: 'user', name: 'doc-writer' }, name: 'doc-writer', description: '写文档', sourceLabel: '用户' },
40
+ ]
41
+ const binding = { skills: [{ skill: { source: 'project', name: 'api-audit' }, rules: [] }] }
42
+
43
+ test('filter: no query shows everything', () => {
44
+ assert.equal(visibleSkills(catalog, binding, [], '', false).length, 4)
45
+ })
46
+
47
+ test('filter: the query narrows by name, description and layer', () => {
48
+ // `api-audit` is bound in this fixture, so it stays visible whatever the query —
49
+ // that rule is asserted separately, and these expectations include it.
50
+ const names = (...args) => visibleSkills(...args).map(e => e.name).sort()
51
+ assert.deepEqual(names(catalog, binding, [], 'writer', false), ['api-audit', 'doc-writer'],
52
+ 'name matches')
53
+ assert.deepEqual(names(catalog, binding, [], '写文档', false), ['api-audit', 'doc-writer'],
54
+ 'description matches')
55
+ assert.deepEqual(names(catalog, binding, [], '用户', false), ['api-audit', 'doc-writer'],
56
+ 'the layer label matches, so a user can list one layer')
57
+ assert.deepEqual(names(catalog, binding, [], '审计', false), ['api-audit'],
58
+ 'a bound skill matching by its own description is shown')
59
+ assert.deepEqual(names(catalog, binding, [], 'nothing-matches-this', false), ['api-audit'],
60
+ 'a query matching nothing still shows the skill that is in force')
61
+ })
62
+
63
+ test('filter: a BOUND skill is never hidden by the query', () => {
64
+ // The panel shows the configuration. A search that hid a skill currently in force
65
+ // would make the panel disagree with the config it is displaying.
66
+ const shown = visibleSkills(catalog, binding, [], 'doc', false)
67
+ assert.ok(shown.some(e => e.name === 'api-audit'),
68
+ 'the bound skill stays visible even though it does not match the query')
69
+ assert.ok(shown.some(e => e.name === 'doc-writer'), 'and the matching one is shown too')
70
+ })
71
+
72
+ test('filter: only-selected keeps bound and preset skills, drops the rest', () => {
73
+ const shown = visibleSkills(catalog, binding, [{ skill: { source: 'bundled', name: 'code-review' } }], '', true)
74
+ const names = shown.map(e => e.name).sort()
75
+ assert.deepEqual(names, ['api-audit', 'code-review'],
76
+ 'a bound skill and a preset-shipped one are both in force; the other two are not')
77
+ })
78
+
79
+ test('filter: only-selected combined with a query still keeps what is in force', () => {
80
+ const shown = visibleSkills(catalog, binding, [], 'doc', true)
81
+ assert.ok(shown.some(e => e.name === 'api-audit'),
82
+ 'the filter must not drop a bound skill even when a query is active')
83
+ assert.ok(!shown.some(e => e.name === 'code-implement'), 'an unbound non-matching skill is dropped')
84
+ })
85
+
86
+ test('filter: the shipped catalog is small enough that the default view shows it all', () => {
87
+ // Sanity: the initial view of a fresh project is the shipped set, unfiltered.
88
+ const adopted = adoptRecommendation('standard')
89
+ const resolved = resolveFlow('standard', adopted).config
90
+ const binding = bindingsForStage('开发', resolved)
91
+ assert.ok(binding !== undefined, 'the adopted config binds 开发')
92
+ assert.ok((binding.skills ?? []).length > 0)
93
+ })
package/.freeze-test.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  * running task used silently changed what that task was doing, and deleting one
6
6
  * made a configured constraint disappear without the task recording anything.
7
7
  */
8
+ import { adoptRecommendation } from './lib/workflows.js'
8
9
  import test from 'node:test'
9
10
  import assert from 'node:assert/strict'
10
11
  import { join, resolve } from 'node:path'
@@ -15,7 +16,10 @@ import { hashText } from './lib/snapshot.js'
15
16
  async function created(flow) {
16
17
  const cwd = resolve('freeze-project')
17
18
  const records = new Map([
18
- [join(cwd, '.dsh/eng.json'), JSON.stringify({ flow })],
19
+ // A project that adopted the shipped recommendation, which is what gives it
20
+ // skills and rules to freeze. `{flow}` alone adopts nothing and therefore has
21
+ // nothing to freeze — a legitimate state, but not the one under test.
22
+ [join(cwd, '.dsh/eng.json'), JSON.stringify(adoptRecommendation(flow))],
19
23
  [join(cwd, 'a.js'), 'source'],
20
24
  ])
21
25
  const events = []
@@ -87,4 +91,109 @@ test('freeze: every preset freezes a complete, non-empty set', async () => {
87
91
  assert.ok((state.flow.resources ?? []).length >= 3,
88
92
  `${flow} must freeze its resources; saw ${(state.flow.resources ?? []).length}`)
89
93
  }
94
+ })
95
+
96
+ /**
97
+ * A task carrying one project rule on a skill, parked at the stage that uses it.
98
+ *
99
+ * Built from the adopted recommendation so the binding is one the resolver really
100
+ * accepts, then given a project rule of its own — exactly what the workbench
101
+ * writes when a user edits a skill's rule list.
102
+ */
103
+ async function atDevelopmentWithProjectRule() {
104
+ const cwd = resolve('freeze-live')
105
+ const adopted = adoptRecommendation('standard')
106
+ const skills = adopted.stage_bindings['开发'].skills.map(entry => ({
107
+ ...entry,
108
+ rules: [...entry.rules, { source: 'project', name: 'mine' }],
109
+ }))
110
+ const project = { ...adopted, stage_bindings: { ...adopted.stage_bindings, '开发': { skills } } }
111
+ const records = new Map([
112
+ [join(cwd, '.dsh/eng.json'), JSON.stringify(project)],
113
+ [join(cwd, '.dsh/rules/mine.md'), 'ORIGINAL RULE'],
114
+ [join(cwd, 'a.js'), 'source'],
115
+ ])
116
+ let execute
117
+ const key = (p, options) => join(options?.cwd ?? cwd, p)
118
+ const fs = {
119
+ resolve: async (p, options) => ({ targetKey: key(p, options) }),
120
+ readText: async target => records.get(target.targetKey),
121
+ listDir: async () => [],
122
+ lstat: async (p, options) => records.has(key(p, options)) ? { version: 'v' } : undefined,
123
+ writeText: async (target, content) => { records.set(target.targetKey, content) },
124
+ }
125
+ const ctx = {
126
+ fs,
127
+ tools: { register(tool) { execute = tool.execute; return () => {} } },
128
+ get: name => name === 'sandboxPolicy'
129
+ ? { resolve: () => ({ mode: 'workspace-write', workspaceRoot: cwd, sessionId: 'f' }) }
130
+ : undefined,
131
+ }
132
+ registerDevTask(ctx)
133
+ const exec = { agent: { session: { id: 'f', header: { cwd }, snapshotEvents: () => [] } }, signal: new AbortController().signal }
134
+ const call = async args => execute(JSON.parse(JSON.stringify({ task_id: 'FZ-2', ...args })), exec)
135
+ await call({ operation: 'create', title: 'freeze', branch: 'main', files: ['a.js'] })
136
+ const state = JSON.parse(records.get(join(cwd, '.dsh/task-FZ-2.json')))
137
+ state.stage = '开发'
138
+ state.requirement_confirmed = true
139
+ state.solution_confirmed = true
140
+ records.set(join(cwd, '.dsh/task-FZ-2.json'), JSON.stringify(state))
141
+ return {
142
+ call,
143
+ records,
144
+ cwd,
145
+ rule: () => join(cwd, '.dsh/rules/mine.md'),
146
+ disclosed: async () => {
147
+ const status = JSON.parse(await call({ operation: 'status' }))
148
+ return { status, mine: status.bindings.rules.find(r => r.name === 'mine') }
149
+ },
150
+ }
151
+ }
152
+
153
+ test('freeze: a running task follows the FROZEN body after its source is edited', async () => {
154
+ // Freezing that only archives the content is not freezing: status and the stage
155
+ // disclosure used to read the live files, so editing a rule silently changed what
156
+ // a running task was told to follow — the instability the snapshot exists to stop.
157
+ const f = await atDevelopmentWithProjectRule()
158
+ const before = await f.disclosed()
159
+ assert.equal(before.mine?.content, 'ORIGINAL RULE', 'the frozen body is what gets disclosed')
160
+
161
+ f.records.set(f.rule(), 'CHANGED RULE')
162
+ const after = await f.disclosed()
163
+ assert.equal(after.mine?.content, 'ORIGINAL RULE',
164
+ 'an edit to the source must not change what the running task is told to follow')
165
+ assert.ok((after.status.stale_source_rules ?? []).some(r => r.includes('edited')),
166
+ 'the drift is reported so it is visible rather than silently authoritative')
167
+ })
168
+
169
+ test('freeze: a running task survives its source rule being deleted', async () => {
170
+ // The frozen copy exists precisely so a deleted file is not a missing
171
+ // constraint. Reporting it as missing would be wrong: the task has the body.
172
+ const f = await atDevelopmentWithProjectRule()
173
+ f.records.delete(f.rule())
174
+ const { status, mine } = await f.disclosed()
175
+ assert.equal(mine?.content, 'ORIGINAL RULE', 'the frozen body is still in force')
176
+ assert.deepEqual(status.missing_rules, [],
177
+ 'a frozen rule is not missing; the task already holds its content')
178
+ assert.ok((status.stale_source_rules ?? []).some(r => r.includes('deleted')),
179
+ 'the deletion is reported as drift')
180
+ })
181
+
182
+ test('freeze: an unfrozen task falls back to live resolution and reports a missing file', async () => {
183
+ // Tasks created before freezing have no snapshot. They must keep working, and a
184
+ // deleted rule must be reported as missing rather than silently unbound — the
185
+ // two paths are distinguishable and neither silently degrades into the other.
186
+ const f = await atDevelopmentWithProjectRule()
187
+ const state = JSON.parse(f.records.get(join(f.cwd, '.dsh/task-FZ-2.json')))
188
+ delete state.flow.resources
189
+ f.records.set(join(f.cwd, '.dsh/task-FZ-2.json'), JSON.stringify(state))
190
+
191
+ const live = await f.disclosed()
192
+ assert.equal(live.mine?.content, 'ORIGINAL RULE', 'an unfrozen task reads the live file')
193
+
194
+ f.records.delete(f.rule())
195
+ const { status, mine } = await f.disclosed()
196
+ assert.equal(mine, undefined, 'with no snapshot and no file, the rule cannot resolve')
197
+ assert.ok((status.missing_rules ?? []).includes('project:mine'),
198
+ 'it is reported as missing so the gate can block on it')
90
199
  })
package/.hook-test.mjs CHANGED
@@ -1,191 +1,196 @@
1
- /**
2
- * End-to-end test for the installed commit-msg hook.
3
- *
4
- * The P0 suite asserts on the hook SOURCE (that it disables path quoting, reads
5
- * NUL-separated paths, bundles the frozen snapshot). Those assertions catch a
6
- * hand-mirror regressing, but they cannot show the gate actually blocks a commit
7
- * — a hook that refuses everything, or nothing, passes every one of them.
8
- *
9
- * This runs the real bundled hook inside a throwaway git repository and asserts
10
- * both directions: an illegal commit is refused, a legal one proceeds.
11
- *
12
- * Run: `node --test .hook-test.mjs`
13
- * @module dsh-task-engine/.hook-test
14
- */
15
-
16
- import assert from 'node:assert/strict'
17
- import { execFileSync } from 'node:child_process'
18
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
19
- import { tmpdir } from 'node:os'
20
- import { join, resolve, sep } from 'node:path'
21
- import { test } from 'node:test'
22
-
23
- const HOOK = resolve('hooks/commit-msg')
24
-
25
- /**
26
- * Git runs a hook by handing it to its own POSIX shell, which resolves
27
- * `#!/usr/bin/env node` against PATH. On Windows a `node.cmd` shim on PATH is
28
- * not executable from that shell, so the installed hook is wrapped in a
29
- * launcher that names the very interpreter running this test. Without it the
30
- * suite would read as the gate refusing everything, when in truth the hook
31
- * never started.
32
- */
33
- const NODE = process.execPath.replace(/\\/gu, '/')
34
-
35
- /** Run a git command in `cwd`, returning stdout (throws on non-zero exit). */
36
- function git(cwd, args) {
37
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
38
- }
39
-
40
- /**
41
- * Run `git commit` and report whether the hook allowed it.
42
- * A refused commit surfaces as a non-zero exit whose stderr carries the gate's
43
- * own Chinese refusal banner.
44
- */
45
- function tryCommit(cwd, message) {
46
- try {
47
- execFileSync('git', ['commit', '-m', message], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
48
- return { allowed: true, stderr: '' }
49
- } catch (error) {
50
- return { allowed: false, stderr: String(error.stderr ?? '') }
51
- }
52
- }
53
-
54
- /** Build a throwaway repository with the real hook installed and one staged file. */
55
- function repo(name, task) {
56
- const root = mkdtempSync(join(tmpdir(), `engine-hook-${name}-`))
57
- git(root, ['init', '-q', '-b', 'main'])
58
- git(root, ['config', 'user.email', 'test@example.com'])
59
- git(root, ['config', 'user.name', 'Test'])
60
- git(root, ['config', 'commit.gpgsign', 'false'])
61
- mkdirSync(join(root, '.dsh'), { recursive: true })
62
- mkdirSync(join(root, 'src'), { recursive: true })
63
- // The gate body stays byte-identical to the shipped hook; only the shebang is
64
- // replaced, so the test exercises the real gate under a runnable interpreter.
65
- const gate = readFileSync(HOOK, 'utf8').replace(/^#![^\n]*\n/u, '')
66
- writeFileSync(join(root, '.git', 'hooks', 'commit-msg'), `#!${NODE}\n${gate}`)
67
- writeFileSync(join(root, '.git', 'hooks', 'package.json'), '{\n "type": "commonjs"\n}\n')
68
- writeFileSync(join(root, '.dsh', `task-${task.id}.json`), JSON.stringify(task, null, 2))
69
- writeFileSync(join(root, 'src', 'app.js'), 'console.log(1)\n')
70
- git(root, ['add', 'src/app.js'])
71
- return root
72
- }
73
-
74
- /**
75
- * A standard-flow task that has reached the commit checkpoint with a declared
76
- * scope. `代码审核` is the checkpoint stage, and the engine only permits a
77
- * commit there once that stage's OUTGOING guards already hold — so the fixture
78
- * must also satisfy the review artifact and the review verdict.
79
- */
80
- function approvedTask(overrides = {}) {
81
- return {
82
- schema: 1,
83
- id: 'HOOK-1',
84
- title: 'hook probe',
85
- branch: 'main',
86
- work_size: 'standard',
87
- risk_level: 'standard',
88
- stage: '代码审核',
89
- requirement_confirmed: true,
90
- solution_confirmed: true,
91
- items: [{ id: 'i1', title: 'do the thing', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
92
- verification: { passed: true, evidence: [] },
93
- review: { outcome: 'pass' },
94
- // The `代码审核 -> 完成` edge requires `review_passed` and `artifacts_present`.
95
- artifacts: { review: { conclusion: 'looks good', issues: 'none' } },
96
- files: ['src/app.js'],
97
- commits: [],
98
- flow: { flow: 'standard', version: 2, config: null },
99
- ...overrides,
100
- }
101
- }
102
-
103
- /** Resolve the standard preset's config the same way the hook does. */
104
- async function standardConfig() {
105
- const { resolveFlow } = await import('./lib/workflows.js')
106
- const resolved = resolveFlow('standard')
107
- assert.equal(resolved.ok, true, 'the built-in standard preset resolves')
108
- return resolved.config
109
- }
110
-
111
- test('a task at its checkpoint with the right message commits', async () => {
112
- const config = await standardConfig()
113
- const root = repo('allow', approvedTask({ flow: { flow: 'standard', version: 2, config } }))
114
- try {
115
- const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
116
- assert.equal(result.allowed, true, `expected the commit to proceed, stderr: ${result.stderr}`)
117
- } finally {
118
- assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep))
119
- rmSync(root, { recursive: true, force: true })
120
- }
121
- })
122
-
123
- test('a message that violates the configured pattern is refused', async () => {
124
- const config = await standardConfig()
125
- const root = repo('badmsg', approvedTask({ flow: { flow: 'standard', version: 2, config } }))
126
- try {
127
- const result = tryCommit(root, 'no task id here')
128
- assert.equal(result.allowed, false, 'a malformed summary must be refused')
129
- assert.match(result.stderr, /提交门禁拒绝/, 'the gate states its own refusal reason')
130
- } finally {
131
- rmSync(root, { recursive: true, force: true })
132
- }
133
- })
134
-
135
- test('a commit touching files outside the declared scope is refused', async () => {
136
- const config = await standardConfig()
137
- const root = repo('scope', approvedTask({ flow: { flow: 'standard', version: 2, config } }))
138
- try {
139
- writeFileSync(join(root, 'src', 'other.js'), 'console.log(2)\n')
140
- git(root, ['add', 'src/other.js'])
141
- const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
142
- assert.equal(result.allowed, false, 'an out-of-scope path must be refused')
143
- assert.match(result.stderr, /任务范围之外/, 'the refusal names the scope violation')
144
- } finally {
145
- rmSync(root, { recursive: true, force: true })
146
- }
147
- })
148
-
149
- test('a commit before the checkpoint stage is refused', async () => {
150
- const config = await standardConfig()
151
- const root = repo('stage', approvedTask({ stage: '开发', flow: { flow: 'standard', version: 2, config } }))
152
- try {
153
- const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
154
- assert.equal(result.allowed, false, 'committing before the checkpoint must be refused')
155
- assert.match(result.stderr, /提交门禁拒绝/, 'the gate states its refusal reason')
156
- } finally {
157
- rmSync(root, { recursive: true, force: true })
158
- }
159
- })
160
-
161
- test('a task whose frozen snapshot was edited is refused', async () => {
162
- const config = await standardConfig()
163
- const tampered = { ...config, start_stage: '开发' }
164
- const root = repo('tamper', approvedTask({
165
- flow: { flow: 'standard', version: 2, config: tampered, hash: 'not-the-real-hash' },
166
- }))
167
- try {
168
- const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
169
- assert.equal(result.allowed, false, 'a snapshot hash mismatch must be refused')
170
- assert.match(result.stderr, /hash 不匹配/, 'the refusal names the integrity failure')
171
- } finally {
172
- rmSync(root, { recursive: true, force: true })
173
- }
174
- })
175
-
176
- test('touching a sensitive path without a high-risk receipt is refused', async () => {
177
- const config = await standardConfig()
178
- const root = repo('sensitive', approvedTask({
179
- files: ['src/app.js', '.env'],
180
- flow: { flow: 'standard', version: 2, config },
181
- }))
182
- try {
183
- writeFileSync(join(root, '.env'), 'SECRET=1\n')
184
- git(root, ['add', '.env'])
185
- const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
186
- assert.equal(result.allowed, false, 'a sensitive path without a high-risk receipt must be refused')
187
- assert.match(result.stderr, /敏感路径/, 'the refusal names the sensitive path')
188
- } finally {
189
- rmSync(root, { recursive: true, force: true })
190
- }
1
+ /**
2
+ * End-to-end test for the installed commit-msg hook.
3
+ *
4
+ * The P0 suite asserts on the hook SOURCE (that it disables path quoting, reads
5
+ * NUL-separated paths, bundles the frozen snapshot). Those assertions catch a
6
+ * hand-mirror regressing, but they cannot show the gate actually blocks a commit
7
+ * — a hook that refuses everything, or nothing, passes every one of them.
8
+ *
9
+ * This runs the real bundled hook inside a throwaway git repository and asserts
10
+ * both directions: an illegal commit is refused, a legal one proceeds.
11
+ *
12
+ * Run: `node --test .hook-test.mjs`
13
+ * @module dsh-task-engine/.hook-test
14
+ */
15
+
16
+ import assert from 'node:assert/strict'
17
+ import { execFileSync } from 'node:child_process'
18
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join, resolve, sep } from 'node:path'
21
+ import { test } from 'node:test'
22
+
23
+ const HOOK = resolve('hooks/commit-msg')
24
+
25
+ /**
26
+ * Git runs a hook by handing it to its own POSIX shell, which resolves
27
+ * `#!/usr/bin/env node` against PATH. On Windows a `node.cmd` shim on PATH is
28
+ * not executable from that shell, so the installed hook is wrapped in a
29
+ * launcher that names the very interpreter running this test. Without it the
30
+ * suite would read as the gate refusing everything, when in truth the hook
31
+ * never started.
32
+ */
33
+ const NODE = process.execPath.replace(/\\/gu, '/')
34
+
35
+ /** Run a git command in `cwd`, returning stdout (throws on non-zero exit). */
36
+ function git(cwd, args) {
37
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
38
+ }
39
+
40
+ /**
41
+ * Run `git commit` and report whether the hook allowed it.
42
+ * A refused commit surfaces as a non-zero exit whose stderr carries the gate's
43
+ * own Chinese refusal banner.
44
+ */
45
+ function tryCommit(cwd, message) {
46
+ try {
47
+ execFileSync('git', ['commit', '-m', message], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
48
+ return { allowed: true, stderr: '' }
49
+ } catch (error) {
50
+ return { allowed: false, stderr: String(error.stderr ?? '') }
51
+ }
52
+ }
53
+
54
+ /** Build a throwaway repository with the real hook installed and one staged file. */
55
+ function repo(name, task) {
56
+ const root = mkdtempSync(join(tmpdir(), `engine-hook-${name}-`))
57
+ git(root, ['init', '-q', '-b', 'main'])
58
+ git(root, ['config', 'user.email', 'test@example.com'])
59
+ git(root, ['config', 'user.name', 'Test'])
60
+ git(root, ['config', 'commit.gpgsign', 'false'])
61
+ mkdirSync(join(root, '.dsh'), { recursive: true })
62
+ mkdirSync(join(root, 'src'), { recursive: true })
63
+ // The gate body stays byte-identical to the shipped hook; only the shebang is
64
+ // replaced, so the test exercises the real gate under a runnable interpreter.
65
+ const gate = readFileSync(HOOK, 'utf8').replace(/^#![^\n]*\n/u, '')
66
+ writeFileSync(join(root, '.git', 'hooks', 'commit-msg'), `#!${NODE}\n${gate}`)
67
+ writeFileSync(join(root, '.git', 'hooks', 'package.json'), '{\n "type": "commonjs"\n}\n')
68
+ writeFileSync(join(root, '.dsh', `task-${task.id}.json`), JSON.stringify(task, null, 2))
69
+ writeFileSync(join(root, 'src', 'app.js'), 'console.log(1)\n')
70
+ git(root, ['add', 'src/app.js'])
71
+ return root
72
+ }
73
+
74
+ /**
75
+ * A standard-flow task that has reached the commit checkpoint with a declared
76
+ * scope. `代码审核` is the checkpoint stage, and the engine only permits a
77
+ * commit there once that stage's OUTGOING guards already hold — so the fixture
78
+ * must also satisfy the review artifact and the review verdict.
79
+ */
80
+ function approvedTask(overrides = {}) {
81
+ return {
82
+ schema: 1,
83
+ id: 'HOOK-1',
84
+ title: 'hook probe',
85
+ branch: 'main',
86
+ work_size: 'standard',
87
+ risk_level: 'standard',
88
+ stage: '代码审核',
89
+ requirement_confirmed: true,
90
+ solution_confirmed: true,
91
+ items: [{ id: 'i1', title: 'do the thing', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
92
+ verification: { passed: true, evidence: [] },
93
+ review: { outcome: 'pass' },
94
+ // The `代码审核 -> 完成` edge requires `review_passed` and `artifacts_present`.
95
+ artifacts: { review: { conclusion: 'looks good', issues: 'none' } },
96
+ files: ['src/app.js'],
97
+ commits: [],
98
+ flow: { flow: 'standard', version: 2, config: null },
99
+ ...overrides,
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Resolve the standard preset's config the way a real project has it: the skeleton
105
+ * plus the shipped recommendation, adopted. The hook reads the project's config
106
+ * from disk, so a project that adopted the recommendation is what these tests
107
+ * describe — the bare skeleton has no commit rule or artifacts to check.
108
+ */
109
+ async function standardConfig() {
110
+ const { resolveFlow, adoptRecommendation } = await import('./lib/workflows.js')
111
+ const resolved = resolveFlow('standard', adoptRecommendation('standard'))
112
+ assert.equal(resolved.ok, true, 'the built-in standard preset resolves')
113
+ return resolved.config
114
+ }
115
+
116
+ test('a task at its checkpoint with the right message commits', async () => {
117
+ const config = await standardConfig()
118
+ const root = repo('allow', approvedTask({ flow: { flow: 'standard', version: 2, config } }))
119
+ try {
120
+ const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
121
+ assert.equal(result.allowed, true, `expected the commit to proceed, stderr: ${result.stderr}`)
122
+ } finally {
123
+ assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep))
124
+ rmSync(root, { recursive: true, force: true })
125
+ }
126
+ })
127
+
128
+ test('a message that violates the configured pattern is refused', async () => {
129
+ const config = await standardConfig()
130
+ const root = repo('badmsg', approvedTask({ flow: { flow: 'standard', version: 2, config } }))
131
+ try {
132
+ const result = tryCommit(root, 'no task id here')
133
+ assert.equal(result.allowed, false, 'a malformed summary must be refused')
134
+ assert.match(result.stderr, /提交门禁拒绝/, 'the gate states its own refusal reason')
135
+ } finally {
136
+ rmSync(root, { recursive: true, force: true })
137
+ }
138
+ })
139
+
140
+ test('a commit touching files outside the declared scope is refused', async () => {
141
+ const config = await standardConfig()
142
+ const root = repo('scope', approvedTask({ flow: { flow: 'standard', version: 2, config } }))
143
+ try {
144
+ writeFileSync(join(root, 'src', 'other.js'), 'console.log(2)\n')
145
+ git(root, ['add', 'src/other.js'])
146
+ const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
147
+ assert.equal(result.allowed, false, 'an out-of-scope path must be refused')
148
+ assert.match(result.stderr, /任务范围之外/, 'the refusal names the scope violation')
149
+ } finally {
150
+ rmSync(root, { recursive: true, force: true })
151
+ }
152
+ })
153
+
154
+ test('a commit before the checkpoint stage is refused', async () => {
155
+ const config = await standardConfig()
156
+ const root = repo('stage', approvedTask({ stage: '开发', flow: { flow: 'standard', version: 2, config } }))
157
+ try {
158
+ const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
159
+ assert.equal(result.allowed, false, 'committing before the checkpoint must be refused')
160
+ assert.match(result.stderr, /提交门禁拒绝/, 'the gate states its refusal reason')
161
+ } finally {
162
+ rmSync(root, { recursive: true, force: true })
163
+ }
164
+ })
165
+
166
+ test('a task whose frozen snapshot was edited is refused', async () => {
167
+ const config = await standardConfig()
168
+ const tampered = { ...config, start_stage: '开发' }
169
+ const root = repo('tamper', approvedTask({
170
+ flow: { flow: 'standard', version: 2, config: tampered, hash: 'not-the-real-hash' },
171
+ }))
172
+ try {
173
+ const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
174
+ assert.equal(result.allowed, false, 'a snapshot hash mismatch must be refused')
175
+ assert.match(result.stderr, /hash 不匹配/, 'the refusal names the integrity failure')
176
+ } finally {
177
+ rmSync(root, { recursive: true, force: true })
178
+ }
179
+ })
180
+
181
+ test('touching a sensitive path without a high-risk receipt is refused', async () => {
182
+ const config = await standardConfig()
183
+ const root = repo('sensitive', approvedTask({
184
+ files: ['src/app.js', '.env'],
185
+ flow: { flow: 'standard', version: 2, config },
186
+ }))
187
+ try {
188
+ writeFileSync(join(root, '.env'), 'SECRET=1\n')
189
+ git(root, ['add', '.env'])
190
+ const result = tryCommit(root, '【HOOK-1】【TASK】probe the hook')
191
+ assert.equal(result.allowed, false, 'a sensitive path without a high-risk receipt must be refused')
192
+ assert.match(result.stderr, /敏感路径/, 'the refusal names the sensitive path')
193
+ } finally {
194
+ rmSync(root, { recursive: true, force: true })
195
+ }
191
196
  })