Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .boundary-fixes-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,10 @@ test('deletion plans reject untracked old content, directories and invalid renam
assert.throws(() => validateDeliveryChanges([{ path: 'a', kind: 'rename', to: 'b' }], ['a']), /both rename paths/)
assert.throws(() => validateDeliveryChanges([{ path: 'a', kind: 'delete' }, { path: 'a', kind: 'delete' }], ['a']), /unique old paths/)
const directory = await fixture(); unlinkSync(join(directory.root, directory.old)); mkdirSync(join(directory.root, directory.old))
await assert.rejects(directory.plan([{ path: directory.old, kind: 'delete' }]), /not a regular file/)
const unchanged = directory.state()
await assert.rejects(directory.plan([{ path: directory.old, kind: 'delete' }]),
error => error.code === 'INVALID_FILE_SCOPE' && error.path === directory.old)
assert.deepEqual(directory.state(), unchanged)
})
test('baseline capture preserves UTF-8 text and rejects binary snapshots', async () => {
for (const body of ['export const 名称 = "旧实现,包含中文"\n', 'binary\0content']) {
Expand Down
225 changes: 225 additions & 0 deletions .file-scope-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/** File declarations, readable error projections and controlled recovery against real modules. */
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, readdirSync, lstatSync, rmSync, symlinkSync } from 'node:fs'
import { join, dirname, resolve, sep } from 'node:path'
import { tmpdir } from 'node:os'
import { spawnSync } from 'node:child_process'
import { runInNewContext } from 'node:vm'
import { registerDevTask, scopeFingerprint, verificationInputHashes } from './lib/dev-task.js'
import { validateTaskFiles } from './lib/task-files.js'
import Controller from './lib/controller.js'
import { taskDocument } from './lib/task-ledger-document.js'
import { confirmedRequirement } from './scripts/fixtures/confirmed-requirement.mjs'

const command = 'node --test --experimental-test-isolation=none --test-reporter=tap example.test.mjs'
async function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'task-file-scope-'))
t.after(() => {
assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep + 'task-file-scope-'))
rmSync(root, { recursive: true, force: true })
})
const put = (file, text) => { mkdirSync(dirname(join(root, file)), { recursive: true }); writeFileSync(join(root, file), text) }
put('package.json', '{"type":"module"}\n')
put('example.mjs', 'export const value = 2\n')
put('example.test.mjs', 'import{test}from"node:test";import assert from"node:assert/strict";import{value}from"./example.mjs";test("business value",()=>assert.equal(value,2))\n')
const fs = {
resolve: async (file, options) => ({ path: resolve(options?.cwd ?? root, file) }),
readText: async target => { try { return readFileSync(target.path, 'utf8') } catch (e) { if (e.code !== 'ENOENT') throw e } },
lstat: async (file, options) => {
try {
const info = lstatSync(resolve(options?.cwd ?? root, file))
return { type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'symlink', version: info.mtimeMs + ':' + info.size }
} catch (e) { if (e.code !== 'ENOENT') throw e }
},
listDir: async target => readdirSync(target.path, { withFileTypes: true }).map(e => ({ name: e.name,
type: e.isSymbolicLink() ? 'symlink' : e.isDirectory() ? 'directory' : 'file' })),
writeText: async (target, text) => { mkdirSync(dirname(target.path), { recursive: true }); writeFileSync(target.path, text) },
}
let execute, shellCalls = 0
const ctx = { fs, tools: { register(tool) { execute = tool.execute } }, get(name) {
if (name === 'sandboxPolicy') return { resolve: () => ({ mode: 'workspace-write', workspaceRoot: root }) }
if (name === 'shell') return { resolve: value => value, run: async spec => {
shellCalls++
const env = { ...process.env }; delete env.NODE_TEST_CONTEXT
const result = spawnSync(process.execPath, spec.command.split(' ').slice(1), { cwd: spec.workdir, encoding: 'utf8', env })
return { exitCode: result.status, timedOut: false, aborted: false, stdout: { text: result.stdout ?? '' }, stderr: { text: result.stderr ?? '' },
sandbox: { mode: 'workspace-write', denied: false } }
} }
} }
registerDevTask(ctx)
const call = args => execute({ task_id: 'SCOPE', ...args }, { agent: { session: { id: 'scope-fixture', header: { cwd: root }, snapshotEvents: () => [] } },
signal: new AbortController().signal, callId: 'scope-call' })
const ledger = join(root, '.dsh/tasks/SCOPE/task.json')
const raw = () => readFileSync(ledger, 'utf8'), state = () => JSON.parse(raw())
await call({ operation: 'create', title: 'Scope regression', intent: 'implementation', intent_reason: 'Isolated scope fixture', branch: 'fixture',
complexity: 'medium', complexity_reason: 'Local implementation', files: ['example.mjs', 'example.test.mjs'], items: [{ id: 'I1', title: 'complete unit' }] })
const initial = confirmedRequirement(state(), put); initial.stage = '代码开发'; writeFileSync(ledger, JSON.stringify(initial))
const plan = (files, id = 'checks') => call({ operation: 'plan_verification', verification_group: id, files, command, item_ids: ['I1'] })
const view = () => Controller.prototype.readTasks.call({ authorizedPath: async path => path, fs: () => fs }, root)
return { root, fs, put, call, raw, state, plan, view, calls: () => shellCalls }
}

