From 9087a9cb9f975caf295f58b8a3c0c5d38be2fb61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 14:58:32 +0800 Subject: [PATCH] fix(plan): exclude volatile worktree snapshot from planHash; keep porcelain leading spaces via trimEnd --- README.md | 4 +- lib/git.mjs | 3 +- lib/plan.mjs | 22 +++-- .../brief.md | 97 +++++++++++++++++++ .../knowledge/plan-credential-chain.md | 36 +++++++ shadow-docs/menu.md | 1 + test/cli.test.mjs | 18 ++++ 7 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 shadow-docs/changes/20260917-fix-plan-credential-chain/brief.md create mode 100644 shadow-docs/knowledge/plan-credential-chain.md diff --git a/README.md b/README.md index 42853ae..1c50807 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # shadow-dev-cli -Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute 两段式:`plan` 输出 planHash 并持久化进 brief,`execute` 必须携带确认与匹配的 planHash 才会落盘或调用外部系统,杜绝不可复现的隐式变更。 +Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute 两段式:`plan` 输出 planHash 并持久化进 brief,`execute` 必须携带确认与匹配的 planHash 才会落盘或调用外部系统,杜绝不可复现的隐式变更。planHash 覆盖命令的语义输入,但剥离 plan 自身的副作用(写回 brief 的凭证字段、易变 worktree 快照 `changedFiles`/`clean`)——干净树上 plan→execute 同样可复现。 纯 Node.js(>=20)、零 npm 依赖、单命令入口 `shadow-dev`。 @@ -64,7 +64,7 @@ stdout 的 JSON 契约之外,CLI 在 stderr 渲染一层人类提示:进场 ## 开发 ```bash -npm test # node --test,45 个契约测试覆盖全部命令域与 stderr 人用层 +npm test # node --test,47 个契约测试覆盖全部命令域、stderr 人用层与凭证链 ``` 行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。 diff --git a/lib/git.mjs b/lib/git.mjs index 7cd8e3d..05a87e5 100644 --- a/lib/git.mjs +++ b/lib/git.mjs @@ -1,8 +1,9 @@ import { execFileSync } from 'node:child_process' import { err } from './errors.mjs' +// 只剥尾部换行:porcelain 状态行以空格开头(如 " M path"),全局 trim 会削掉首行前导空格 export function git(r, a, o = {}) { - return execFileSync('git', a, { cwd: r, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...o }).trim() + return execFileSync('git', a, { cwd: r, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...o }).trimEnd() } export function root() { diff --git a/lib/plan.mjs b/lib/plan.mjs index b5ccde5..d6bce44 100644 --- a/lib/plan.mjs +++ b/lib/plan.mjs @@ -9,13 +9,23 @@ export function canon(v) { export function hash(v) { return createHash('sha256').update(canon(v)).digest('hex') } -// plan 对比前剥离 plan 产物本身,保证 plan 与 execute 重算结果一致 +// plan 对比前剥离 plan 产物本身,保证 plan 与 execute 重算结果一致。 +// 剥离边界=「plan 自身的副作用」:workflow 凭证字段,以及 repo 的易变 worktree 快照 +// (changedFiles/clean 会因 plan 写回 brief 而变化;dirty 行为检查仍由 sync 等命令域在 planData 内联执行,安全语义不丢) export function norm(d) { - if (!d || typeof d !== 'object' || !d.brief || !d.brief.workflow) return d - const v = { ...d, brief: { ...d.brief, workflow: { ...d.brief.workflow } } } - delete v.brief.workflow.planHash - delete v.brief.workflow.release - delete v.brief.workflow.issuePlan + if (!d || typeof d !== 'object') return d + let v = d + if (d.brief && d.brief.workflow) { + v = { ...v, brief: { ...d.brief, workflow: { ...d.brief.workflow } } } + delete v.brief.workflow.planHash + delete v.brief.workflow.release + delete v.brief.workflow.issuePlan + } + if (d.repo && (d.repo.changedFiles !== undefined || d.repo.clean !== undefined)) { + v = { ...v, repo: { ...v.repo } } + delete v.repo.changedFiles + delete v.repo.clean + } return v } diff --git a/shadow-docs/changes/20260917-fix-plan-credential-chain/brief.md b/shadow-docs/changes/20260917-fix-plan-credential-chain/brief.md new file mode 100644 index 0000000..4ab1e76 --- /dev/null +++ b/shadow-docs/changes/20260917-fix-plan-credential-chain/brief.md @@ -0,0 +1,97 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20260917-fix-plan-credential-chain", + "type": "fix", + "scope": "lib", + "status": "branched", + "baseBranch": "main", + "branch": "fix/20260917-fix-plan-credential-chain", + "files": [ + "README.md", + "lib/git.mjs", + "lib/plan.mjs", + "shadow-docs/knowledge/plan-credential-chain.md", + "shadow-docs/menu.md", + "test/cli.test.mjs" + ], + "github": { + "repository": "stack-wuh/shadow-dev-cli", + "issue": 5, + "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/5", + "pullRequest": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "pending", + "verifiedCommit": null, + "verifiedAt": null + }, + "workflow": { + "operation": null, + "checkpoint": "issue:5", + "planHash": "a92fc185a61148c301916c72c6501c29fa085e9124e4ab4feaf8b1a181a0108e", + "updatedAt": null, + "lastError": null, + "issuePlan": { + "title": "plan 凭证链修复:execute hash 漂移与 porcelain 首行截字", + "body": "两个 dogfood 实锤缺陷:① plan 回写自脏 worktree,干净树上 plan→execute 必然 PLAN_HASH_INVALID;② git() 全局 trim() 削掉 porcelain 首行前导空格,changedFiles 首条路径截字并污染 DIRTY_WORKTREE 判断。修复:norm() 剥离 repo.changedFiles/clean;git() 改 trimEnd()。详见 shadow-docs/changes/20260917-fix-plan-credential-chain/brief.md", + "labels": [ + "fix" + ] + } + } +} +--- + +# plan 凭证链修复:execute hash 漂移与 porcelain 首行截字 + +## 动机 + +今天两个变更在真实 dogfood 中各踩中一个存量缺陷,均已定位根因并有现场复现路径: + +1. **plan 自脏漂移**:`planDomain` 把 planHash 写回 brief 后 worktree 必然变脏;若 plan 之前树是干净的,`executeDomain` 重算的 planData 里 `repo.changedFiles` 从 `[]` 变成 `[brief]`,hash 必然不匹配 → 干净树上 plan→execute 永远 `PLAN_HASH_INVALID`(建 feature 分支时实测踩中,重跑 plan 才绕过)。 +2. **porcelain 首行截字**:`git()` 对全部输出做 `.trim()`,porcelain 行 ` M path` 以空格开头,首行前导空格被削掉后 `slice(3)` 吃掉路径首字符(release 阶段实测:`shadow-docs` → `hadow-docs`),污染 `changedFiles`,进而使 sync 的 `DIRTY_WORKTREE` 判断和 conflict 比对失真。 + +## 引用规范 + +- norms/code-style.md(通用规范) + - 当前结论: 渐进式治理,一次变更只做直接相关的事;修改旧代码只修与当前改动直接相关的问题。 + - 适用 scope: `lib/plan.mjs`、`lib/git.mjs` +- shadow-docs/knowledge/cli-output-contract.md + - 当前结论: stdout 恒为单行 JSON 契约,语言/环境无关;stderr 人用层与之隔离。 + - 适用 scope: 本变更不改输出通道结构,但新增的 norm() 剥离规则必须保持 planHash 语言无关——由存量逐字节测试保护。 + +## 决策 + +- **选型:** ① `lib/plan.mjs` 的 `norm()` 在哈希前剥离 `repo.changedFiles` 与 `repo.clean`(易变 worktree 快照,本就是 plan 自身写入的副作用源);`repo.head`/`branch`/`root` 保留参与哈希。DIRTY_WORKTREE 行为检查保留在 `sync.planData` 内联执行(plan 与 execute 都会跑,安全语义不丢)。② `lib/git.mjs` 的 `git()` 由 `.trim()` 改 `.trimEnd()`:只剥尾部换行,首行前导空格不再丢失,rev-parse 等其余消费方无影响(它们的输出无前导空白)。 +- **对比方案:** ①的另一路线是让 changedFiles 不出现在各域 planData(分支/审查/提交等域的 plan JSON 会丢失 repo 可见性,agent 消费退化),或把 planHash 挪出 brief(推翻已归档的凭证设计),均否。②的另一路线是 repo() 内特判首行补空格(在错误的层面打补丁,掩盖 git() 的有损 trim),否。 +- **理由:** norm() 的既有职责就是"剥离 plan 产物自身造成的漂移源"(先例:workflow.planHash),本次是同构扩展;两处修复均为最小根因修复,不触任何外部契约。 + +## 任务 + +### Phase 1 — 失败测试(TDD) + +- [x] 契约测试:干净树(shadow-docs 已提交)上 `branch plan` 后立即 `branch execute` 应成功 —— `test/cli.test.mjs` +- [x] 契约测试:仅跟踪文件被修改时 `repo inspect` 的 `changedFiles[0]` 路径完整(首行 ` M` 状态不再截字) —— `test/cli.test.mjs` + +### Phase 2 — 根因修复 + +- [x] `norm()` 剥离 `repo.changedFiles`/`repo.clean`,补注释说明剥离边界 —— `lib/plan.mjs` +- [x] `git()` 改 `trimEnd()`,注释标明 porcelain 前导空格约束 —— `lib/git.mjs` + +### Phase 3 — 回归与知识 + +- [x] 全量回归;README 环境变量/行为无需变更则不动,`plan/execute` 语义描述如有出入顺手校正 —— `README.md` +- [x] 知识卡片 `plan-credential-chain.md`(planHash 输入边界)落盘并加 menu 路由 —— `shadow-docs/knowledge/plan-credential-chain.md` `shadow-docs/menu.md` + +## 结果 + +- 实际耗时: 约 20 分钟 +- 验证: `node --test` 47/47 通过(45 存量 + 2 新增:干净树 plan→execute 可复现、porcelain 首行路径完整);两条新测试在修复前均实测为红;README 计划哈希边界描述与卡片 `plan-credential-chain.md` + menu 路由已落盘。 + +## 知识评估 + +- **预期影响:** 新增 +- **候选卡片:** shadow-docs/knowledge/plan-credential-chain.md(domain: cli-infrastructure,scope: lib/plan.mjs, cli.mjs) +- **理由:** 「planHash 输入 = 语义输入,不含易变 worktree 快照;凭证在 brief、DIRTY 检查在 planData」是本次确立的核心机制边界,后续所有新增域都要遵守,属非显然约束。 diff --git a/shadow-docs/knowledge/plan-credential-chain.md b/shadow-docs/knowledge/plan-credential-chain.md new file mode 100644 index 0000000..5909131 --- /dev/null +++ b/shadow-docs/knowledge/plan-credential-chain.md @@ -0,0 +1,36 @@ +--- +title: plan/execute 凭证链与哈希边界 +domain: cli-infrastructure +keywords: [planHash, plan, execute, norm, changedFiles, 凭证, hash 漂移, porcelain, trim] +scope: [lib/plan.mjs, cli.mjs, lib/git.mjs] +status: active +source: + - changes/20260917-fix-plan-credential-chain/brief.md +verified: 2026-09-17 +--- + +# plan/execute 凭证链与哈希边界 + +## 当前结论 + +`planHash = sha256(canon({command, data: norm(planData)}))`。`norm()` 的剥离边界是**「plan 自身的副作用」**,共两类:① brief `workflow` 中由 plan 写回的凭证字段(`planHash`、`release`、`issuePlan`);② `repo` 的易变 worktree 快照(`changedFiles`、`clean`)——plan 写回 brief 必然改变脏文件集合,不剥离则干净树上 execute 必漂移。`repo.head`/`branch`/`root` 属于语义输入,保留参与哈希。凭证存放:带 `--name` 的域以 brief 中持久化的 planHash 为准;无 brief 的域(`index rebuild`)以 `--plan-hash` 参数为唯一凭证。 + +## 执行约束 + +- 新增命令域导出 `planData` 时,凡包含 `repo` 状态,其 `changedFiles`/`clean` 已被 `norm()` 剥离,无需自行处理;不得为了「哈希稳定」把 head/branch 也剥掉。 +- 脏工作区的行为门禁(如 `sync` 的 `DIRTY_WORKTREE`)必须在命令域 `planData` 内联执行——plan 与 execute 都会跑一次,这是哈希剥离脏状态后唯一的脏检查通道,不得移入哈希输入。 +- `git()` 输出只做尾部裁剪(`trimEnd`):`git status --porcelain` 状态行以空格开头(` M path`),消费方从下标 3 取路径,全局 `trim()` 会截掉首行路径首字符。 +- execute 端若命令的 `planData` 依赖 flag(如 `commit`/`publish`/`release` 的 `--files`/`--message`/`--title`/`--body`),execute 必须传与 plan 完全一致的参数,否则 `PLAN_HASH_INVALID` 属预期行为。 + +## 适用边界 + +适用于所有走 `DOMAINS` 统一通道的 plan/execute 命令域。不适用于直接命令(`change create/approve`、`task set`、`repo inspect`),它们无哈希凭证。 + +## 验证方式 + +`node --test test/cli.test.mjs` 全绿即成立,关键用例:`plan to execute survives the clean-tree write of planHash`(干净树 plan→execute 可复现)、`porcelain first-line status keeps the full path in changed files`(首行路径完整)、`plan persists the hash so execute runs without copying it`(brief 凭证链)。 + +## 关联知识 + +- [CLI 双通道输出契约](cli-output-contract.md) +- [brief.md frontmatter 行尾契约](brief-frontmatter-crlf.md) diff --git a/shadow-docs/menu.md b/shadow-docs/menu.md index 8ecb837..4b1780d 100644 --- a/shadow-docs/menu.md +++ b/shadow-docs/menu.md @@ -8,3 +8,4 @@ |--------|--------|--------| | brief 读写 | brief frontmatter 行尾 CRLF autocrlf BRIEF_FRONTMATTER_REQUIRED planHash | knowledge/brief-frontmatter-crlf.md | | CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET | knowledge/cli-output-contract.md | +| plan/execute 凭证 | planHash hash 漂移 norm changedFiles 脏工作区 porcelain trim 凭证链 | knowledge/plan-credential-chain.md | diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 9630d61..e782128 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -187,6 +187,24 @@ test('file lists normalize Windows backslash separators', () => { assert.deepEqual(JSON.parse(conflict.stdout).data.overlaps, [{ change: 'backslash', files: ['src/example.js'] }]) }) +test('plan to execute survives the clean-tree write of planHash', () => { + const root = fixture() + execFileSync('git', ['add', '--', 'shadow-docs'], { cwd: root }) + execFileSync('git', ['commit', '-m', 'docs: seed briefs'], { cwd: root }) + const planned = run(['branch', 'plan', '--name', 'sample', '--json'], root) + assert.equal(planned.status, 0, planned.stderr) + const result = run(['branch', 'execute', '--name', 'sample', '--plan-hash', JSON.parse(planned.stdout).planHash, '--confirm', '--json'], root) + assert.equal(result.status, 0, JSON.parse(result.stdout).error?.message) +}) + +test('porcelain first-line status keeps the full path in changed files', () => { + const root = fixture() + writeFileSync(join(root, 'README.md'), '# changed\n') + const result = run(['repo', 'inspect', '--json'], root) + assert.equal(result.status, 0, result.stderr) + assert.ok(JSON.parse(result.stdout).data.changedFiles.includes('README.md')) +}) + test('human layer: banners and hints on stderr, stdout contract language-invariant', () => { const root = fixture() const zh = run(['task', 'list', '--name', 'sample', '--lang', 'zh'], root)