@godv61/dsh-task-engine 0.29.2 → 0.29.8

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 (62) hide show
  1. package/.adaptive-test.mjs +302 -149
  2. package/.codex-project-test.mjs +18 -18
  3. package/.evidence-test.mjs +16 -3
  4. package/.hook-test.mjs +19 -19
  5. package/.resource-test.mjs +19 -2
  6. package/.roundtrip-test.mjs +8 -3
  7. package/.sonar-credential-test.mjs +38 -0
  8. package/.sonarlint-local-test.mjs +65 -0
  9. package/.workflow-test.mjs +29 -12
  10. package/README.md +22 -22
  11. package/docs/CHANGELOG.md +33 -14
  12. package/docs/README.md +6 -6
  13. package/docs/adaptive-workflows.md +85 -71
  14. package/docs/configuration.md +4 -4
  15. package/docs/faq.md +21 -11
  16. package/docs/getting-started.md +9 -9
  17. package/docs/manual-legacy.html +1 -1
  18. package/docs/manual.html +124 -116
  19. package/docs/roadmap.md +7 -7
  20. package/hooks/commit-msg +1 -1
  21. package/lib/adaptive.js +1 -1
  22. package/lib/adaptive.js.map +1 -1
  23. package/lib/client.js +337 -20
  24. package/lib/client.js.map +2 -2
  25. package/lib/controller.d.ts +35 -0
  26. package/lib/controller.js +68 -0
  27. package/lib/controller.js.map +1 -1
  28. package/lib/dev-task.d.ts +2 -0
  29. package/lib/dev-task.js +261 -31
  30. package/lib/dev-task.js.map +1 -1
  31. package/lib/engine.d.ts +1 -1
  32. package/lib/hook.js +3 -3
  33. package/lib/hook.js.map +1 -1
  34. package/lib/project-init.d.ts +4 -0
  35. package/lib/project-init.js +21 -2
  36. package/lib/project-init.js.map +1 -1
  37. package/lib/skill-audit.js +9 -1
  38. package/lib/skill-audit.js.map +1 -1
  39. package/lib/sonar-credential.d.ts +17 -0
  40. package/lib/sonar-credential.js +17 -0
  41. package/lib/sonar-credential.js.map +1 -0
  42. package/lib/sonar-report.d.ts +4 -0
  43. package/lib/sonar-report.js +40 -0
  44. package/lib/sonar-report.js.map +1 -0
  45. package/lib/sonar.d.ts +23 -2
  46. package/lib/sonar.js +55 -4
  47. package/lib/sonar.js.map +1 -1
  48. package/lib/sonarlint-local.d.ts +15 -0
  49. package/lib/sonarlint-local.js +307 -0
  50. package/lib/sonarlint-local.js.map +1 -0
  51. package/package.json +9 -7
  52. package/preset/enable.mjs +2 -2
  53. package/preset/persona.md +4 -4
  54. package/scripts/verify-dsh-compat.mjs +14 -14
  55. package/scripts/verify-package.mjs +7 -7
  56. package/skills/architecture-design/SKILL.md +11 -11
  57. package/skills/code-development/SKILL.md +11 -11
  58. package/skills/code-review/SKILL.md +11 -11
  59. package/skills/eng-delivery/SKILL.md +6 -4
  60. package/skills/requirements-analysis/SKILL.md +11 -11
  61. package/skills/task-orchestration/SKILL.md +11 -11
  62. package/skills/test-validation/SKILL.md +11 -11