for (const operation of ['create', 'scope', 'plan', 'plan_execution', 'plan_verification']) {
test(operation + ' rejects existing directories before any task mutation', async t => {
const f = await fixture(t)
mkdirSync(join(f.root, 'module'))
const before = f.raw()
const fields = { files: ['module'], acceptance: ['works'], task_boundary: 'local', execution_reason: 'one complete unit', depends_on: [] }
const args = operation === 'create'
? { operation, task_id: 'BAD', title: 'bad scope', intent: 'implementation', intent_reason: 'fixture', branch: 'fixture', complexity: 'medium', complexity_reason: 'fixture', files: ['module'] }
: operation === 'plan' ? { operation, plans: [{ id: 'I1', title: 'complete unit', ...fields }] }
: operation === 'plan_execution' ? { operation, item_id: 'I1', ...fields }
: operation === 'plan_verification' ? { operation, verification_group: 'checks', command, files: ['module'], item_ids: ['I1'] }
: { operation, files: ['module'] }
await assert.rejects(f.call(args), /INVALID_FILE_SCOPE.*regular files, not directories/)
assert.equal(f.raw(), before)
assert.equal(await f.fs.lstat('.dsh/tasks/BAD/task.json', { cwd: f.root }), undefined)
})
}

test('all declarations permit concrete new files, including extensionless files and bracketed routes', async t => {
const f = await fixture(t)
for (const use of ['delivery', 'verification'])
assert.deepEqual(await validateTaskFiles(f.fs, ['new/module', 'routes/[slug]/page.tsx'], f.root, 'test', use),
['new/module', 'routes/[slug]/page.tsx'])
await f.call({ operation: 'scope', files: ['new/module', 'routes/[slug]/page.tsx'] })
await f.plan(['new/module', 'routes/[slug]/page.tsx'])
})

test('scope and verification reject ledger inputs and traversal; sensitive delivery policy is consistent', async t => {
const f = await fixture(t)
for (const path of ['.dsh/tasks/SCOPE/task.json', '../outside.txt', '*.mjs']) {
const before = f.raw()
await assert.rejects(f.call({ operation: 'scope', files: [path] }), /INVALID_FILE_SCOPE/)
await assert.rejects(f.plan([path]), /INVALID_FILE_SCOPE/)
assert.equal(f.raw(), before)
}
f.put('.env', 'FIXTURE_ONLY=value\n')
await assert.rejects(f.call({ operation: 'scope', files: ['.env'] }), /sensitive delivery path/)
// Ordinary configuration may be a read-only test input under existing policy.
await f.plan(['example.mjs', 'example.test.mjs', '.env'])
const first = await verificationInputHashes(f.fs, f.state(), f.root)
f.put('.env', 'FIXTURE_ONLY=changed\n')
assert.notEqual((await verificationInputHashes(f.fs, f.state(), f.root)).checks, first.checks)
})

