@godv61/dsh-task-engine 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.acceptance.mjs +150 -150
- package/.assessment-batch1.mjs +20 -20
- package/.enforce-test.mjs +138 -138
- package/.evidence-test.mjs +118 -119
- package/.filter-test.mjs +92 -92
- package/.hook-test.mjs +195 -195
- package/.p0-test.mjs +59 -59
- package/.preset-test.mjs +160 -160
- package/.revision-test.mjs +30 -16
- package/.roundtrip-test.mjs +178 -63
- package/.workflow-test.mjs +86 -0
- package/README.md +8 -8
- package/cordis.patch.yml +10 -10
- package/defaults/eng.json +70 -22
- package/docs/BRIEF-FOR-REVIEW.md +162 -162
- package/docs/CHANGELOG.md +18 -0
- package/docs/README.md +40 -40
- package/docs/configuration.md +14 -9
- package/docs/development.md +1 -1
- package/docs/faq.md +4 -4
- package/docs/listing/godv61__dsh-task-engine.yml +5 -5
- package/docs/listing/submission.md +83 -83
- package/docs/manual.html +32 -27
- package/docs/releases/0.23.2.md +21 -21
- package/docs/roadmap.md +34 -34
- 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
- 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
- package/docs/testing/0.23.2//346/265/213/350/257/225/346/212/245/345/221/212.md +65 -65
- package/hooks/commit-msg +70 -1
- package/lib/client.js +870 -571
- package/lib/client.js.map +3 -3
- package/lib/controller.d.ts +2 -1
- package/lib/controller.js +6 -3
- package/lib/controller.js.map +1 -1
- package/lib/dev-task.js +83 -15
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +15 -0
- package/lib/engine.js +29 -5
- package/lib/engine.js.map +1 -1
- package/lib/hook.js +3 -0
- package/lib/hook.js.map +1 -1
- package/lib/skill-audit.js +3 -3
- package/lib/skill-audit.js.map +1 -1
- package/lib/workflows.d.ts +4 -1
- package/lib/workflows.js +90 -2
- package/lib/workflows.js.map +1 -1
- package/package.json +1 -1
- package/preset/agent.cordis.yml +22 -22
- package/preset/enable.mjs +87 -87
- package/preset/persona.md +5 -5
- package/preset/preset.yml +1 -1
- package/rules/coding-conventions.md +6 -6
- package/rules/commit-conventions.md +6 -6
- package/rules/security-redlines.md +5 -5
- package/scripts/verify-dsh-compat.mjs +144 -144
- package/skills/code-implement/SKILL.md +1 -1
- package/skills/code-verify/SKILL.md +3 -3
- package/skills/eng-delivery/SKILL.md +10 -31
package/.hook-test.mjs
CHANGED
|
@@ -1,196 +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
|
-
/**
|
|
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
|
-
}
|
|
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
|
+
}
|
|
196
196
|
})
|
package/.p0-test.mjs
CHANGED
|
@@ -123,12 +123,12 @@ assert(!flowSatisfies('minimal', HIGH_RISK_REQUIRED_CAPABILITIES), 'minimal lack
|
|
|
123
123
|
// separate recommendation a user adopts. The version number is what makes an
|
|
124
124
|
// already-created task keep its frozen config instead of silently changing.
|
|
125
125
|
assert(FLOW_PRESETS.standard.version === 3, 'standard preset carries the skeleton/recommendation version')
|
|
126
|
-
// Versions are per-preset because they record per-preset changes: agile moved
|
|
127
|
-
// again when 审查 gained a completion guard, and a task created before that
|
|
128
|
-
// keeps its frozen version and behaves exactly as it did.
|
|
129
|
-
assert(FLOW_PRESETS.standard.version === 3, 'standard carries the skeleton/recommendation version')
|
|
130
|
-
assert(FLOW_PRESETS.minimal.version === 3, 'minimal carries the skeleton/recommendation version')
|
|
131
|
-
assert(FLOW_PRESETS.agile.version === 4, 'agile moved again for the completion guard')
|
|
126
|
+
// Versions are per-preset because they record per-preset changes: agile moved
|
|
127
|
+
// again when 审查 gained a completion guard, and a task created before that
|
|
128
|
+
// keeps its frozen version and behaves exactly as it did.
|
|
129
|
+
assert(FLOW_PRESETS.standard.version === 3, 'standard carries the skeleton/recommendation version')
|
|
130
|
+
assert(FLOW_PRESETS.minimal.version === 3, 'minimal carries the skeleton/recommendation version')
|
|
131
|
+
assert(FLOW_PRESETS.agile.version === 4, 'agile moved again for the completion guard')
|
|
132
132
|
|
|
133
133
|
// ── 2. unknown flow fails closed ───────────────────────────────────────────
|
|
134
134
|
// `resolveFlow` is called directly here: this test is about the fail-closed
|
|
@@ -260,59 +260,59 @@ await assertThrows(
|
|
|
260
260
|
)
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
// ── 11. a bound rule resolves from the layer the reference names ────────────
|
|
264
|
-
// Rules used to be looked up by bare name through a precedence chain, so a
|
|
265
|
-
// project file named like a bundled one silently replaced the shipped body.
|
|
266
|
-
// A reference now names its layer (`bundled:` / `project:` / `user:`), so there
|
|
267
|
-
// is no chain to walk and no shadowing to defend against: the same name in two
|
|
268
|
-
// layers is two distinct resources, and the binding says which one it means.
|
|
269
|
-
{
|
|
270
|
-
const bundled = readFileSync('./rules/security-redlines.md', 'utf8')
|
|
271
|
-
assert(bundled.includes('服务端'), 'bundled security-redlines exists as expected')
|
|
272
|
-
const fs = makeFs({
|
|
273
|
-
// A project file with the same NAME but a different body. Under the old
|
|
274
|
-
// bare-name lookup this would have replaced the bundled rule silently.
|
|
275
|
-
'.dsh/eng.json': JSON.stringify({
|
|
276
|
-
flow: 'standard',
|
|
277
|
-
stage_bindings: {
|
|
278
|
-
'需求评审': { skills: [{
|
|
279
|
-
skill: { source: 'bundled', name: 'requirement-analysis' },
|
|
280
|
-
rules: [{ source: 'project', name: 'security-redlines' }],
|
|
281
|
-
}] },
|
|
282
|
-
},
|
|
283
|
-
}),
|
|
284
|
-
'.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
|
|
285
|
-
})
|
|
286
|
-
const exe = await registered(fs)
|
|
287
|
-
await exe({ operation: 'create', task_id: 'T9', title: 'x', branch: 'main' }, EXEC)
|
|
288
|
-
const status = JSON.parse(await exe({ operation: 'status', task_id: 'T9' }, EXEC))
|
|
289
|
-
const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
|
|
290
|
-
assert(sec !== undefined, 'the bound project rule is disclosed at the start stage')
|
|
291
|
-
assert(sec.source === 'project', 'the disclosed rule reports the layer it was resolved from')
|
|
292
|
-
assert(sec.content.includes('前端校验即可'), 'the PROJECT body is what gets disclosed, because the reference names the project layer')
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// ── 11b. a bundled reference keeps resolving the bundled body ───────────────
|
|
296
|
-
{
|
|
297
|
-
const fs = makeFs({
|
|
298
|
-
'.dsh/eng.json': JSON.stringify({
|
|
299
|
-
flow: 'standard',
|
|
300
|
-
stage_bindings: {
|
|
301
|
-
'需求评审': { skills: [{
|
|
302
|
-
skill: { source: 'bundled', name: 'requirement-analysis' },
|
|
303
|
-
rules: [{ source: 'bundled', name: 'security-redlines' }],
|
|
304
|
-
}] },
|
|
305
|
-
},
|
|
306
|
-
}),
|
|
307
|
-
'.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
|
|
308
|
-
})
|
|
309
|
-
const exe = await registered(fs)
|
|
310
|
-
await exe({ operation: 'create', task_id: 'T10', title: 'x', branch: 'main' }, EXEC)
|
|
311
|
-
const status = JSON.parse(await exe({ operation: 'status', task_id: 'T10' }, EXEC))
|
|
312
|
-
const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
|
|
313
|
-
assert(sec !== undefined && sec.source === 'bundled', 'a bundled reference resolves to the bundled layer')
|
|
314
|
-
assert(sec.content.includes('服务端'), 'the bundled body is used, untouched by the same-named project file')
|
|
315
|
-
}
|
|
263
|
+
// ── 11. a bound rule resolves from the layer the reference names ────────────
|
|
264
|
+
// Rules used to be looked up by bare name through a precedence chain, so a
|
|
265
|
+
// project file named like a bundled one silently replaced the shipped body.
|
|
266
|
+
// A reference now names its layer (`bundled:` / `project:` / `user:`), so there
|
|
267
|
+
// is no chain to walk and no shadowing to defend against: the same name in two
|
|
268
|
+
// layers is two distinct resources, and the binding says which one it means.
|
|
269
|
+
{
|
|
270
|
+
const bundled = readFileSync('./rules/security-redlines.md', 'utf8')
|
|
271
|
+
assert(bundled.includes('服务端'), 'bundled security-redlines exists as expected')
|
|
272
|
+
const fs = makeFs({
|
|
273
|
+
// A project file with the same NAME but a different body. Under the old
|
|
274
|
+
// bare-name lookup this would have replaced the bundled rule silently.
|
|
275
|
+
'.dsh/eng.json': JSON.stringify({
|
|
276
|
+
flow: 'standard',
|
|
277
|
+
stage_bindings: {
|
|
278
|
+
'需求评审': { skills: [{
|
|
279
|
+
skill: { source: 'bundled', name: 'requirement-analysis' },
|
|
280
|
+
rules: [{ source: 'project', name: 'security-redlines' }],
|
|
281
|
+
}] },
|
|
282
|
+
},
|
|
283
|
+
}),
|
|
284
|
+
'.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
|
|
285
|
+
})
|
|
286
|
+
const exe = await registered(fs)
|
|
287
|
+
await exe({ operation: 'create', task_id: 'T9', title: 'x', branch: 'main' }, EXEC)
|
|
288
|
+
const status = JSON.parse(await exe({ operation: 'status', task_id: 'T9' }, EXEC))
|
|
289
|
+
const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
|
|
290
|
+
assert(sec !== undefined, 'the bound project rule is disclosed at the start stage')
|
|
291
|
+
assert(sec.source === 'project', 'the disclosed rule reports the layer it was resolved from')
|
|
292
|
+
assert(sec.content.includes('前端校验即可'), 'the PROJECT body is what gets disclosed, because the reference names the project layer')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── 11b. a bundled reference keeps resolving the bundled body ───────────────
|
|
296
|
+
{
|
|
297
|
+
const fs = makeFs({
|
|
298
|
+
'.dsh/eng.json': JSON.stringify({
|
|
299
|
+
flow: 'standard',
|
|
300
|
+
stage_bindings: {
|
|
301
|
+
'需求评审': { skills: [{
|
|
302
|
+
skill: { source: 'bundled', name: 'requirement-analysis' },
|
|
303
|
+
rules: [{ source: 'bundled', name: 'security-redlines' }],
|
|
304
|
+
}] },
|
|
305
|
+
},
|
|
306
|
+
}),
|
|
307
|
+
'.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
|
|
308
|
+
})
|
|
309
|
+
const exe = await registered(fs)
|
|
310
|
+
await exe({ operation: 'create', task_id: 'T10', title: 'x', branch: 'main' }, EXEC)
|
|
311
|
+
const status = JSON.parse(await exe({ operation: 'status', task_id: 'T10' }, EXEC))
|
|
312
|
+
const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
|
|
313
|
+
assert(sec !== undefined && sec.source === 'bundled', 'a bundled reference resolves to the bundled layer')
|
|
314
|
+
assert(sec.content.includes('服务端'), 'the bundled body is used, untouched by the same-named project file')
|
|
315
|
+
}
|
|
316
316
|
|
|
317
317
|
// ── 12. session cwd drives every relative path (init regression) ──────────
|
|
318
318
|
{
|