@@ -1,189 +1,342 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { join, resolve } from 'node:path'
4
- import { mkdtempSync, rmSync } from 'node:fs'
5
- import { tmpdir } from 'node:os'
6
- import { adaptiveWorkflow, COMPLEXITY_OPTIONS } from './lib/adaptive.js'
7
- import { validateWorkflow } from './lib/engine.js'
8
- import { registerDevTask } from './lib/dev-task.js'
9
- import { inspectSonar } from './lib/sonar.js'
10
- import Controller from './lib/controller.js'
11
-
12
- const home = mkdtempSync(join(tmpdir(), 'dsh-adaptive-'))
13
- process.env.DSH_HOME = home
14
- process.on('exit', () => rmSync(home, { recursive: true, force: true }))
15
-
16
- function fixture(extra = {}) {
17
- const cwd = resolve('adaptive-project')
18
- const files = new Map([[join(cwd, 'package.json'), '{"name":"adaptive-project","dependencies":{"vue":"^2.7.0"}}'],
19
- [join(cwd, '.git/HEAD'), 'ref: refs/heads/feature'] , ...Object.entries(extra).map(([p, content]) => [join(cwd, p), content])])
20
- const events = []
21
- const session = { id: 'adaptive', header: { cwd }, snapshotEvents: () => events }
22
- const key = (path, options) => join(options?.cwd ?? cwd, path)
23
- let execute
24
- const fs = {
25
- resolve: async (path, options) => ({ path: key(path, options) }),
26
- readText: async target => files.get(target.path),
27
- lstat: async (path, options) => files.has(key(path, options)) ? { version: 'v1' } : undefined,
28
- writeText: async (target, content, intent) => {
29
- if (intent?.kind === 'createIfAbsent' && files.has(target.path)) throw new Error('already exists')
30
- files.set(target.path, content)
31
- },
32
- listDir: async target => [...new Set([...files.keys()].filter(path => path.startsWith(target.path + '\\'))
33
- .map(path => path.slice(target.path.length + 1).split('\\')[0]))].map(name => ({ name })),
34
- }
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { join, resolve } from 'node:path'
4
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { adaptiveWorkflow, COMPLEXITY_OPTIONS } from './lib/adaptive.js'
7
+ import { validateWorkflow } from './lib/engine.js'
8
+ import { plannedItemIds, registerDevTask } from './lib/dev-task.js'
9
+ import { assertLocalSonarReady, inspectSonar, validLocalScanCommand } from './lib/sonar.js'
10
+ import Controller from './lib/controller.js'
11
+
12
+ const home = mkdtempSync(join(tmpdir(), 'dsh-adaptive-'))
13
+ process.env.DSH_HOME = home
14
+ process.on('exit', () => rmSync(home, { recursive: true, force: true }))
15
+
16
+ function fixture(extra = {}, services = {}) {
17
+ const cwd = resolve('adaptive-project')
18
+ const files = new Map([[join(cwd, 'package.json'), '{"name":"adaptive-project","dependencies":{"vue":"^2.7.0"}}'],
19
+ [join(cwd, '.git/HEAD'), 'ref: refs/heads/feature'] , ...Object.entries(extra).map(([p, content]) => [join(cwd, p), content])])
20
+ const events = []
21
+ const session = { id: 'adaptive', header: { cwd }, snapshotEvents: () => events }
22
+ const key = (path, options) => join(options?.cwd ?? cwd, path)
23
+ let execute
24
+ const fs = {
25
+ resolve: async (path, options) => ({ path: key(path, options) }),
26
+ readText: async target => files.get(target.path),
27
+ lstat: async (path, options) => files.has(key(path, options)) ? { version: 'v1' } : undefined,
28
+ writeText: async (target, content, intent) => {
29
+ if (intent?.kind === 'createIfAbsent' && files.has(target.path)) throw new Error('already exists')
30
+ files.set(target.path, content)
31
+ },
32
+ listDir: async target => [...new Set([...files.keys()].filter(path => path.startsWith(target.path + '\\'))
33
+ .map(path => path.slice(target.path.length + 1).split('\\')[0]))].map(name => ({ name })),
34
+ }
35
35
  const ctx = { fs, tools: { register(tool) { execute = tool.execute; return () => {} } }, get(name) {
36
+ if (name in services) return services[name]
36
37
  if (name === 'sandboxPolicy') return { resolve: () => ({ mode: 'workspace-write', workspaceRoot: cwd, sessionId: session.id }) }
37
- return undefined
38
- } }
39
- registerDevTask(ctx)
40
- const exec = { agent: { session }, signal: new AbortController().signal }
41
- return { cwd, files, fs, events, call: args => execute(args, exec), state: id => JSON.parse(files.get(join(cwd, `.dsh/task-${id}.json`))) }
42
- }
43
-
38
+ return undefined
39
+ } }
40
+ registerDevTask(ctx)
41
+ const exec = { agent: { session }, signal: new AbortController().signal }
42
+ return { cwd, files, fs, events, call: args => execute(args, exec), state: id => JSON.parse(files.get(join(cwd, `.dsh/task-${id}.json`))) }
43
+ }
44
+
44
45
  test('each task grade has a valid and increasing meta-skill sequence', () => {
45
- assert.deepEqual(COMPLEXITY_OPTIONS.map(option => option.id), ['low', 'medium', 'high', 'ultra'])
46
- const configs = COMPLEXITY_OPTIONS.map(option => adaptiveWorkflow(option.id))
47
- for (const config of configs) assert.deepEqual(validateWorkflow(config), [])
48
- assert.deepEqual(configs.map(config => config.stages.length), [4, 5, 6, 7])
49
- assert.ok(configs[2].stages.includes('任务编排'))
50
- assert.ok(configs[3].stages.includes('架构设计'))
51
- assert.ok(configs.every(config => config.transitions.some(edge => edge.requires?.includes('verified'))))
46
+ assert.deepEqual(COMPLEXITY_OPTIONS.map(option => option.id), ['low', 'medium', 'high', 'ultra'])
47
+ const configs = COMPLEXITY_OPTIONS.map(option => adaptiveWorkflow(option.id))
48
+ for (const config of configs) assert.deepEqual(validateWorkflow(config), [])
49
+ assert.deepEqual(configs.map(config => config.stages.length), [4, 5, 6, 7])
50
+ assert.ok(configs[2].stages.includes('任务编排'))
51
+ assert.ok(configs[3].stages.includes('架构设计'))
52
+ assert.ok(configs.every(config => config.transitions.some(edge => edge.requires?.includes('verified'))))
52
53
  assert.ok(configs.every(config => config.commit.checkpoints.includes('测试')))
53
- })
54
-
55
- test('adaptive task freezes a project override and its own Rules, not the bundled same-name Skill', async () => {
56
- const f = fixture({
57
- '.dsh/skills/requirements-analysis/SKILL.md': '# Project requirement method',
58
- '.dsh/skills/requirements-analysis/profile.json': JSON.stringify({ rules: [{ source: 'project', name: 'team-scope' }], evidence: 'artifact' }),
59
- '.dsh/rules/team-scope.md': '# Team scope rule',
60
- '.dsh/meta.json': JSON.stringify({ meta_bindings: { 'code-development': ['adaptive-project-project-map'] } }),
61
- '.dsh/skills/adaptive-project-project-map/SKILL.md': '# Project map',
62
- '.dsh/skills/adaptive-project-project-map/profile.json': JSON.stringify({ rules: [], evidence: 'none' }),
63
- })
64
- const preview = JSON.parse(await f.call({ operation: 'assess', complexity: 'high' }))
65
- assert.equal(preview.meta_skills['需求分析'][0].effective_source, 'project')
66
- assert.ok(preview.meta_skills['代码开发'].some(skill => skill.logical_name === 'adaptive-project-project-map'))
67
- await f.call({ operation: 'create', task_id: 'A-1', title: 'cross-module change', branch: 'feature',
68
- complexity: 'high', complexity_reason: 'Several implementation steps depend on earlier interfaces', files: ['app.js'] })
69
- const state = f.state('A-1')
70
- assert.equal(state.flow.flow, 'adaptive-high')
71
- assert.equal(state.flow.config.stage_bindings['需求分析'].skills[0].skill.source, 'project')
72
- assert.ok(state.flow.resources.some(entry => entry.ref.name === 'team-scope' && entry.ref.source === 'project'))
73
- assert.match(await f.call({ operation: 'load_skill', task_id: 'A-1', skill_name: 'project:requirements-analysis' }), /Project requirement method/)
74
- await assert.rejects(f.call({ operation: 'load_skill', task_id: 'A-1', skill_name: 'bundled:requirements-analysis' }), /exact source:name/)
75
- })
76
-
54
+ })
55
+
56
+ test('adaptive task freezes a project override and its own Rules, not the bundled same-name Skill', async () => {
57
+ const f = fixture({
58
+ '.dsh/skills/requirements-analysis/SKILL.md': '# Project requirement method',
59
+ '.dsh/skills/requirements-analysis/profile.json': JSON.stringify({ rules: [{ source: 'project', name: 'team-scope' }], evidence: 'artifact' }),
60
+ '.dsh/rules/team-scope.md': '# Team scope rule',
61
+ '.dsh/meta.json': JSON.stringify({ meta_bindings: { 'code-development': ['adaptive-project-project-map'] } }),
62
+ '.dsh/skills/adaptive-project-project-map/SKILL.md': '# Project map',
63
+ '.dsh/skills/adaptive-project-project-map/profile.json': JSON.stringify({ rules: [], evidence: 'none' }),
64
+ })
65
+ const preview = JSON.parse(await f.call({ operation: 'assess', complexity: 'high' }))
66
+ assert.equal(preview.meta_skills['需求分析'][0].effective_source, 'project')
67
+ assert.ok(preview.meta_skills['代码开发'].some(skill => skill.logical_name === 'adaptive-project-project-map'))
68
+ await f.call({ operation: 'create', task_id: 'A-1', title: 'cross-module change', branch: 'feature',
69
+ complexity: 'high', complexity_reason: 'Several implementation steps depend on earlier interfaces', files: ['app.js'] })
70
+ const state = f.state('A-1')
71
+ assert.equal(state.flow.flow, 'adaptive-high')
72
+ assert.equal(state.flow.config.stage_bindings['需求分析'].skills[0].skill.source, 'project')
73
+ assert.ok(state.flow.resources.some(entry => entry.ref.name === 'team-scope' && entry.ref.source === 'project'))
74
+ assert.match(await f.call({ operation: 'load_skill', task_id: 'A-1', skill_name: 'project:requirements-analysis' }), /Project requirement method/)
75
+ await assert.rejects(f.call({ operation: 'load_skill', task_id: 'A-1', skill_name: 'bundled:requirements-analysis' }), /exact source:name/)
76
+ })
77
+
77
78
  test('init_project proposes project-specific Skills and binds reviewed resources', async () => {
78
- const f = fixture()
79
+ const f = fixture()
79
80
  const inventory = JSON.parse(await f.call({ operation: 'init_project', phase: 'inspect' }))
80
- assert.ok(inventory.suggestions.some(entry => entry.name === 'adaptive-project-project-map'))
81
- assert.ok(inventory.manifests.some(entry => entry.facts.includes('vue=^2.7.0')))
82
- const resources = [
83
- { kind: 'rule', name: 'component-boundary', content: 'Keep component boundary calls behind the existing service interface.' },
84
- { kind: 'skill', name: 'adaptive-project-project-map', description: 'Project structure and entry points',
85
- content: 'The root package.json declares Vue 2.7; inspect src for the actual component layout.',
86
- meta_skills: ['requirements-analysis', 'code-development'], rules: ['component-boundary'] },
87
- ]
81
+ const projectMap = inventory.suggestions.find(entry => entry.name === 'adaptive-project-project-map')
82
+ assert.ok(projectMap)
83
+ assert.match(projectMap.why, /entire repository/)
84
+ assert.match(projectMap.why, /current feature request/)
85
+ assert.match(inventory.caution, /repository-wide/)
86
+ assert.ok(inventory.manifests.some(entry => entry.facts.includes('vue=^2.7.0')))
87
+ const resources = [
88
+ { kind: 'rule', name: 'component-boundary', content: 'Keep component boundary calls behind the existing service interface.' },
89
+ { kind: 'skill', name: 'adaptive-project-project-map', description: 'Project structure and entry points',
90
+ content: 'The root package.json declares Vue 2.7; inspect src for the actual component layout.',
91
+ meta_skills: ['requirements-analysis', 'code-development'], rules: ['component-boundary'] },
92
+ ]
88
93
  const proposed = JSON.parse(await f.call({ operation: 'init_project', phase: 'propose', resources }))
89
- assert.equal(f.files.has(join(f.cwd, '.dsh/meta.json')), false)
90
- await f.call({ operation: 'init_project', phase: 'apply', resources, expected_hash: proposed.expected_hash,
91
- existing_hash: proposed.existing_hash })
92
- const meta = JSON.parse(f.files.get(join(f.cwd, '.dsh/meta.json')))
93
- assert.deepEqual(meta.meta_bindings['code-development'], ['adaptive-project-project-map'])
94
+ assert.match(proposed.instruction, /whole repository/)
95
+ assert.match(proposed.instruction, /current-feature details/)
96
+ assert.equal(f.files.has(join(f.cwd, '.dsh/meta.json')), false)
97
+ await f.call({ operation: 'init_project', phase: 'apply', resources, expected_hash: proposed.expected_hash,
98
+ existing_hash: proposed.existing_hash })
99
+ const meta = JSON.parse(f.files.get(join(f.cwd, '.dsh/meta.json')))
100
+ assert.deepEqual(meta.meta_bindings['code-development'], ['adaptive-project-project-map'])
94
101
  assert.ok(f.files.has(join(f.cwd, '.dsh/skills/adaptive-project-project-map/profile.json')))
95
102
  })