test('future directory stays visible in status, ledger, transport and Markdown; verification stays blocked', async t => {
const f = await fixture(t)
await f.plan(['example.mjs', 'example.test.mjs', 'future'])
await f.call({ operation: 'verify', verification_group: 'checks' })
assert.equal(f.state().verification.passed, true)
f.put('future/new.mjs', 'export const added = 1\n')
const before = f.raw()
const status = JSON.parse(await f.call({ operation: 'status' }))
assert.ok(status.file_scope_problems.some(problem => /checks.*future/u.test(problem)))
assert.ok(status.evidence_blockers.length)
assert.equal(status.commit.allowed, false)
const view = await f.view(), task = view.tasks[0]
assert.equal(view.tasks.length, 1)
assert.equal(view.errors, undefined)
assert.equal(task.title, 'Scope regression')
assert.equal(task.evidence_status, 'blocked')
assert.equal(task.verification_passed, false)
assert.equal(task.verification_groups[0].status, 'blocked')
assert.match(task.evidence_problems.join('\n'), /checks.*future/)
assert.match(taskDocument(task), /文件范围或证据无法核验/)
assert.equal(f.raw(), before) // Rendering/status never rewrites original evidence.
await assert.rejects(f.call({ operation: 'verify', verification_group: 'checks' }), /INVALID_FILE_SCOPE/)
await assert.rejects(f.call({ operation: 'advance', target_stage: '测试' }), /INVALID_FILE_SCOPE/)
assert.equal(f.calls(), 1)
await f.plan(['example.mjs', 'example.test.mjs', 'future/new.mjs'])
assert.equal(f.state().verification.passed, false)
await f.call({ operation: 'verify', verification_group: 'checks' })
assert.equal((await f.view()).tasks[0].evidence_status, 'valid')
assert.equal(f.calls(), 2)

let registration, contribution
runInNewContext(readFileSync('lib/client.js', 'utf8'), { window: { __ModuleLoader__: { load(value) { registration = value } } } })
// React is provided by the DSH browser host. Codec registration does not
// render components and must also run in a clean npm consumer without React.
const client = registration.factory(() => ({}))
const stop = new Error('descriptors captured')
await assert.rejects(client.apply({ remote: { $mount: async value => { contribution = value; throw stop } } }), error => error === stop)
const method = contribution.descriptors.find(d => d.method === 'readTasks')
assert.deepEqual(JSON.parse(JSON.stringify(method.result.create().parse(view))), view)
})

test('two invalid groups can be repaired individually without faking either receipt', async t => {
const f = await fixture(t)
await f.plan(['example.mjs', 'example.test.mjs', 'future-a'], 'a')
await f.plan(['example.mjs', 'example.test.mjs', 'future-b'], 'b')
f.put('future-a/a.mjs', 'a\n'); f.put('future-b/b.mjs', 'b\n')
assert.equal((await f.view()).tasks[0].verification_groups.filter(g => g.status === 'blocked').length, 2)
await f.plan(['example.mjs', 'example.test.mjs', 'future-a/a.mjs'], 'a')
assert.equal(f.state().verification.passed, false)
const partial = await f.view()
assert.equal(partial.tasks[0].evidence_status, 'blocked')
assert.ok(partial.tasks[0].evidence_problems.some(p => /group b/.test(p)))
await assert.rejects(f.call({ operation: 'verify', verification_group: 'a' }), /group b.*future-b/)
assert.equal(f.calls(), 0)
await f.plan(['example.mjs', 'example.test.mjs', 'future-b/b.mjs'], 'b')
assert.equal((f.state().verification_runs ?? []).length, 0)
await f.call({ operation: 'verify', verification_group: 'a' })
await f.call({ operation: 'verify', verification_group: 'b' })
assert.equal(f.state().verification.passed, true)
assert.equal((await f.view()).tasks[0].evidence_status, 'valid')
})

