From 7197ff46ce5531bd605a0c70dfbe83a381f228ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Mon, 12 Oct 2026 20:00:26 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(bind):=20dirHash=20=E9=80=92=E5=BD=92?= =?UTF-8?q?=E6=94=B6=E9=9B=86=E6=96=87=E4=BB=B6=EF=BC=8C=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=90=AB=E5=AD=90=E7=9B=AE=E5=BD=95=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=9B=20EISDIR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/domains/bind.mjs | 9 +- .../brief.md | 165 ++++++++++++++++++ test/cli.test.mjs | 27 +++ 3 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md diff --git a/lib/domains/bind.mjs b/lib/domains/bind.mjs index 0230f46..828b9fb 100644 --- a/lib/domains/bind.mjs +++ b/lib/domains/bind.mjs @@ -4,7 +4,7 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' import { homedir } from 'node:os' -import { join } from 'node:path' +import { join, relative } from 'node:path' import { err } from '../errors.mjs' import { plan } from '../plan.mjs' import { confirm } from '../input.mjs' @@ -23,7 +23,12 @@ export function loadAdapters(root) { } const sidecarRead = dir => { try { return JSON.parse(readFileSync(join(dir, SIDECAR), 'utf8')) } catch { return null } } -const dirHash = d => createHash('sha256').update(readdirSync(d).sort().map(f => `${f}:${readFileSync(join(d, f), 'utf8')}`).join('\n')).digest('hex') +// 目录内容指纹:递归收集文件,键为相对路径并以正斜杠归一,按字典序拼接后求 sha256。 +// 旧实现直接 readFileSync(readdirSync 的每个条目):技能目录一旦出现子目录(references/ 等按需加载目录), +// 读目录即抛 EISDIR 让 bind execute 整体崩——cpSync 早已递归,唯独这里假设扁平,属实现不一致(issue #62)。 +const collectFiles = (dir, base = dir) => readdirSync(dir, { withFileTypes: true }) + .flatMap(e => e.isDirectory() ? collectFiles(join(dir, e.name), base) : [join(dir, e.name)]) +const dirHash = d => createHash('sha256').update(collectFiles(d).map(f => `${relative(d, f).replaceAll('\\', '/')}:${readFileSync(f, 'utf8')}`).sort().join('\n')).digest('hex') export function planData(_r, o) { const root = resolvedRoot(o) diff --git a/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md new file mode 100644 index 0000000..8f8f713 --- /dev/null +++ b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md @@ -0,0 +1,165 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20261012-fix-bind-dirhash-subdirs", + "type": "fix", + "scope": "bind-domain", + "status": "branched", + "baseBranch": "main", + "branch": "fix/20261012-fix-bind-dirhash-subdirs", + "files": [ + "lib/domains/bind.mjs", + "shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md", + "test/cli.test.mjs" + ], + "github": { + "repository": "stack-wuh/shadow-dev-cli", + "issue": 63, + "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/63", + "pullRequest": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "pending", + "verifiedCommit": null, + "verifiedAt": null + }, + "workflow": { + "operation": null, + "checkpoint": "issue:63", + "planHash": "b25dafe34dd4c43d6835f48e8da44c6c83183dc88673661e8d0d91fb44a43139", + "updatedAt": null, + "lastError": null, + "issuePlan": { + "title": "[fix] bind 的 dirHash 遇技能子目录抛 EISDIR,改为递归收集文件", + "titleRaw": "[fix] bind 的 dirHash 遇技能子目录抛 EISDIR,改为递归收集文件", + "supplement": "关联既有 issue #62:本 change 即其修复载体。", + "body": "## 动机\n`lib/domains/bind.mjs` 的 `dirHash` 用 `readdirSync(skill 目录)` 后对每个条目直接 `readFileSync`。技能目录里一旦出现子目录(按需加载用的 `references/`、`templates/` 等),`readFileSync` 读到目录即抛 `EISDIR: illegal operation on a directory, read`,`bind execute` 整体崩——绑定这条分发路径被一个隐式约束(「技能目录必须扁平」)锁死,而该约束既没写在任何契约里,也没有门拦着。\n\nGitHub issue #62 记录了复刻实测。\n\n## 引用规范\n(brief 缺少该节)\n\n## 决策\n- 修实现而非改约定:`cpSync(..., { recursive: true })` 早已递归复制,产物侧 `pack.mjs` 也递归收录目录——唯独哈希这一处假设扁平,属实现不一致而非设计选择。\n- 键序用相对路径字典序(非 `readdirSync` 返回序),保证 Windows/POSIX 与不同创建序下 sidecar 哈希一致,与既有「确定性输出」契约同源(见 `bind copies managed skills…` 用例里对字典序的断言)。\n- 不改 sidecar schema:只是哈希值算得更全,老宿主重绑一次即刷新。\n\n## 任务\n- [ ] task-1 — `lib/domains/bind.mjs` — dirHash 递归化 + 相对路径字典序归一\n- [ ] task-2 — `test/cli.test.mjs` — 子目录绑定用例 + 「子目录内容变化必须改哈希」的反证 + unbind 递归清除\n- [ ] task-3 — 观察点 — 复现旧崩溃的最小复刻、新用例通过、既有 bind/workflow 用例不回归\n\n## 补充\n关联既有 issue #62:本 change 即其修复载体。\n\n完整 brief:shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md\n\n\n", + "labels": [ + "bug" + ] + }, + "release": { + "files": [ + "lib/domains/bind.mjs", + "shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md", + "test/cli.test.mjs" + ], + "message": "fix(bind): dirHash 递归收集文件,技能目录含子目录不再抛 EISDIR", + "title": "fix(bind): dirHash 递归收集文件,技能目录含子目录不再抛 EISDIR", + "body": "## 变更\n- `lib/domains/bind.mjs`:`dirHash` 改为递归收集文件,键用相对路径并以正斜杠归一、按字典序拼接后求 sha256;附注释说明旧实现为何假设扁平是错的。\n- `test/cli.test.mjs`:新增「技能目录含子目录时哈希与绑定仍成立」用例,含三条反证(子目录内容参与指纹、同内容重绑哈希稳定、`unbind` 递归删除)。\n\n## 影响\n- 行为/触发面变化:`bind execute` 不再因技能目录里存在子目录(`references/`、`templates/` 等)而抛 `EISDIR` 崩;sidecar 的哈希值算得更全(子目录内容进入指纹),因此**首次重绑时托管技能目录的哈希会变**——这是内容指纹的正常刷新,schema 与形态(64 位十六进制)不变。\n- 明确不受影响的范围:命令目录零变化,无新命令键 → 插件 `requiresCommands` 与 `cliVersion` 判定不受影响;`cpSync` 的递归复制行为本就如此,本次只是让指纹与之同口径;`bind plan` 的 blocked/entries 判定不变;宿主既有扁平技能目录的哈希不受影响(无子目录时集合等同)。\n- 解除了插件侧一条落位约束:`shadow-dev-workflow` 的 `norms/pr-message.md` 里「规范/模板不得放 `skills/*/` 子目录」这条是基于本缺陷写的,需等含本修复的版本发布后由插件另立 change 撤除——本 PR 不代劳。\n- 回滚方式:revert 即恢复旧 `dirHash`,新用例会随之判红(正是它锁住的行为)。\n\n## 验证\n- 缺陷复刻(改前):把 `dirHash` 临时换回旧实现跑新用例 → `✖ bind: 技能目录含子目录时哈希与绑定仍成立`,报错原文 `✗ EISDIR: EISDIR`,`bind execute` exit 1。用例确实看得见缺陷。\n- 修复后:`node --test --test-name-pattern bind test/cli.test.mjs` → 3 用例 3 绿 0 红,既有的 `bind copies managed skills with a sidecar…` 不回归。\n- 反证三条:只改 `references/step-01.md` 内容重绑 → 哈希必须变;同内容连续重绑两次 → 哈希逐字相同;`unbind` 后含子目录的技能目录不存在。\n- 未覆盖项(明说):本地只跑了 bind 子集;全量套件与 `windows/macos/ubuntu × node 20/22/24` 矩阵由本 PR 的 `test.yml` 判定。\n\n## 关联\n- Ref https://github.com/stack-wuh/shadow-dev-cli/issues/62(缺陷来源,本 PR 为其修复载体)\n- Closes #63\n- brief: shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md" + } + } +} +--- + +# 20261012-fix-bind-dirhash-subdirs + +## 动机 + +`lib/domains/bind.mjs` 的 `dirHash` 用 `readdirSync(skill 目录)` 后对每个条目直接 `readFileSync`。技能目录里一旦出现子目录(按需加载用的 `references/`、`templates/` 等),`readFileSync` 读到目录即抛 `EISDIR: illegal operation on a directory, read`,`bind execute` 整体崩——绑定这条分发路径被一个隐式约束(「技能目录必须扁平」)锁死,而该约束既没写在任何契约里,也没有门拦着。 + +GitHub issue #62 记录了复刻实测。 + +## 覆盖范围 + +- `lib/domains/bind.mjs`:`dirHash` 改为**递归收集文件**,键用相对路径并以正斜杠归一(跨平台同哈希),条目按路径字典序排序后拼接求 sha256。托管标记语义不变:仍是「绑定后目标目录的内容指纹」,写进 sidecar。 +- `test/cli.test.mjs`:新增用例——技能目录含 `references/` 子目录时 bind plan/execute 均通过、子目录随绑定复制、改子目录内容后 sidecar 哈希必须变化(证明递归真的参与指纹),unbind 递归清除。 +- 不改命令面、不加命令键:插件侧 `requiresCommands` 与 `cliVersion` 契约不受影响。 + +## 决策 + +- 修实现而非改约定:`cpSync(..., { recursive: true })` 早已递归复制,产物侧 `pack.mjs` 也递归收录目录——唯独哈希这一处假设扁平,属实现不一致而非设计选择。 +- 键序用相对路径字典序(非 `readdirSync` 返回序),保证 Windows/POSIX 与不同创建序下 sidecar 哈希一致,与既有「确定性输出」契约同源(见 `bind copies managed skills…` 用例里对字典序的断言)。 +- 不改 sidecar schema:只是哈希值算得更全,老宿主重绑一次即刷新。 + +## 任务 + +- [x] task-1 — `lib/domains/bind.mjs` — dirHash 递归化 + 相对路径字典序归一 +- [x] task-2 — `test/cli.test.mjs` — 子目录绑定用例 + 「子目录内容变化必须改哈希」的反证 + unbind 递归清除 +- [x] task-3 — 观察点 — 复现旧崩溃的最小复刻、新用例通过、既有 bind/workflow 用例不回归 + +## 结果 + +评级 M:行为缺陷修复 + 用例反证,不改命令面。 + +- **缺陷复刻(改前)**:把 `dirHash` 临时换回旧实现后跑新用例 → `✖ bind: 技能目录含子目录时哈希与绑定仍成立`,报错原文 `✗ EISDIR: EISDIR`(`bind execute` exit 1)。证明用例真能看见这个缺陷,不是摆设。 +- **修复后**:`node --test --test-name-pattern bind` → **3 用例 3 绿 0 红**,含既有的 `bind copies managed skills with a sidecar…` 不回归。 +- **子目录参与指纹的反证**:只改 `references/step-01.md` 内容后重绑,sidecar 哈希必须变化(`assert.notEqual`);同内容连续重绑两次哈希逐字相同(字典序键,与 `readdirSync` 返回序无关)。 +- **形态不变**:sidecar 的 `hash` 仍是 `^[0-9a-f]{64}--- +{ + "schema": "shadow-dev/v1", + "name": "20261012-fix-bind-dirhash-subdirs", + "type": "fix", + "scope": "bind-domain", + "status": "branched", + "baseBranch": "main", + "branch": "fix/20261012-fix-bind-dirhash-subdirs", + "files": [ + "lib/domains/bind.mjs", + "shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md", + "test/cli.test.mjs" + ], + "github": { + "repository": "stack-wuh/shadow-dev-cli", + "issue": 63, + "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/63", + "pullRequest": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "pending", + "verifiedCommit": null, + "verifiedAt": null + }, + "workflow": { + "operation": null, + "checkpoint": "issue:63", + "planHash": "ab69056398d4a9f4cab8fcc6495f076421fce90a2cfa09a3f14f6d9b392b82f2", + "updatedAt": null, + "lastError": null, + "issuePlan": { + "title": "[fix] bind 的 dirHash 遇技能子目录抛 EISDIR,改为递归收集文件", + "titleRaw": "[fix] bind 的 dirHash 遇技能子目录抛 EISDIR,改为递归收集文件", + "supplement": "关联既有 issue #62:本 change 即其修复载体。", + "body": "## 动机\n`lib/domains/bind.mjs` 的 `dirHash` 用 `readdirSync(skill 目录)` 后对每个条目直接 `readFileSync`。技能目录里一旦出现子目录(按需加载用的 `references/`、`templates/` 等),`readFileSync` 读到目录即抛 `EISDIR: illegal operation on a directory, read`,`bind execute` 整体崩——绑定这条分发路径被一个隐式约束(「技能目录必须扁平」)锁死,而该约束既没写在任何契约里,也没有门拦着。\n\nGitHub issue #62 记录了复刻实测。\n\n## 引用规范\n(brief 缺少该节)\n\n## 决策\n- 修实现而非改约定:`cpSync(..., { recursive: true })` 早已递归复制,产物侧 `pack.mjs` 也递归收录目录——唯独哈希这一处假设扁平,属实现不一致而非设计选择。\n- 键序用相对路径字典序(非 `readdirSync` 返回序),保证 Windows/POSIX 与不同创建序下 sidecar 哈希一致,与既有「确定性输出」契约同源(见 `bind copies managed skills…` 用例里对字典序的断言)。\n- 不改 sidecar schema:只是哈希值算得更全,老宿主重绑一次即刷新。\n\n## 任务\n- [ ] task-1 — `lib/domains/bind.mjs` — dirHash 递归化 + 相对路径字典序归一\n- [ ] task-2 — `test/cli.test.mjs` — 子目录绑定用例 + 「子目录内容变化必须改哈希」的反证 + unbind 递归清除\n- [ ] task-3 — 观察点 — 复现旧崩溃的最小复刻、新用例通过、既有 bind/workflow 用例不回归\n\n## 补充\n关联既有 issue #62:本 change 即其修复载体。\n\n完整 brief:shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md\n\n\n", + "labels": [ + "bug" + ] + } + } +} +--- + +# 20261012-fix-bind-dirhash-subdirs + +## 动机 + +`lib/domains/bind.mjs` 的 `dirHash` 用 `readdirSync(skill 目录)` 后对每个条目直接 `readFileSync`。技能目录里一旦出现子目录(按需加载用的 `references/`、`templates/` 等),`readFileSync` 读到目录即抛 `EISDIR: illegal operation on a directory, read`,`bind execute` 整体崩——绑定这条分发路径被一个隐式约束(「技能目录必须扁平」)锁死,而该约束既没写在任何契约里,也没有门拦着。 + +GitHub issue #62 记录了复刻实测。 + +## 覆盖范围 + +- `lib/domains/bind.mjs`:`dirHash` 改为**递归收集文件**,键用相对路径并以正斜杠归一(跨平台同哈希),条目按路径字典序排序后拼接求 sha256。托管标记语义不变:仍是「绑定后目标目录的内容指纹」,写进 sidecar。 +- `test/cli.test.mjs`:新增用例——技能目录含 `references/` 子目录时 bind plan/execute 均通过、子目录随绑定复制、改子目录内容后 sidecar 哈希必须变化(证明递归真的参与指纹),unbind 递归清除。 +- 不改命令面、不加命令键:插件侧 `requiresCommands` 与 `cliVersion` 契约不受影响。 + +## 决策 + +- 修实现而非改约定:`cpSync(..., { recursive: true })` 早已递归复制,产物侧 `pack.mjs` 也递归收录目录——唯独哈希这一处假设扁平,属实现不一致而非设计选择。 +- 键序用相对路径字典序(非 `readdirSync` 返回序),保证 Windows/POSIX 与不同创建序下 sidecar 哈希一致,与既有「确定性输出」契约同源(见 `bind copies managed skills…` 用例里对字典序的断言)。 +- 不改 sidecar schema:只是哈希值算得更全,老宿主重绑一次即刷新。 + +## 任务 + +- [ ] task-1 — `lib/domains/bind.mjs` — dirHash 递归化 + 相对路径字典序归一 +- [ ] task-2 — `test/cli.test.mjs` — 子目录绑定用例 + 「子目录内容变化必须改哈希」的反证 + unbind 递归清除 +- [ ] task-3 — 观察点 — 复现旧崩溃的最小复刻、新用例通过、既有 bind/workflow 用例不回归 + +## 结果 + +,schema 未动;`unbind` 递归删除含子目录的技能目录。 +- 命令面零变化:未新增命令键 → 插件 `requiresCommands` 与 `cliVersion` 判定不受影响。 +- 门槛:本地跑的是 bind 子集;全量套件与三平台矩阵由 `test.yml`(`pull_request` 触发)判。 +- 后继(不在本 PR):本修复解除了插件侧「规范/模板不得放 `skills/*/` 子目录」的落位约束(写在其 `norms/pr-message.md`)。撤除那条文本要等含本修复的 CLI 版本发布后才生效,需另立插件 change。 diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 8a6837f..e64a915 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -1209,6 +1209,33 @@ test('bind copies managed skills with a sidecar, blocks unmanaged targets, unbin assert.equal(run(['bind', 'unbind', '--host', 'claude-code', '--confirm', '--json'], root, env).status, 1, 'second unbind has nothing to remove') }) +test('bind: 技能目录含子目录时哈希与绑定仍成立(旧实现抛 EISDIR,issue #62)', () => { + const root = mkdtempSync(join(tmpdir(), 'bind-sub-')) + const artifact = buildArtifact(join(root, 'artifact'), '6.3.0') + mkdirSync(join(artifact, 'skills', 'shadow-dev-propose', 'references'), { recursive: true }) + writeFileSync(join(artifact, 'skills', 'shadow-dev-propose', 'references', 'step-01.md'), '# step one\n') + const env = wfEnv(root) + assert.equal(run(['workflow', 'link', '--dir', artifact, '--confirm', '--json'], root, env).status, 0) + const p = run(['bind', 'plan', '--host', 'claude-code', '--json'], root, env) + assert.equal(p.status, 0, p.stderr) + const ex = run(['bind', 'execute', '--host', 'claude-code', '--plan-hash', JSON.parse(p.stdout).planHash, '--confirm', '--json'], root, env) + assert.equal(ex.status, 0, ex.stderr) + const target = join(root, 'home', '.claude', 'skills', 'shadow-dev-propose') + assert.equal(readFileSync(join(target, 'references', 'step-01.md'), 'utf8'), '# step one\n', '子目录随绑定递归复制') + const hashOf = () => JSON.parse(readFileSync(join(root, 'home', '.claude', 'skills', '.shadow-dev-workflow.json'), 'utf8')).skills['shadow-dev-propose'].hash + const h1 = hashOf() + assert.match(h1, /^[0-9a-f]{64}$/, 'sidecar 哈希形态不变') + // 反证:子目录内容必须参与指纹——只改 references 内文件,哈希必须变 + writeFileSync(join(artifact, 'skills', 'shadow-dev-propose', 'references', 'step-01.md'), '# step one edited\n') + const p2 = JSON.parse(run(['bind', 'plan', '--host', 'claude-code', '--json'], root, env).stdout) + assert.equal(run(['bind', 'execute', '--host', 'claude-code', '--plan-hash', p2.planHash, '--confirm', '--json'], root, env).status, 0) + assert.notEqual(hashOf(), h1, '子目录内容变化必须反映到哈希') + // 确定性:同内容重绑两次,哈希逐字相同(与 readdir 返回序无关) + const rebind = () => { const q = JSON.parse(run(['bind', 'plan', '--host', 'claude-code', '--json'], root, env).stdout); assert.equal(run(['bind', 'execute', '--host', 'claude-code', '--plan-hash', q.planHash, '--confirm', '--json'], root, env).status, 0); return hashOf() } + assert.equal(rebind(), rebind(), '同内容重绑哈希必须稳定') + assert.equal(run(['bind', 'unbind', '--host', 'claude-code', '--confirm', '--json'], root, env).status, 0) + assert.equal(existsSync(target), false, 'unbind 递归删除含子目录的技能目录') +}) // ---- .shadow-dev/ config layers (20261005-feature-shadow-dev-config) ---- function withProjectConfig(root, content) { mkdirSync(join(root, '.shadow-dev'), { recursive: true }) From 3a5cf48b14c5246e948e432f3798a23e30638a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Mon, 12 Oct 2026 20:00:36 +0800 Subject: [PATCH 2/5] =?UTF-8?q?docs(shadow):=20brief=20=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E6=80=81=E2=80=94=E2=80=94published,=20PR=20#64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/20261012-fix-bind-dirhash-subdirs/brief.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md index 8f8f713..3ecd7c9 100644 --- a/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md +++ b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md @@ -4,7 +4,7 @@ "name": "20261012-fix-bind-dirhash-subdirs", "type": "fix", "scope": "bind-domain", - "status": "branched", + "status": "published", "baseBranch": "main", "branch": "fix/20261012-fix-bind-dirhash-subdirs", "files": [ @@ -16,8 +16,8 @@ "repository": "stack-wuh/shadow-dev-cli", "issue": 63, "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/63", - "pullRequest": null, - "pullRequestUrl": null + "pullRequest": 64, + "pullRequestUrl": "https://github.com/stack-wuh/shadow-dev-cli/pull/64" }, "review": { "conclusion": "pending", @@ -26,7 +26,7 @@ }, "workflow": { "operation": null, - "checkpoint": "issue:63", + "checkpoint": "pr:64", "planHash": "b25dafe34dd4c43d6835f48e8da44c6c83183dc88673661e8d0d91fb44a43139", "updatedAt": null, "lastError": null, From 3167a1ab2669c7fba9a87bcf2a84e4f25a5230c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Mon, 12 Oct 2026 20:03:36 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(bind):=20=E9=87=8D=E5=BB=BA=E8=A2=AB?= =?UTF-8?q?=E6=94=B9=E5=86=99=E6=8D=9F=E5=9D=8F=E7=9A=84=20brief=20?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E5=B9=B6=E5=9B=9E=E5=A1=AB=20M=20=E7=BA=A7?= =?UTF-8?q?=E8=A7=82=E5=AF=9F=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../brief.md | 125 +++++------------- 1 file changed, 30 insertions(+), 95 deletions(-) diff --git a/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md index 3ecd7c9..2e3ba79 100644 --- a/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md +++ b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md @@ -4,7 +4,7 @@ "name": "20261012-fix-bind-dirhash-subdirs", "type": "fix", "scope": "bind-domain", - "status": "published", + "status": "reviewed", "baseBranch": "main", "branch": "fix/20261012-fix-bind-dirhash-subdirs", "files": [ @@ -20,14 +20,14 @@ "pullRequestUrl": "https://github.com/stack-wuh/shadow-dev-cli/pull/64" }, "review": { - "conclusion": "pending", - "verifiedCommit": null, - "verifiedAt": null + "conclusion": "passed", + "verifiedCommit": "3a5cf48b14c5246e948e432f3798a23e30638a22", + "verifiedAt": "2026-10-12T12:03:34.783Z" }, "workflow": { "operation": null, "checkpoint": "pr:64", - "planHash": "b25dafe34dd4c43d6835f48e8da44c6c83183dc88673661e8d0d91fb44a43139", + "planHash": "e9215fe7d47a55662ee97533ed3d64f2490c93b0cb2c28768e0796869375a0da", "updatedAt": null, "lastError": null, "issuePlan": { @@ -48,118 +48,53 @@ "message": "fix(bind): dirHash 递归收集文件,技能目录含子目录不再抛 EISDIR", "title": "fix(bind): dirHash 递归收集文件,技能目录含子目录不再抛 EISDIR", "body": "## 变更\n- `lib/domains/bind.mjs`:`dirHash` 改为递归收集文件,键用相对路径并以正斜杠归一、按字典序拼接后求 sha256;附注释说明旧实现为何假设扁平是错的。\n- `test/cli.test.mjs`:新增「技能目录含子目录时哈希与绑定仍成立」用例,含三条反证(子目录内容参与指纹、同内容重绑哈希稳定、`unbind` 递归删除)。\n\n## 影响\n- 行为/触发面变化:`bind execute` 不再因技能目录里存在子目录(`references/`、`templates/` 等)而抛 `EISDIR` 崩;sidecar 的哈希值算得更全(子目录内容进入指纹),因此**首次重绑时托管技能目录的哈希会变**——这是内容指纹的正常刷新,schema 与形态(64 位十六进制)不变。\n- 明确不受影响的范围:命令目录零变化,无新命令键 → 插件 `requiresCommands` 与 `cliVersion` 判定不受影响;`cpSync` 的递归复制行为本就如此,本次只是让指纹与之同口径;`bind plan` 的 blocked/entries 判定不变;宿主既有扁平技能目录的哈希不受影响(无子目录时集合等同)。\n- 解除了插件侧一条落位约束:`shadow-dev-workflow` 的 `norms/pr-message.md` 里「规范/模板不得放 `skills/*/` 子目录」这条是基于本缺陷写的,需等含本修复的版本发布后由插件另立 change 撤除——本 PR 不代劳。\n- 回滚方式:revert 即恢复旧 `dirHash`,新用例会随之判红(正是它锁住的行为)。\n\n## 验证\n- 缺陷复刻(改前):把 `dirHash` 临时换回旧实现跑新用例 → `✖ bind: 技能目录含子目录时哈希与绑定仍成立`,报错原文 `✗ EISDIR: EISDIR`,`bind execute` exit 1。用例确实看得见缺陷。\n- 修复后:`node --test --test-name-pattern bind test/cli.test.mjs` → 3 用例 3 绿 0 红,既有的 `bind copies managed skills with a sidecar…` 不回归。\n- 反证三条:只改 `references/step-01.md` 内容重绑 → 哈希必须变;同内容连续重绑两次 → 哈希逐字相同;`unbind` 后含子目录的技能目录不存在。\n- 未覆盖项(明说):本地只跑了 bind 子集;全量套件与 `windows/macos/ubuntu × node 20/22/24` 矩阵由本 PR 的 `test.yml` 判定。\n\n## 关联\n- Ref https://github.com/stack-wuh/shadow-dev-cli/issues/62(缺陷来源,本 PR 为其修复载体)\n- Closes #63\n- brief: shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md" + }, + "commit": { + "files": [ + "shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md" + ], + "message": "fix(bind): 重建被改写损坏的 brief 正文并回填 M 级观察点" } + }, + "knowledge": { + "action": "无需变更", + "target": "knowledge/distribution-capability-contract.md", + "reason": "缺陷由代码与用例锁定;长期事实仍是产物递归收录与宿主绑定需内容指纹可复现,不新建卡" } } --- - # 20261012-fix-bind-dirhash-subdirs ## 动机 -`lib/domains/bind.mjs` 的 `dirHash` 用 `readdirSync(skill 目录)` 后对每个条目直接 `readFileSync`。技能目录里一旦出现子目录(按需加载用的 `references/`、`templates/` 等),`readFileSync` 读到目录即抛 `EISDIR: illegal operation on a directory, read`,`bind execute` 整体崩——绑定这条分发路径被一个隐式约束(「技能目录必须扁平」)锁死,而该约束既没写在任何契约里,也没有门拦着。 - -GitHub issue #62 记录了复刻实测。 +`lib/domains/bind.mjs` 的 `dirHash` 用 `readdirSync(skill 目录)` 后对每个条目直接 `readFileSync`。技能目录里一旦出现子目录(按需加载用的 `references/`、`templates/` 等),`readFileSync` 读到目录即抛 `EISDIR: illegal operation on a directory, read`,`bind execute` 整体崩——绑定这条分发路径被一个隐式约束(技能目录必须扁平)锁死,而该约束既没写在任何契约里,也没有门拦着。GitHub issue #62 记录了复刻实测。 ## 覆盖范围 -- `lib/domains/bind.mjs`:`dirHash` 改为**递归收集文件**,键用相对路径并以正斜杠归一(跨平台同哈希),条目按路径字典序排序后拼接求 sha256。托管标记语义不变:仍是「绑定后目标目录的内容指纹」,写进 sidecar。 -- `test/cli.test.mjs`:新增用例——技能目录含 `references/` 子目录时 bind plan/execute 均通过、子目录随绑定复制、改子目录内容后 sidecar 哈希必须变化(证明递归真的参与指纹),unbind 递归清除。 +- `lib/domains/bind.mjs`:`dirHash` 改为递归收集文件,键用相对路径并以正斜杠归一(跨平台同哈希),按字典序拼接后求 sha256。托管标记语义不变:仍是绑定后目标目录的内容指纹,写进 sidecar。 +- `test/cli.test.mjs`:新增技能目录含子目录的用例——plan/execute 均通过、子目录随绑定复制、只改子目录内容时哈希必须变化、同内容重绑哈希稳定、`unbind` 递归删除。 - 不改命令面、不加命令键:插件侧 `requiresCommands` 与 `cliVersion` 契约不受影响。 ## 决策 - 修实现而非改约定:`cpSync(..., { recursive: true })` 早已递归复制,产物侧 `pack.mjs` 也递归收录目录——唯独哈希这一处假设扁平,属实现不一致而非设计选择。 -- 键序用相对路径字典序(非 `readdirSync` 返回序),保证 Windows/POSIX 与不同创建序下 sidecar 哈希一致,与既有「确定性输出」契约同源(见 `bind copies managed skills…` 用例里对字典序的断言)。 -- 不改 sidecar schema:只是哈希值算得更全,老宿主重绑一次即刷新。 +- 键序用相对路径字典序(非 `readdirSync` 返回序),保证 Windows/POSIX 与不同创建序下 sidecar 哈希一致,与既有确定性输出契约同源。 +- 不改 sidecar schema:哈希值算得更全,老宿主重绑一次即刷新。 ## 任务 - [x] task-1 — `lib/domains/bind.mjs` — dirHash 递归化 + 相对路径字典序归一 -- [x] task-2 — `test/cli.test.mjs` — 子目录绑定用例 + 「子目录内容变化必须改哈希」的反证 + unbind 递归清除 -- [x] task-3 — 观察点 — 复现旧崩溃的最小复刻、新用例通过、既有 bind/workflow 用例不回归 +- [x] task-2 — `test/cli.test.mjs` — 子目录绑定用例 + 哈希参与与稳定两条反证 + unbind 递归清除 +- [x] task-3 — 观察点 — 旧实现复刻崩溃、新用例判绿、既有 bind 用例不回归 ## 结果 评级 M:行为缺陷修复 + 用例反证,不改命令面。 -- **缺陷复刻(改前)**:把 `dirHash` 临时换回旧实现后跑新用例 → `✖ bind: 技能目录含子目录时哈希与绑定仍成立`,报错原文 `✗ EISDIR: EISDIR`(`bind execute` exit 1)。证明用例真能看见这个缺陷,不是摆设。 -- **修复后**:`node --test --test-name-pattern bind` → **3 用例 3 绿 0 红**,含既有的 `bind copies managed skills with a sidecar…` 不回归。 -- **子目录参与指纹的反证**:只改 `references/step-01.md` 内容后重绑,sidecar 哈希必须变化(`assert.notEqual`);同内容连续重绑两次哈希逐字相同(字典序键,与 `readdirSync` 返回序无关)。 -- **形态不变**:sidecar 的 `hash` 仍是 `^[0-9a-f]{64}--- -{ - "schema": "shadow-dev/v1", - "name": "20261012-fix-bind-dirhash-subdirs", - "type": "fix", - "scope": "bind-domain", - "status": "branched", - "baseBranch": "main", - "branch": "fix/20261012-fix-bind-dirhash-subdirs", - "files": [ - "lib/domains/bind.mjs", - "shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md", - "test/cli.test.mjs" - ], - "github": { - "repository": "stack-wuh/shadow-dev-cli", - "issue": 63, - "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/63", - "pullRequest": null, - "pullRequestUrl": null - }, - "review": { - "conclusion": "pending", - "verifiedCommit": null, - "verifiedAt": null - }, - "workflow": { - "operation": null, - "checkpoint": "issue:63", - "planHash": "ab69056398d4a9f4cab8fcc6495f076421fce90a2cfa09a3f14f6d9b392b82f2", - "updatedAt": null, - "lastError": null, - "issuePlan": { - "title": "[fix] bind 的 dirHash 遇技能子目录抛 EISDIR,改为递归收集文件", - "titleRaw": "[fix] bind 的 dirHash 遇技能子目录抛 EISDIR,改为递归收集文件", - "supplement": "关联既有 issue #62:本 change 即其修复载体。", - "body": "## 动机\n`lib/domains/bind.mjs` 的 `dirHash` 用 `readdirSync(skill 目录)` 后对每个条目直接 `readFileSync`。技能目录里一旦出现子目录(按需加载用的 `references/`、`templates/` 等),`readFileSync` 读到目录即抛 `EISDIR: illegal operation on a directory, read`,`bind execute` 整体崩——绑定这条分发路径被一个隐式约束(「技能目录必须扁平」)锁死,而该约束既没写在任何契约里,也没有门拦着。\n\nGitHub issue #62 记录了复刻实测。\n\n## 引用规范\n(brief 缺少该节)\n\n## 决策\n- 修实现而非改约定:`cpSync(..., { recursive: true })` 早已递归复制,产物侧 `pack.mjs` 也递归收录目录——唯独哈希这一处假设扁平,属实现不一致而非设计选择。\n- 键序用相对路径字典序(非 `readdirSync` 返回序),保证 Windows/POSIX 与不同创建序下 sidecar 哈希一致,与既有「确定性输出」契约同源(见 `bind copies managed skills…` 用例里对字典序的断言)。\n- 不改 sidecar schema:只是哈希值算得更全,老宿主重绑一次即刷新。\n\n## 任务\n- [ ] task-1 — `lib/domains/bind.mjs` — dirHash 递归化 + 相对路径字典序归一\n- [ ] task-2 — `test/cli.test.mjs` — 子目录绑定用例 + 「子目录内容变化必须改哈希」的反证 + unbind 递归清除\n- [ ] task-3 — 观察点 — 复现旧崩溃的最小复刻、新用例通过、既有 bind/workflow 用例不回归\n\n## 补充\n关联既有 issue #62:本 change 即其修复载体。\n\n完整 brief:shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md\n\n\n", - "labels": [ - "bug" - ] - } - } -} ---- - -# 20261012-fix-bind-dirhash-subdirs - -## 动机 - -`lib/domains/bind.mjs` 的 `dirHash` 用 `readdirSync(skill 目录)` 后对每个条目直接 `readFileSync`。技能目录里一旦出现子目录(按需加载用的 `references/`、`templates/` 等),`readFileSync` 读到目录即抛 `EISDIR: illegal operation on a directory, read`,`bind execute` 整体崩——绑定这条分发路径被一个隐式约束(「技能目录必须扁平」)锁死,而该约束既没写在任何契约里,也没有门拦着。 - -GitHub issue #62 记录了复刻实测。 - -## 覆盖范围 - -- `lib/domains/bind.mjs`:`dirHash` 改为**递归收集文件**,键用相对路径并以正斜杠归一(跨平台同哈希),条目按路径字典序排序后拼接求 sha256。托管标记语义不变:仍是「绑定后目标目录的内容指纹」,写进 sidecar。 -- `test/cli.test.mjs`:新增用例——技能目录含 `references/` 子目录时 bind plan/execute 均通过、子目录随绑定复制、改子目录内容后 sidecar 哈希必须变化(证明递归真的参与指纹),unbind 递归清除。 -- 不改命令面、不加命令键:插件侧 `requiresCommands` 与 `cliVersion` 契约不受影响。 - -## 决策 - -- 修实现而非改约定:`cpSync(..., { recursive: true })` 早已递归复制,产物侧 `pack.mjs` 也递归收录目录——唯独哈希这一处假设扁平,属实现不一致而非设计选择。 -- 键序用相对路径字典序(非 `readdirSync` 返回序),保证 Windows/POSIX 与不同创建序下 sidecar 哈希一致,与既有「确定性输出」契约同源(见 `bind copies managed skills…` 用例里对字典序的断言)。 -- 不改 sidecar schema:只是哈希值算得更全,老宿主重绑一次即刷新。 - -## 任务 - -- [ ] task-1 — `lib/domains/bind.mjs` — dirHash 递归化 + 相对路径字典序归一 -- [ ] task-2 — `test/cli.test.mjs` — 子目录绑定用例 + 「子目录内容变化必须改哈希」的反证 + unbind 递归清除 -- [ ] task-3 — 观察点 — 复现旧崩溃的最小复刻、新用例通过、既有 bind/workflow 用例不回归 - -## 结果 - -,schema 未动;`unbind` 递归删除含子目录的技能目录。 -- 命令面零变化:未新增命令键 → 插件 `requiresCommands` 与 `cliVersion` 判定不受影响。 -- 门槛:本地跑的是 bind 子集;全量套件与三平台矩阵由 `test.yml`(`pull_request` 触发)判。 -- 后继(不在本 PR):本修复解除了插件侧「规范/模板不得放 `skills/*/` 子目录」的落位约束(写在其 `norms/pr-message.md`)。撤除那条文本要等含本修复的 CLI 版本发布后才生效,需另立插件 change。 +- 缺陷复刻(改前):把 `dirHash` 临时换回旧实现再跑新用例,结果 `✖ bind: 技能目录含子目录时哈希与绑定仍成立`,报错原文 `✗ EISDIR: EISDIR`,`bind execute` exit 1。用例确实看得见缺陷。 +- 修复后:`node --test --test-name-pattern bind test/cli.test.mjs` → 3 用例 3 绿 0 红,既有的 bind 托管用例不回归。 +- 子目录参与指纹:只改 `references/step-01.md` 内容后重绑,sidecar 哈希必须变化;同内容连续重绑两次哈希逐字相同;sidecar 的 hash 形态仍是 64 位十六进制,schema 未动。 +- 命令面零变化:无新命令键 → 插件 `requiresCommands` / `cliVersion` 判定不受影响。 +- 门槛:本地只跑 bind 子集;全量套件与 windows/macos/ubuntu × node 20/22/24 矩阵由本 PR 的 test.yml 判定。 +- 后继(不在本 PR):本修复解除插件侧「规范/模板不得放 skills 子目录」的落位约束(写在其 `norms/pr-message.md`),撤除该文本需等含本修复的 CLI 版本发布后另立插件 change。 +- 事故留痕:本 brief 一度被我改写时命中 frontmatter 内的转义副本(workflow.issuePlan.body),导致重复块与截断段落,已整文件重建。教训:brief 正文不得用字符串替换改写,要改就整份重写或走 CLI 写入路径。 From fc3cdbb0f0fc3e794b821108b919a0a6e0786760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Mon, 12 Oct 2026 20:03:36 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(shadow):=20brief=20=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E6=80=81=E2=80=94=E2=80=94committed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/20261012-fix-bind-dirhash-subdirs/brief.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md index 2e3ba79..210c5df 100644 --- a/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md +++ b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md @@ -4,7 +4,7 @@ "name": "20261012-fix-bind-dirhash-subdirs", "type": "fix", "scope": "bind-domain", - "status": "reviewed", + "status": "committed", "baseBranch": "main", "branch": "fix/20261012-fix-bind-dirhash-subdirs", "files": [ @@ -26,7 +26,7 @@ }, "workflow": { "operation": null, - "checkpoint": "pr:64", + "checkpoint": "3167a1ab2669c7fba9a87bcf2a84e4f25a5230c4", "planHash": "e9215fe7d47a55662ee97533ed3d64f2490c93b0cb2c28768e0796869375a0da", "updatedAt": null, "lastError": null, From 86ab1b290d13be8e07c6f78bddf4da3960ce3170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Mon, 12 Oct 2026 20:04:09 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs(shadow):=20brief=20=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E6=80=81=E2=80=94=E2=80=94published,=20PR=20#64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/20261012-fix-bind-dirhash-subdirs/brief.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md index 210c5df..faaaad6 100644 --- a/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md +++ b/shadow-docs/changes/20261012-fix-bind-dirhash-subdirs/brief.md @@ -4,7 +4,7 @@ "name": "20261012-fix-bind-dirhash-subdirs", "type": "fix", "scope": "bind-domain", - "status": "committed", + "status": "published", "baseBranch": "main", "branch": "fix/20261012-fix-bind-dirhash-subdirs", "files": [ @@ -26,8 +26,8 @@ }, "workflow": { "operation": null, - "checkpoint": "3167a1ab2669c7fba9a87bcf2a84e4f25a5230c4", - "planHash": "e9215fe7d47a55662ee97533ed3d64f2490c93b0cb2c28768e0796869375a0da", + "checkpoint": "pr:64", + "planHash": "74c90b188b297d241380381b2d7c4ba048ee5c1e2a67f0476a344b1426478417", "updatedAt": null, "lastError": null, "issuePlan": {