96
103
 
104
+ test('task-plan item headings preserve the full implementation sequence', async () => {
105
+ assert.deepEqual(plannedItemIds('## Steps\n- I1 data model\n- I2 service\n### I3 UI\n- I2 service'), ['I1', 'I2', 'I3'])
106
+ const f = fixture()
107
+ await f.call({ operation: 'create', task_id: 'PLAN-1', title: 'Cross-module change', branch: 'feature',
108
+ complexity: 'high', complexity_reason: 'Backend then frontend dependency' })
109
+ const state = f.state('PLAN-1')
110
+ state.stage = '任务编排'
111
+ state.artifacts['task-plan'] = { steps: '- I1 model\n- I2 service', dependencies: 'I2 after I1', handoffs: 'Model to service' }
112
+ state.items = [{ id: 'I2', title: 'service', status: 'todo' }]
113
+ f.files.set(join(f.cwd, '.dsh/task-PLAN-1.json'), JSON.stringify(state))
114
+ const status = JSON.parse(await f.call({ operation: 'status', task_id: 'PLAN-1' }))
115
+ assert.match(status.task_plan_item_blockers[0], /I1/)
116
+ await assert.rejects(f.call({ operation: 'advance', task_id: 'PLAN-1', target_stage: '代码开发' }), /I1/)
117
+ })
118
+
119
+ test('init_project rejects a feature-only project map that omits discovered modules', async () => {
120
+ const f = fixture({
121
+ 'backend/pom.xml': '<project><properties><java.version>8</java.version></properties></project>',
122
+ 'frontend/package.json': '{"dependencies":{"vue":"^2.7.0"}}',
123
+ })
124
+ const draft = [{ kind: 'skill', name: 'adaptive-project-project-map',
125
+ description: 'Reusable repository map', meta_skills: ['requirements-analysis', 'code-development'],
126
+ content: '# Feature implementation\nThe root package.json and backend contain the feature API.' }]
127
+ await assert.rejects(f.call({ operation: 'init_project', phase: 'propose', resources: draft }), /frontend/)
128
+ draft[0].content = '# Repository map\nRoot package.json, backend/pom.xml and frontend/package.json define separate modules.'
129
+ const proposed = JSON.parse(await f.call({ operation: 'init_project', phase: 'propose', resources: draft }))
130
+ assert.deepEqual(proposed.project_map_coverage, ['backend', 'frontend', 'package.json'])
131
+ await f.call({ operation: 'init_project', phase: 'apply', resources: draft,
132
+ expected_hash: proposed.expected_hash, existing_hash: proposed.existing_hash })
133
+ assert.ok(f.files.has(join(f.cwd, '.dsh/skills/adaptive-project-project-map/SKILL.md')))
134
+ })
135
+
97
136
  test('workbench adaptive settings persist meta bindings with a version check', async () => {
137
+ const f = fixture()
138
+ const receiver = { authorizedPath: async path => path, fs: () => f.fs,
139
+ listSkills: async () => ({ skills: [{ name: 'adaptive-project-project-map', source: 'project' }] }),
140
+ readAdaptive: Controller.prototype.readAdaptive }
141
+ const before = await Controller.prototype.readAdaptive.call(receiver, f.cwd)
142
+ assert.equal(before.source, 'default')
143
+ const config = { meta_bindings: { 'code-development': ['adaptive-project-project-map'] }, sonar: { enabled: false } }
144
+ const after = await Controller.prototype.writeAdaptive.call(receiver,
145
+ { path: f.cwd, config, expected_hash: before.hash })
146
+ assert.equal(after.ok, true)
147
+ assert.deepEqual(after.config.meta_bindings['code-development'], ['adaptive-project-project-map'])
148
+ await assert.rejects(Controller.prototype.writeAdaptive.call(receiver,
149
+ { path: f.cwd, config, expected_hash: before.hash }), /已被其他会话修改/)
150
+ })
151
+
152
+ test('Sonar audit reads the exact CI analysis gate and filters new-code medium/high findings', async () => {
153
+ const calls = []
154
+ const fetcher = async (url, options) => {
155
+ calls.push({ url: String(url), authorization: options.headers.Authorization })
156
+ const body = String(url).includes('/ce/task')
157
+ ? { task: { status: 'SUCCESS', analysisId: 'AN-1', componentKey: 'project' } }
158
+ : String(url).includes('/qualitygates/project_status')
159
+ ? { projectStatus: { status: 'ERROR' } }
160
+ : { paging: { total: 2 }, issues: [
161
+ { key: 'ISSUE-1', rule: 'java:S111', message: 'problem', severity: 'MAJOR', component: 'project:src/A.java', line: 3 },
162
+ { key: 'ISSUE-2', rule: 'java:S222', message: 'minor', severity: 'MINOR', component: 'project:src/B.java' },
163
+ ] }
164
+ return { ok: true, json: async () => body }
165
+ }
166
+ const policy = { enabled: true, host_url: 'https://sonar.example', project_key: 'project', mode: 'branch', token_env: 'SONAR_TOKEN' }
167
+ const audit = await inspectSonar(policy, 'secret-token', 'CE-TASK-123', 'feature', 'scope-hash', fetcher)
168
+ assert.equal(audit.analysis_id, 'AN-1')
169
+ assert.equal(audit.gate, 'ERROR')
170
+ assert.deepEqual(audit.blocking.map(issue => issue.key), ['ISSUE-1'])
171
+ assert.ok(calls[2].url.includes('inNewCodePeriod=true'))
172
+ assert.ok(calls.every(call => call.authorization === 'Bearer secret-token'))
173
+ assert.equal(JSON.stringify(audit).includes('secret-token'), false)
174
+ })
175
+
176
+ test('IDE-style local Sonar settings save and move only that task commit after review', async () => {
177
+ const sonar = { enabled: true, host_url: 'http://sonar.example', project_key: 'project',
178
+ mode: 'branch', source: 'ide-local', reference_branch: 'main', token_env: 'SONAR_TOKEN' }
98
179
  const f = fixture()
99
180
  const receiver = { authorizedPath: async path => path, fs: () => f.fs,
100
- listSkills: async () => ({ skills: [{ name: 'adaptive-project-project-map', source: 'project' }] }),
101
- readAdaptive: Controller.prototype.readAdaptive }
181
+ listSkills: async () => ({ skills: [] }), readAdaptive: Controller.prototype.readAdaptive }
102
182
  const before = await Controller.prototype.readAdaptive.call(receiver, f.cwd)
103
- assert.equal(before.source, 'default')
104
- const config = { meta_bindings: { 'code-development': ['adaptive-project-project-map'] }, sonar: { enabled: false } }
105
- const after = await Controller.prototype.writeAdaptive.call(receiver,
106
- { path: f.cwd, config, expected_hash: before.hash })
107
- assert.equal(after.ok, true)
108
- assert.deepEqual(after.config.meta_bindings['code-development'], ['adaptive-project-project-map'])
109
- await assert.rejects(Controller.prototype.writeAdaptive.call(receiver,
110
- { path: f.cwd, config, expected_hash: before.hash }), /已被其他会话修改/)
183
+ const saved = await Controller.prototype.writeAdaptive.call(receiver,
184
+ { path: f.cwd, config: { sonar }, expected_hash: before.hash })
185
+ assert.equal(saved.ok, true)
186
+ await f.call({ operation: 'create', task_id: 'L-1', title: 'local review', branch: 'feature',
187
+ complexity: 'low', complexity_reason: 'Small isolated change', files: ['app.java'] })
188
+ const state = f.state('L-1')
189
+ assert.equal(state.sonar_policy.source, 'ide-local')
190
+ assert.deepEqual(state.flow.config.commit.checkpoints, ['代码审核'])
111
191
  })