test('a declared delivery file that becomes a directory is repairable through scope', async t => {
const f = await fixture(t)
await f.call({ operation: 'scope', files: ['future'] })
f.put('future/real.mjs', 'content\n')
assert.equal((await f.view()).tasks[0].evidence_status, 'blocked')
const before = f.raw()
assert.ok(JSON.parse(await f.call({ operation: 'status' })).file_scope_problems.length)
assert.equal(f.raw(), before)
await f.call({ operation: 'scope', files: ['future/real.mjs'] })
assert.deepEqual(JSON.parse(await f.call({ operation: 'status' })).file_scope_problems, [])
})

test('legal prototype-like group IDs are not mistaken for file diagnostics', async t => {
const f = await fixture(t)
await f.plan(['example.mjs', 'example.test.mjs'], 'constructor')
const view = await f.view()
assert.equal(view.tasks.length, 1)
assert.equal(view.tasks[0].evidence_problems, undefined)
await f.call({ operation: 'verify', verification_group: 'constructor' })
assert.equal((await f.view()).tasks[0].evidence_status, 'valid')
})

test('linked parents are rejected even for a missing target file', async t => {
const f = await fixture(t)
mkdirSync(join(f.root, 'actual'))
symlinkSync(join(f.root, 'actual'), join(f.root, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
await assert.rejects(f.call({ operation: 'scope', files: ['linked/missing.mjs'] }), /parent is not an ordinary directory/)
await assert.rejects(f.plan(['linked/missing.mjs']), /parent is not an ordinary directory/)
await assert.rejects(scopeFingerprint(f.fs, { ...f.state(), files: ['linked/missing.mjs'] }, f.root), /INVALID_FILE_SCOPE/)
})

test('a missing declared file that becomes a linked file never gets a valid fingerprint', async t => {
const f = await fixture(t)
await f.plan(['example.mjs', 'example.test.mjs', 'future-link'])
mkdirSync(join(f.root, 'actual'))
symlinkSync(join(f.root, 'actual'), join(f.root, 'future-link'), process.platform === 'win32' ? 'junction' : 'dir')
await assert.rejects(verificationInputHashes(f.fs, f.state(), f.root), /non-regular/)
assert.equal((await f.view()).tasks[0].evidence_status, 'blocked')
})

test('unreadable evidence preserves task identity and never promotes an old passing run', async t => {
const f = await fixture(t)
await f.plan(['example.mjs', 'example.test.mjs'])
await f.call({ operation: 'verify', verification_group: 'checks' })
const read = f.fs.readText
f.fs.readText = async target => {
if (target.path === join(f.root, 'example.mjs')) throw Object.assign(new Error('fixture denied'), { code: 'EACCES' })
return read(target)
}
const view = await f.view()
assert.equal(view.tasks.length, 1)
assert.equal(view.tasks[0].evidence_status, 'blocked')
assert.equal(view.tasks[0].verification_passed, false)
assert.match(view.tasks[0].evidence_problems.join('\n'), /example.mjs.*fixture denied/)
assert.ok(JSON.parse(await f.call({ operation: 'status' })).file_scope_problems.length)
await assert.rejects(f.call({ operation: 'verify', verification_group: 'checks' }), /fixture denied/)
})
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@

不带交付契约的旧台账继续按原快照执行,不自动转换进行中的任务。分析后要求实施时新建关联任务,保留原始调查证据。

> 插件 `0.40.0` 支持 DSH `0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.1-rc.5`,使用 `rc.5` 构建和验证。旧宿主 `0.2.0-rc.2` 继续固定插件 `0.34.1`。
> 插件 `0.40.1` 支持 DSH `0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.1-rc.5`,使用 `rc.5` 构建和验证。旧宿主 `0.2.0-rc.2` 继续固定插件 `0.34.1`。

本次宿主升级的具体影响、验证结果和限制见 [rc.5 适配记录](docs/testing/dsh-rc5-compatibility.md)。

`0.40.0` 的任务材料、台账面板与流程修复见[版本说明](docs/releases/0.40.0.md)。新任务使用 `.dsh/tasks/<任务标识>/`,不自动扫描或迁移旧目录记录。可选的[产品确认清单 Skill 示例](examples/skills/product-clarification/SKILL.md)供自行安装挂载,不会自动成为内置技能。
`0.40.1` 修复文件范围校验、异常台账展示与验证组恢复,见[版本说明](docs/releases/0.40.1.md)。`0.40.0` 的任务材料与台账面板介绍见[上一版说明](docs/releases/0.40.0.md)。新任务使用 `.dsh/tasks/<任务标识>/`,不自动扫描或迁移旧目录记录。可选的[产品确认清单 Skill 示例](examples/skills/product-clarification/SKILL.md)供自行安装挂载,不会自动成为内置技能。


一个项目会有许多需求、分支和会话。团队需要复用编码约定,但不同需求不该被迫走完全相同的步骤。
Expand Down Expand Up @@ -359,6 +359,8 @@ Sonar 的非秘密配置保存在项目 `.dsh/meta.json`;Token **不会写入

新任务按完整实施项交付代码和对应测试,通过 `plan_verification` 声明前端、后端等必要测试组的命令、输入文件与所属实施项,再用 `verify verification_group=...` 分别取得回执。全部必要组有效才可通过任务验证;同组最新失败不会被历史通过覆盖。输入包括相关测试、构建配置和依赖项,覆盖充分性仍由审核核对。

`create`、`scope`、实施计划和验证组中的 `files` 都须逐个填写具体普通文件,不能填写目录或通配符;尚未创建的文件可提前登记。路径后来变成目录、链接或无法读取时,台账保留任务并显示错误路径及所属验证组,`status.file_scope_problems` 提供同样的诊断。使用 `scope`、`plan_execution` 或 `plan_verification` 修正对应清单;多个坏组可逐个修正,旧回执保留但不能据此放行。只读测试配置沿用验证输入策略;交付范围拒绝敏感路径和任务台账自身。不会通过忽略目录来宣称测试有效。

同一轮缺陷返工可用 `revise revision_kind=defect affected_items=[...]` 指定影响范围;共享输入和下游依赖会扩大失效范围。未受影响的回执和逐项审核保留,任务级审核与 Sonar 重新核对。影响未知时省略范围,完整返工;需求和方案变更仍完整失效。缺少新版需求确认结构的活动任务仅可查看,需新建任务继续;历史回执保留,归档任务继续只读。

开发阶段内也可用 defect 修订重新打开已审核项。`plan_execution.files` 是交付范围,`plan_verification.files` 还包括只读取的配置和依赖;共同配置未修改时不必登记为共同交付。`verify` 返回 `group_result` 表示本组结果,`task_verified` 和兼容字段 `ok` 表示任务整体结果,本组通过时可以先逐项验收。
Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

插件分为 host 控制器、agent 工具和浏览器工作台。源码在 `src/`,构建结果在 `lib/`,npm 包包含预构建入口、内置 Skill、预设和提交钩子。

插件 `0.40.0` 以 DSH `0.2.1-rc.5` 构建,明确支持 `rc.3`、`rc.4`、`rc.5`,不自动放行后续预览版。旧宿主 `0.2.0-rc.2` 保持使用插件 `0.34.1`。本版使用声明式 `@deepseek-ai/dsh-agent-preset` 和宿主子代理服务。不再声明旧 `0.1.x` 宿主兼容;旧版文件预设派生测试在缺少旧宿主包时跳过,不能充当当前声明式预设的验收。
插件 `0.40.1` 以 DSH `0.2.1-rc.5` 构建,明确支持 `rc.3`、`rc.4`、`rc.5`,不自动放行后续预览版。旧宿主 `0.2.0-rc.2` 保持使用插件 `0.34.1`。本版使用声明式 `@deepseek-ai/dsh-agent-preset` 和宿主子代理服务。不再声明旧 `0.1.x` 宿主兼容;旧版文件预设派生测试在缺少旧宿主包时跳过,不能充当当前声明式预设的验收。

边界修复回归在 `.boundary-fixes-test.mjs` 与 `.repair-test.mjs`:使用真实临时 Git 仓库、文件和 Node 测试进程;子代理返回与 Sonar HTTP 为可控适配器,不代表模型语义审核质量或真实 Sonar 服务验收。包黑盒检查也从安装产物运行这些用例。

Expand Down
Loading
Loading