112
192
 
113
- test('Sonar audit reads the exact CI analysis gate and filters new-code medium/high findings', async () => {
193
+ test('local Sonar audit ignores failing old-code gate conditions', async () => {
194
+ const policy = { enabled: true, host_url: 'https://sonar.example', project_key: 'project', mode: 'branch',
195
+ token_env: 'SONAR_TOKEN', source: 'local', reference_branch: 'main' }
196
+ const fetcher = async url => ({ ok: true, json: async () => String(url).includes('/ce/task')
197
+ ? { task: { status: 'SUCCESS', analysisId: 'AN-local', componentKey: 'project', branch: 'feature' } }
198
+ : String(url).includes('/qualitygates/project_status')
199
+ ? { projectStatus: { status: 'ERROR', conditions: [
200
+ { metricKey: 'bugs', status: 'ERROR' }, { metricKey: 'new_coverage', status: 'OK' }] } }
201
+ : { paging: { total: 0 }, issues: [] } })
202
+ const audit = await inspectSonar(policy, 'token', 'CE-local-123', 'feature', 'scope', fetcher, true)
203
+ assert.equal(audit.gate, 'OK')
204
+ assert.equal(audit.server_gate, 'ERROR')
205
+ assert.deepEqual(audit.blocking, [])
206
+ const newFailure = await inspectSonar(policy, 'token', 'CE-local-123', 'feature', 'scope',
207
+ async url => String(url).includes('/qualitygates/project_status')
208
+ ? { ok: true, json: async () => ({ projectStatus: { status: 'ERROR', conditions: [
209
+ { metricKey: 'new_coverage', status: 'ERROR' }] } }) }
210
+ : fetcher(url), true)
211
+ assert.equal(newFailure.gate, 'ERROR')
212
+ })
213
+
214
+ test('local scanner configuration rejects shell operators before receiving the Token', () => {
215
+ assert.equal(validLocalScanCommand('mvn org.sonarsource.scanner.maven:sonar-maven-plugin:5.5.0.6356:sonar -DskipTests'), true)
216
+ assert.equal(validLocalScanCommand('mvn sonar:sonar; curl http://example'), false)
217
+ assert.equal(validLocalScanCommand('mvn sonar:sonar -Dsonar.token=secret'), false)
218
+ assert.equal(validLocalScanCommand('curl http://example'), false)
219
+ })
220
+
221
+ test('Community Sonar refuses a local branch scan before uploading any analysis', async () => {
222
+ const policy = { enabled: true, host_url: 'https://sonar.example', project_key: 'project', mode: 'branch',
223
+ token_env: 'SONAR_TOKEN', source: 'local', reference_branch: 'main' }
114
224
  const calls = []
115
- const fetcher = async (url, options) => {
116
- calls.push({ url: String(url), authorization: options.headers.Authorization })
117
- const body = String(url).includes('/ce/task')
118
- ? { task: { status: 'SUCCESS', analysisId: 'AN-1', componentKey: 'project' } }
119
- : String(url).includes('/qualitygates/project_status')
120
- ? { projectStatus: { status: 'ERROR' } }
121
- : { paging: { total: 2 }, issues: [
122
- { key: 'ISSUE-1', rule: 'java:S111', message: 'problem', severity: 'MAJOR', component: 'project:src/A.java', line: 3 },
123
- { key: 'ISSUE-2', rule: 'java:S222', message: 'minor', severity: 'MINOR', component: 'project:src/B.java' },
124
- ] }
125
- return { ok: true, json: async () => body }
126
- }
127
- const policy = { enabled: true, host_url: 'https://sonar.example', project_key: 'project', mode: 'branch', token_env: 'SONAR_TOKEN' }
128
- const audit = await inspectSonar(policy, 'secret-token', 'CE-TASK-123', 'feature', 'scope-hash', fetcher)
129
- assert.equal(audit.analysis_id, 'AN-1')
130
- assert.equal(audit.gate, 'ERROR')
131
- assert.deepEqual(audit.blocking.map(issue => issue.key), ['ISSUE-1'])
132
- assert.ok(calls[2].url.includes('inNewCodePeriod=true'))
133
- assert.ok(calls.every(call => call.authorization === 'Bearer secret-token'))
134
- assert.equal(JSON.stringify(audit).includes('secret-token'), false)
225
+ await assert.rejects(assertLocalSonarReady(policy, 'token', async url => {
226
+ calls.push(String(url))
227
+ return { ok: true, json: async () => ({ edition: 'community' }) }
228
+ }), /Community Build supports only the main analysis branch/u)
229
+ assert.equal(calls.length, 1)
230
+ assert.match(calls[0], /navigation\/global/u)
135
231
  })
136
232
 
137
- test('review-only Sonar gate blocks pass until the task records a passing CI analysis', async () => {
233
+ test('sonar_check runs a local scan on the recorded commit without pushing or exposing Token', async () => {
234
+ const calls = []
235
+ const shell = { resolve: request => request, run: async spec => {
236
+ calls.push(spec)
237
+ if (spec.command === 'git rev-parse HEAD') return { exitCode: 0, stdout: { text: 'abcdef1234567890\n' } }
238
+ if (spec.command === 'git branch --show-current') return { exitCode: 0, stdout: { text: 'feature\n' } }
239
+ if (spec.command === 'git status --porcelain --untracked-files=all') return { exitCode: 0, stdout: { text: '' } }
240
+ const metadata = /-Dsonar\.scanner\.metadataFilePath='([^']+)'/u.exec(spec.command)?.[1]
241
+ assert.ok(metadata)
242
+ writeFileSync(metadata, 'ceTaskId=CE-local-456\n')
243
+ return { exitCode: 0 }
244
+ } }
138
245
  const f = fixture({ '.dsh/meta.json': JSON.stringify({ sonar: { enabled: true, host_url: 'https://sonar.example',
139
- project_key: 'project', mode: 'branch', token_env: 'SONAR_TOKEN' } }) })
140
- await f.call({ operation: 'create', task_id: 'S-1', title: 'change', branch: 'feature', complexity: 'low',
246
+ project_key: 'project', mode: 'branch', source: 'local', reference_branch: 'main' } }) }, { shell })
247
+ await f.call({ operation: 'create', task_id: 'LOCAL-1', title: 'change', branch: 'feature', complexity: 'low',
141
248
  complexity_reason: 'One local change', files: ['app.js'], items: [{ id: 'one', title: 'change', status: 'done' }] })
142
- const state = f.state('S-1')
249
+ const state = f.state('LOCAL-1')
143
250
  state.stage = '代码审核'
144
251
  state.verification = { passed: true, evidence: ['verified'] }
145
252
  state.commits = [{ label: 'TASK', hash: 'abcdef1234567890' }]
146
- f.files.set(join(f.cwd, '.dsh/task-S-1.json'), JSON.stringify(state))
147
- await assert.rejects(f.call({ operation: 'review', task_id: 'S-1', outcome: 'pass' }), /SonarQube audit is enabled/)
253
+ f.files.set(join(f.cwd, '.dsh/task-LOCAL-1.json'), JSON.stringify(state))
148
254
  const oldFetch = globalThis.fetch
149
255
  const oldToken = process.env.SONAR_TOKEN
150
- process.env.SONAR_TOKEN = 'secret-token'
151
- globalThis.fetch = async url => ({ ok: true, json: async () => String(url).includes('/ce/task')
152
- ? { task: { status: 'SUCCESS', analysisId: 'AN-2', componentKey: 'project' } }
153
- : String(url).includes('/qualitygates/project_status') ? { projectStatus: { status: 'OK' } }
256
+ process.env.SONAR_TOKEN = 'private-token'
257
+ globalThis.fetch = async url => ({ ok: true, json: async () => String(url).includes('/navigation/global')
258
+ ? { edition: 'developer' }
259
+ : String(url).includes('/project_branches/list') ? { branches: [{ name: 'main' }] }
260
+ : String(url).includes('/ce/task')
261
+ ? { task: { status: 'SUCCESS', analysisId: 'AN-local', componentKey: 'project', branch: 'feature' } }
262
+ : String(url).includes('/qualitygates/project_status')
263
+ ? { projectStatus: { status: 'ERROR', conditions: [{ metricKey: 'bugs', status: 'ERROR' }] } }
154
264
  : { paging: { total: 0 }, issues: [] } })
155
265
  try {
156
- await f.call({ operation: 'sonar_check', task_id: 'S-1', ce_task_id: 'CE-123456' })
157
- await f.call({ operation: 'review', task_id: 'S-1', outcome: 'pass' })
158
- assert.equal(f.state('S-1').review.outcome, 'pass')
266
+ const result = JSON.parse(await f.call({ operation: 'sonar_check', task_id: 'LOCAL-1' }))
267
+ assert.equal(result.gate, 'OK')
268
+ assert.equal(result.server_gate, 'ERROR')
269
+ assert.equal(calls.length, 4)
270
+ assert.equal(calls[3].env.SONAR_TOKEN, 'private-token')
271
+ assert.equal(calls[3].command.includes('private-token'), false)
272
+ assert.match(calls[3].command, /sonar\.newCode\.referenceBranch='main'/u)
273
+ assert.equal(JSON.stringify(f.state('LOCAL-1')).includes('private-token'), false)
274
+ assert.equal(calls.some(call => /git push/u.test(call.command)), false)
159
275
  } finally {
160
276
  globalThis.fetch = oldFetch
161
277
  if (oldToken === undefined) delete process.env.SONAR_TOKEN
162
278
  else process.env.SONAR_TOKEN = oldToken
163
279
  }
164
280
  })
165
-
166
- test('a failed Sonar finding becomes a reviewed project Rule attached to the coding Skill', async () => {
167
- const f = fixture()
168
- await f.call({ operation: 'create', task_id: 'L-1', title: 'change', branch: 'feature', complexity: 'low',
169
- complexity_reason: 'One local change', files: ['app.js'] })
170
- const state = f.state('L-1')
171
- const finding = { key: 'ISSUE-9', rule: 'java:S999', message: 'Handle null', severity: 'MAJOR', file: 'A.java' }
172
- state.stage = '代码审核'
173
- state.sonar_audit = { ce_task_id: 'CE-123456', analysis_id: 'AN-9', gate: 'ERROR', checked_at: 'now',
174
- scope_hash: 'old', findings: [finding], blocking: [finding], target: 'feature' }
175
- f.files.set(join(f.cwd, '.dsh/task-L-1.json'), JSON.stringify(state))
281
+
282
+ test('review-only Sonar gate blocks pass until the task records CI analysis of new code', async () => {
283
+ const f = fixture({ '.dsh/meta.json': JSON.stringify({ sonar: { enabled: true, host_url: 'https://sonar.example',
284
+ project_key: 'project', mode: 'branch', token_env: 'SONAR_TOKEN' } }) })
285
+ await f.call({ operation: 'create', task_id: 'S-1', title: 'change', branch: 'feature', complexity: 'low',
286
+ complexity_reason: 'One local change', files: ['app.js'], items: [{ id: 'one', title: 'change', status: 'done' }] })
287
+ const state = f.state('S-1')
288
+ state.stage = '代码审核'
289
+ state.verification = { passed: true, evidence: ['verified'] }
290
+ state.commits = [{ label: 'TASK', hash: 'abcdef1234567890' }]
291
+ f.files.set(join(f.cwd, '.dsh/task-S-1.json'), JSON.stringify(state))
292
+ await assert.rejects(f.call({ operation: 'review', task_id: 'S-1', outcome: 'pass' }), /SonarQube audit is enabled/)
293
+ const oldFetch = globalThis.fetch
294
+ const oldToken = process.env.SONAR_TOKEN
295
+ process.env.SONAR_TOKEN = 'secret-token'
296
+ globalThis.fetch = async url => ({ ok: true, json: async () => String(url).includes('/ce/task')
297
+ ? { task: { status: 'SUCCESS', analysisId: 'AN-2', componentKey: 'project' } }
298
+ : String(url).includes('/qualitygates/project_status') ? { projectStatus: { status: 'ERROR',
299
+ conditions: [{ metricKey: 'bugs', status: 'ERROR' }, { metricKey: 'new_coverage', status: 'OK' }] } }
300
+ : { paging: { total: 0 }, issues: [] } })
301
+ try {
302
+ const check = JSON.parse(await f.call({ operation: 'sonar_check', task_id: 'S-1', ce_task_id: 'CE-123456' }))
303
+ assert.equal(check.gate, 'OK')
304
+ assert.equal(check.server_gate, 'ERROR')
305
+ assert.match(check.report_path, /^\.dsh\/reviews\/S-1\//)
306
+ assert.match(f.files.get(join(f.cwd, check.report_path)), /SonarQube 代码审核/)
307
+ assert.equal(f.state('S-1').sonar_audit.report_path, check.report_path)
308
+ await f.call({ operation: 'review', task_id: 'S-1', outcome: 'pass' })
309
+ assert.equal(f.state('S-1').review.outcome, 'pass')
310
+ } finally {
311
+ globalThis.fetch = oldFetch
312
+ if (oldToken === undefined) delete process.env.SONAR_TOKEN
313
+ else process.env.SONAR_TOKEN = oldToken
314
+ }
315
+ })
316
+
317
+ test('a failed Sonar finding becomes a reviewed project Rule attached to the coding Skill', async () => {
318
+ const f = fixture()
319
+ await f.call({ operation: 'create', task_id: 'L-1', title: 'change', branch: 'feature', complexity: 'low',
320
+ complexity_reason: 'One local change', files: ['app.js'] })
321
+ const state = f.state('L-1')
322
+ const finding = { key: 'ISSUE-9', rule: 'java:S999', message: 'Handle null', severity: 'MAJOR', file: 'A.java' }
323
+ state.stage = '代码审核'
324
+ state.sonar_audit = { ce_task_id: 'CE-123456', analysis_id: 'AN-9', gate: 'ERROR', checked_at: 'now',
325
+ scope_hash: 'old', findings: [finding], blocking: [finding], target: 'feature' }
326
+ f.files.set(join(f.cwd, '.dsh/task-L-1.json'), JSON.stringify(state))
176
327
  await f.call({ operation: 'revise', task_id: 'L-1', target_stage: '代码开发',
177
328
  revision_kind: 'defect', revision_reason: 'Fix the SonarQube issue' })
178
329
  assert.equal(f.state('L-1').sonar_history[0].blocking[0].key, 'ISSUE-9')
179
- const args = { operation: 'learn_rule', task_id: 'L-1', issue_key: 'ISSUE-9', skill_name: 'bundled:code-development',
180
- rule_name: 'handle-null-boundary', learning_reason: 'This boundary appears in several service methods',
181
- content: 'At a service boundary, check for null before dereferencing. Show the failing call and a guarded call in the review example.' }
182
- const proposal = JSON.parse(await f.call({ ...args, phase: 'propose' }))
183
- assert.equal(f.files.has(join(f.cwd, '.dsh/rules/handle-null-boundary.md')), false)
184
- await f.call({ ...args, phase: 'apply', expected_hash: proposal.expected_hash })
185
- assert.match(f.files.get(join(f.cwd, '.dsh/rules/handle-null-boundary.md')), /java:S999/)
186
- assert.ok(f.files.has(join(f.cwd, '.dsh/skills/code-development/SKILL.md')))
330
+ assert.equal(JSON.parse(await f.call({ operation: 'status', task_id: 'L-1' })).rule_learning_candidates[0].rule, 'java:S999')
331
+ const args = { operation: 'learn_rule', task_id: 'L-1', issue_key: 'ISSUE-9', skill_name: 'bundled:code-development',
332
+ rule_name: 'handle-null-boundary', learning_reason: 'This boundary appears in several service methods',
333
+ content: 'At a service boundary, check for null before dereferencing. Show the failing call and a guarded call in the review example.' }
334
+ const proposal = JSON.parse(await f.call({ ...args, phase: 'propose' }))
335
+ assert.equal(f.files.has(join(f.cwd, '.dsh/rules/handle-null-boundary.md')), false)
336
+ await f.call({ ...args, phase: 'apply', expected_hash: proposal.expected_hash })
337
+ assert.match(f.files.get(join(f.cwd, '.dsh/rules/handle-null-boundary.md')), /java:S999/)
338
+ assert.ok(f.files.has(join(f.cwd, '.dsh/skills/code-development/SKILL.md')))
187
339
  assert.deepEqual(JSON.parse(f.files.get(join(f.cwd, '.dsh/skills/code-development/profile.json'))).rules,
188
340
  [{ source: 'project', name: 'handle-null-boundary' }])
341
+ assert.deepEqual(JSON.parse(await f.call({ operation: 'status', task_id: 'L-1' })).rule_learning_candidates, [])
189
342
  })
@@ -29,7 +29,7 @@ function diskFs() {
29
29
  }
30
30
  }
31
31
 
32
- test('Codex project Skills are discovered separately; new DSH Skills stay in .dsh', async t => {
32
+ test('Codex project Skills are discovered separately; new DSH Skills stay in .dsh', async t => {
33
33
  const root = fixture(t)
34
34
  const body = '---\nname: same\ndescription: Codex method\n---\nCodex body v1\n'
35
35
  put(join(root, '.agents/skills/same/SKILL.md'), body)
@@ -42,23 +42,23 @@ test('Codex project Skills are discovered separately; new DSH Skills stay in .ds
42
42
  const created = await Controller.prototype.writeSkill.call(receiver, { level: 'project', name: 'new-one', description: 'New DSH method', whenToUse: '', content: 'body', path: root })
43
43
  assert.equal(created.ok, true)
44
44
  assert.equal(readFileSync(join(root, '.dsh/skills/new-one/SKILL.md'), 'utf8').includes('body'), true)
45
- })
46
-
47
- test('a project Skill can shadow a bundled meta Skill without replacing an existing project copy', async t => {
48
- const root = fixture(t)
49
- const receiver = { authorizedPath: async path => path }
50
- const request = { level: 'project', name: 'requirements-analysis', description: 'Team requirements method',
51
- whenToUse: 'During requirement analysis', content: 'Project method', path: root, createOnly: true }
52
- const first = await Controller.prototype.writeSkill.call(receiver, request)
53
- assert.equal(first.ok, true)
54
- const file = join(root, '.dsh/skills/requirements-analysis/SKILL.md')
55
- assert.match(readFileSync(file, 'utf8'), /Project method/)
56
- const second = await Controller.prototype.writeSkill.call(receiver, { ...request, content: 'Should not replace' })
57
- assert.equal(second.ok, false)
58
- assert.match(readFileSync(file, 'utf8'), /Project method/)
59
- const catalog = await Controller.prototype.listSkills.call(receiver, root)
60
- assert.deepEqual(catalog.skills.filter(skill => skill.name === 'requirements-analysis').map(skill => skill.ref.source).sort(), ['bundled', 'project'])
61
- })
45
+ })
46
+
47
+ test('a project Skill can shadow a bundled meta Skill without replacing an existing project copy', async t => {
48
+ const root = fixture(t)
49
+ const receiver = { authorizedPath: async path => path }
50
+ const request = { level: 'project', name: 'requirements-analysis', description: 'Team requirements method',
51
+ whenToUse: 'During requirement analysis', content: 'Project method', path: root, createOnly: true }
52
+ const first = await Controller.prototype.writeSkill.call(receiver, request)
53
+ assert.equal(first.ok, true)
54
+ const file = join(root, '.dsh/skills/requirements-analysis/SKILL.md')
55
+ assert.match(readFileSync(file, 'utf8'), /Project method/)
56
+ const second = await Controller.prototype.writeSkill.call(receiver, { ...request, content: 'Should not replace' })
57
+ assert.equal(second.ok, false)
58
+ assert.match(readFileSync(file, 'utf8'), /Project method/)
59
+ const catalog = await Controller.prototype.listSkills.call(receiver, root)
60
+ assert.deepEqual(catalog.skills.filter(skill => skill.name === 'requirements-analysis').map(skill => skill.ref.source).sort(), ['bundled', 'project'])
61
+ })
62
62
 
63
63
  test('a bound Codex Skill loads its attached DSH Rule and reads edits next time', async t => {
64
64
  const root = fixture(t)
@@ -7,11 +7,11 @@
7
7
  */
8
8
  import test from 'node:test'
9
9
  import assert from 'node:assert/strict'
10
- import { needsSkillReceipt, skillBlockers } from './lib/skill-audit.js'
10
+ import { loadedSkills, needsSkillReceipt, skillBlockers } from './lib/skill-audit.js'
11
11
  import { adoptRecommendation, resolveFlow } from './lib/workflows.js'
12
12
 
13
13
  /** A session that has loaded the named skills. */
14
- function sessionWith(names) {
14
+ function sessionWith(names) {
15
15
  const events = []
16
16
  for (const name of names) {
17
17
  const callId = 'c' + events.length
@@ -19,7 +19,20 @@ function sessionWith(names) {
19
19
  events.push({ type: 'tool/result', data: { message: { content: [{ type: 'tool-result', toolCallId: callId, isError: false }] } } })
20
20
  }
21
21
  return { id: 's', snapshotEvents: () => events }
22
- }
22
+ }
23
+
24
+ test('evidence: DSH 0.2 tool messages prove only successful task-scoped skill loads', () => {
25
+ const events = [
26
+ { type: 'tool/call', data: { name: 'dev_task', callId: 'ok', arguments: JSON.stringify({ operation: 'load_skill', task_id: 'Q-1', skill_name: 'project:qms-project-map' }) } },
27
+ { type: 'tool/result', data: { message: { role: 'tool', toolCallId: 'ok', isError: false, content: [{ type: 'text', text: 'loaded' }] } } },
28
+ { type: 'tool/call', data: { name: 'dev_task', callId: 'bad', arguments: JSON.stringify({ operation: 'load_skill', task_id: 'Q-1', skill_name: 'project:qms-tech-stack' }) } },
29
+ { type: 'tool/result', data: { message: { role: 'tool', toolCallId: 'bad', isError: true, content: [{ type: 'text', text: 'failed' }] } } },
30
+ ]
31
+ const loaded = loadedSkills({ snapshotEvents: () => events })
32
+ assert.equal(loaded.get('Q-1#project:qms-project-map'), 'ok')
33
+ assert.equal(loaded.has('Q-1#project:qms-tech-stack'), false)
34
+ assert.equal(loaded.has('Q-2#project:qms-project-map'), false)
35
+ })
23
36
 
24
37
  /** A standard-flow config with one extra skill on 开发 carrying the given evidence. */
25
38
  function withEvidence(evidence) {