From 13aa3a1a8cee121f9a6269f63cbf64027e9e87f2 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 03:58:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(onboarding/parity):=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=8C=85=E6=94=B9=E5=86=99=E6=89=98=E7=AE=A1=E5=9D=97?= =?UTF-8?q?=E5=89=8D=E6=A3=80=E6=B5=8B=E5=9D=97=E5=A4=96=E6=9C=AA=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=94=B9=E5=8A=A8=EF=BC=8C=E7=BB=99=E5=87=BA=E6=B7=B7?= =?UTF-8?q?=E5=90=88=E6=96=87=E4=BB=B6=E9=A2=84=E8=AD=A6=EF=BC=88P10?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-10-01 enterprise 的 git-workflow.md 在有项目未提交改写时被升级了托管块, 同一文件混有炼境与项目两种来源的改动,按「禁止字符串手术」无法拆分提交,只能交人定案。 管家应在制造混合文件时明确告知,而不是事后由人发现。 - TemplateResult 新增可选 warning(序列化时缺省省略,前端类型同步) - apply_onboarding_pack 在模板分发与 buff 蓝图规则同步前后夹一层检测:写入前对 AGENTS.md、docs/ai-context/git-workflow.md、CLAUDE.md 跑 git status,有改动再取 HEAD:./<文件>(不在 HEAD 中按空内容算),两边去掉 ONBOARDING_MANAGED 与 BLUEPRINT_MANAGED 块后比较(忽略行尾与空行);写入后只对本次确被改写的文件预警, 文案含文件名与「块外有未提交改动,升级后将成为混合文件」 - 只提示不阻断,块外内容照旧不动;git 不可用、非仓库或命令失败一律静默; git status 带 --no-optional-locks,不抢 index.lock - CLAUDE.md 只由蓝图规则同步改写,不在模板结果里,预警时补一条结果 - BLUEPRINT_MANAGED 标记提为 blueprint.rs 的 pub(crate) 常量,检测与 upsert 共用 - MCP 单项目输出在该文件行下加预警行,批量输出追加混合文件清单;工具描述同步; 炼境 UI 接入包结果行下显示预警 - 契约文档 project-context.yaml 同步;蓝图 P10 标 ✅ 并记录验证与已知限制 验证:cargo test --locked lib 270 passed / 5 ignored;新增 3 个测试(纯函数、临时 git 仓库三种情形、非 git 目录),变异自检(探测强制返回 true 时两个场景测试均失败); pnpm typecheck 与 pnpm test 通过。 Rules-Applied: R01,R06,R12 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- .blueprint/manifest.yaml | 6 +- .blueprint/modules/agent-client-parity.md | 9 +- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/blueprint.rs | 11 +- src-tauri/src/commands/onboarding.rs | 472 +++++++++++++++++--- src-tauri/src/mcp_tools.rs | 28 +- src/components/dashboard/BlueprintModal.tsx | 15 +- src/lib/commands.ts | 2 + 8 files changed, 452 insertions(+), 93 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index aff625b..b1a2262 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -511,9 +511,9 @@ modules: name: 三端客户端对等 area: infra status: in_progress - progress: 50 - updated: 2026-10-02 - note: "P1/P2/P3/P3b/P9 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等。待做 P4 探针固化(依赖 architecture-evolution A1)、P5–P8、P10。" + progress: 55 + updated: 2026-10-03 + note: "P1/P2/P3/P3b/P9/P10 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等;接入包改写块外带未提交改动的托管块文件时给出混合文件预警。待做 P4 探针固化(依赖 architecture-evolution A1)、P5–P8。" position: [3500, 950] - id: architecture-evolution diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index c6f7bb4..59af8a5 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -172,10 +172,13 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - verification: "2026-10-02:新增 lib.rs write_if_content_changed(忽略行尾比较,内容不变不写);sync_blueprint_rules 的 CONVENTIONS 与托管块、context.json、.claude/settings.json、Codex 托管块统一改用它,托管块未变时返回 unchanged。cargo test 246 passed。实测 enterprise:注入后 .codex/config.toml 末尾空行 16→0;再次注入四个文件修改时间均不变。已存在的伪改动(context.json、CONVENTIONS.md)需在子项目里由人刷新一次索引。 对抗审查(9 个 agent,2 条成立、5 条驳回)后补:JSON 写入(settings.json、context.json)改为按语义比较——Claude Code 改写后键序与末尾换行不同,按文本比较仍会整文件重排;CI workflow 的 plan 改为忽略行尾判 noop(原按字节比较,CRLF 检出的同内容文件会被判 update 并请用户确认一次无变化写入),apply 与 generate_workflow 落盘同样只在内容变化时写;同类遗漏 agent_infra 生成文件与 notes-archive 回填也统一改用 write_if_content_changed。cargo test 245 passed(新增 JSON 语义与 CI CRLF 回归断言)。" -### 📋 P10 — 接入包升级前检测目标文件的块外未提交改动 -- status: todo +### ✅ P10 — 接入包升级前检测目标文件的块外未提交改动 +- status: done - complexity: S -- files: src-tauri/src/commands/onboarding.rs(apply 预览 / 结果) +- files: src-tauri/src/commands/onboarding.rs(apply 预览 / 结果), src-tauri/src/commands/blueprint.rs(BLUEPRINT_MANAGED 标记常量), src-tauri/src/mcp_tools.rs(MCP 输出), src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx, docs/ai-context/project-context.yaml - depends: P8 - acceptance: "agent_verify: [cargo test] 目标文件在 git 中有托管块之外的未提交改动时,apply_onboarding_pack 的预览与结果对该文件给出 warning(含文件名与「块外有未提交改动,升级后将成为混合文件」提示),默认仍只改托管块、不改块外内容;无未提交改动或非 git 仓库时无此提示。" - 为什么:2026-10-01 enterprise 的 docs/ai-context/git-workflow.md 在有项目未提交改写的情况下被升级了托管块(1.6.0→1.7.0),产生「同一文件混有炼境与项目两种来源改动」的混合文件。按「禁止字符串手术」铁律它无法拆分提交,只能交人定案,verify_onboarding 也因此持续报版本不符。管家应在制造混合文件之前提示,而不是事后由人收拾。 +- 实现(2026-10-03,分支 feat/agent-client-parity-p10):TemplateResult 新增可选 warning。apply_onboarding_pack 在模板分发与 buff 蓝图规则同步前后夹一层检测:写入前对 AGENTS.md、docs/ai-context/git-workflow.md、CLAUDE.md 跑 `git --no-optional-locks status --porcelain -uall -- <文件>`,有改动再取 `git show HEAD:./<文件>`(不在 HEAD 中按空内容算,即未跟踪/仅暂存/尚无提交),两边去掉 ONBOARDING_MANAGED 与 BLUEPRINT_MANAGED 块后比较(忽略行尾、行末空白与空行);写入后只对本次确被改写的文件给出 warning(含文件名与「块外有未提交改动,升级后将成为混合文件」),CLAUDE.md 不在模板结果里,预警时补一条结果。git 不可用、非仓库或命令失败一律静默。只提示不阻断,块外内容照旧不动。MCP 单项目输出在该文件行下加 ⚠️ 行,批量输出追加「混合文件」清单;炼境 UI 接入包结果行下显示琥珀色预警。 +- verification: "2026-10-03:新增 3 个测试——outside_managed_content_strips_blocks_and_ignores_eol_and_blank_lines(纯函数:剔除两种块、忽略 CRLF 与空行、不在 HEAD 时有块外内容才算、残缺块按块外处理);mixed_file_warning_only_for_uncommitted_changes_outside_managed_blocks(临时 git 仓库,走与 apply 相同的检测夹层 + 真实 sync_blueprint_rules:git-workflow.md 块外未提交改写 → updated 且带预警、块外改写原样保留、托管块升到模板版本;未跟踪 CLAUDE.md 含项目内容 → 补 appended 结果并预警;与 HEAD 一致的 AGENTS.md 照常升级无预警;全部提交后只改托管块版本并整体转 CRLF → 升级且全部无预警、不补 CLAUDE.md 行);mixed_file_warning_is_silent_outside_git(非 git 目录升级无预警,--nocapture 确认未走跳过分支)。变异自检:把探测函数强制返回 true,后两个测试均失败,已还原。cargo test --locked:lib 270 passed / 5 ignored,0 failed;pnpm typecheck 通过;pnpm test 16 passed。" +- known_limits: "仓库里没有 apply_onboarding_pack 的预览命令(acceptance 写的「预览」无处可挂),预警随结果返回:检测在写入前完成,但升级照常进行,调用方拿到结果时混合文件已经产生——真正「先提示再决定」要等 P8 的只报告 diff 模式复用本检测。从未提交过的炼境新建文件(块外整段是模板 TODO)在模板升版后重跑也会预警(不在 HEAD 中、无法区分块外内容来源),属安全方向的误报。buff 之外单独调用 sync_blueprint_rules(UI 同步蓝图规则、批量同步)不经此检测。仅比较 HEAD 与工作区:暂存区与工作区不同的中间状态不单独判断。" diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 750bca7..36e3e7d 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -26,7 +26,7 @@ contracts: - ".lian-jing/context.json" - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较忽略行尾与空行),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。" - label: Tauri command 接口(前后端契约) paths: diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index d529b0b..78a68a2 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1262,6 +1262,11 @@ pub struct SyncResult { pub message: String, } +/// BLUEPRINT_MANAGED 块的标记(开始标记后紧跟 ` version="x.y.z" -->`)。 +/// 接入包的混合文件预警(onboarding.rs)比较块外内容时也按它剔除本块。 +pub(crate) const BLUEPRINT_MANAGED_START: &str = ""; + /// 在指定文件中写入/替换 BLUEPRINT_MANAGED 块: /// 文件不存在 → 用 header + 模板创建;已有 MANAGED 区 → 原位替换(内容不变则不写);否则追加。 /// 返回动作:created | updated | unchanged | appended @@ -1277,7 +1282,7 @@ fn upsert_managed_block( return Ok("created".to_string()); } let content = std::fs::read_to_string(path).map_err(|e| format!("读取 {label} 失败: {e}"))?; - if content.contains(""; + let start_marker = BLUEPRINT_MANAGED_START; + let end_marker = BLUEPRINT_MANAGED_END; if let (Some(start), Some(end_pos)) = (content.find(start_marker), content.find(end_marker)) { let end = end_pos + end_marker.len(); diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index d454beb..344d39a 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -34,41 +34,40 @@ const TOOLCHAIN_PACK_VERSION: &str = "1.0.0"; pub struct TemplateResult { /// 相对项目根的目标路径 pub name: String, - /// "written" | "skipped" | "failed" + /// "written" | "updated" | "appended" | "skipped" | "failed" pub action: String, pub detail: String, + /// 混合文件预警(P10):写入前该文件在 git 中已有托管块之外的未提交改动, + /// 本次又改写了其中的托管块。只提示不阻断,块外内容仍不触碰。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + +impl TemplateResult { + fn new(name: &str, action: &str, detail: impl Into) -> Self { + Self { + name: name.to_string(), + action: action.to_string(), + detail: detail.into(), + warning: None, + } + } } /// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。 fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult { let target = root.join(relative); if target.exists() { - return TemplateResult { - name: relative.to_string(), - action: "skipped".into(), - detail: "已存在,未覆盖".into(), - }; + return TemplateResult::new(relative, "skipped", "已存在,未覆盖"); } if let Some(parent) = target.parent() { if let Err(e) = std::fs::create_dir_all(parent) { - return TemplateResult { - name: relative.to_string(), - action: "failed".into(), - detail: format!("建目录失败: {e}"), - }; + return TemplateResult::new(relative, "failed", format!("建目录失败: {e}")); } } match std::fs::write(&target, content) { - Ok(_) => TemplateResult { - name: relative.to_string(), - action: "written".into(), - detail: "已写入".into(), - }, - Err(e) => TemplateResult { - name: relative.to_string(), - action: "failed".into(), - detail: format!("写入失败: {e}"), - }, + Ok(_) => TemplateResult::new(relative, "written", "已写入"), + Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")), } } @@ -83,24 +82,12 @@ fn dispatch_fully_managed(root: &Path, relative: &str, content: &str) -> Templat let normalize = |s: &str| s.replace("\r\n", "\n"); if let Ok(existing) = std::fs::read_to_string(&target) { if normalize(&existing) == normalize(content) { - return TemplateResult { - name: relative.to_string(), - action: "skipped".into(), - detail: "内容已是最新模板".into(), - }; + return TemplateResult::new(relative, "skipped", "内容已是最新模板"); } } match std::fs::write(&target, content) { - Ok(_) => TemplateResult { - name: relative.to_string(), - action: "updated".into(), - detail: "全托管文件已覆盖为最新模板".into(), - }, - Err(e) => TemplateResult { - name: relative.to_string(), - action: "failed".into(), - detail: format!("覆盖失败: {e}"), - }, + Ok(_) => TemplateResult::new(relative, "updated", "全托管文件已覆盖为最新模板"), + Err(e) => TemplateResult::new(relative, "failed", format!("覆盖失败: {e}")), } } @@ -122,11 +109,11 @@ fn node_pnpm_contract_present(root: &Path) -> bool { fn dispatch_toolchain_resolver(root: &Path) -> TemplateResult { if !node_pnpm_contract_present(root) { - return TemplateResult { - name: "mise.toml".to_string(), - action: "skipped".to_string(), - detail: "非 .nvmrc + pnpm packageManager 项目,不适用 Node/pnpm 工具链包".to_string(), - }; + return TemplateResult::new( + "mise.toml", + "skipped", + "非 .nvmrc + pnpm packageManager 项目,不适用 Node/pnpm 工具链包", + ); } dispatch_one(root, "mise.toml", TMPL_MISE) } @@ -161,24 +148,14 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe } let content = match std::fs::read_to_string(&target) { Ok(c) => c, - Err(e) => { - return TemplateResult { - name: relative.to_string(), - action: "failed".into(), - detail: format!("读取失败: {e}"), - } - } + Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")), }; let (new_content, action, detail) = if let (Some(s), Some(e)) = (content.find(MANAGED_START), content.find(MANAGED_END)) { let old_ver = block_version(&content).unwrap_or("?").to_string(); if old_ver == tpl_ver { - return TemplateResult { - name: relative.to_string(), - action: "skipped".into(), - detail: format!("管理块已是 v{tpl_ver}"), - }; + return TemplateResult::new(relative, "skipped", format!("管理块已是 v{tpl_ver}")); } let mut nc = String::new(); nc.push_str(&content[..s]); @@ -198,16 +175,8 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe }; match std::fs::write(&target, new_content) { - Ok(_) => TemplateResult { - name: relative.to_string(), - action: action.into(), - detail, - }, - Err(e) => TemplateResult { - name: relative.to_string(), - action: "failed".into(), - detail: format!("写入失败: {e}"), - }, + Ok(_) => TemplateResult::new(relative, action, detail), + Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")), } } @@ -245,6 +214,155 @@ pub fn dispatch_templates(project_path: &str) -> Vec { ] } +// ── P10:混合文件预警(升级托管块前检测块外的未提交改动)──────────────────── +// +// 为什么:2026-10-01 enterprise 的 git-workflow.md 在有项目未提交改写时被升级了托管块, +// 同一文件混有炼境与项目两种来源的改动;按「禁止字符串手术」无法拆分提交,只能交人定案。 +// 默认仍照常升级(只改托管块),但必须让调用方知道哪个文件因此成了混合文件。 + +/// 接入包会原位改写托管块的文件:AGENTS.md、git-workflow.md 由模板分发写 ONBOARDING_MANAGED, +/// AGENTS.md、CLAUDE.md 另由 buff 的蓝图规则同步写 BLUEPRINT_MANAGED。 +const MANAGED_BLOCK_FILES: [&str; 3] = + ["AGENTS.md", "docs/ai-context/git-workflow.md", "CLAUDE.md"]; + +/// 比较块外内容时整体剔除的托管块(开始标记前缀,结束标记)。 +const STRIPPED_MANAGED_BLOCKS: [(&str, &str); 2] = [ + (MANAGED_START, MANAGED_END), + ( + crate::commands::blueprint::BLUEPRINT_MANAGED_START, + crate::commands::blueprint::BLUEPRINT_MANAGED_END, + ), +]; + +/// 去掉全部托管块(含标记)后的块外内容。忽略行尾、行末空白与空行差异—— +/// 炼境追加托管块时自己插入的空行不应被当成项目改动。 +fn outside_managed_content(text: &str) -> String { + let mut rest = text.replace("\r\n", "\n"); + for (start, end) in STRIPPED_MANAGED_BLOCKS { + while let Some(s) = rest.find(start) { + let Some(len) = rest[s..].find(end) else { + break; // 残缺块(无结束标记):余下内容按块外处理 + }; + rest.replace_range(s..s + len + end.len(), ""); + } + } + rest.lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") +} + +/// HEAD 版本(None = 不在 HEAD 中:未跟踪、仅暂存或仓库尚无提交)与工作区版本的块外内容是否不同。 +fn outside_managed_differs(head: Option<&str>, worktree: &str) -> bool { + outside_managed_content(head.unwrap_or("")) != outside_managed_content(worktree) +} + +/// 目标文件在 git 中是否有托管块之外的未提交改动(未跟踪文件带块外内容也算)。 +/// 非 git 仓库、git 不可用或命令失败一律返回 false:只做提示,探测失败不打扰调用方。 +fn has_uncommitted_outside_managed(root: &Path, relative: &str, worktree: &str) -> bool { + // --no-optional-locks:后台探测不抢 index.lock,避免与 agent 同时进行的提交冲突 + let status = crate::silent_cmd("git") + .args([ + "--no-optional-locks", + "status", + "--porcelain", + "--untracked-files=all", + "--", + relative, + ]) + .current_dir(root) + .output(); + match status { + Ok(out) if out.status.success() => { + if out.stdout.iter().all(u8::is_ascii_whitespace) { + return false; // 与 HEAD 一致(或被 .gitignore 忽略),没有未提交改动 + } + } + _ => return false, + } + // `HEAD:./` 按 cwd 解析,项目根是仓库子目录时同样正确 + let head = crate::silent_cmd("git") + .args(["show", &format!("HEAD:./{relative}")]) + .current_dir(root) + .output() + .ok() + .filter(|out| out.status.success()) + .map(|out| String::from_utf8_lossy(&out.stdout).into_owned()); + outside_managed_differs(head.as_deref(), worktree) +} + +fn mixed_file_warning(relative: &str) -> String { + format!( + "{relative}:块外有未提交改动,升级后将成为混合文件——本次只改写了其中的炼境托管块,块外内容未动;\ + 提交前先确认块外改动的归属(不得用字符串手术拆分暂存),无法整体提交时交人定案。" + ) +} + +/// 写入前快照:块外已有未提交改动的托管块文件,及其写入前内容。 +struct MixedFileGuard { + dirty: Vec<(&'static str, String)>, +} + +impl MixedFileGuard { + fn snapshot(root: &Path) -> Self { + let dirty = MANAGED_BLOCK_FILES + .into_iter() + .filter_map(|relative| { + let before = std::fs::read_to_string(root.join(relative)).ok()?; + has_uncommitted_outside_managed(root, relative, &before) + .then_some((relative, before)) + }) + .collect(); + Self { dirty } + } + + /// 写入后:快照里本次确被改写的文件标注 warning;不在模板结果里的 + /// (CLAUDE.md 只由蓝图规则同步改写)补一条结果,免得预警无处可见。 + fn annotate(self, root: &Path, templates: &mut Vec) { + for (relative, before) in self.dirty { + let Ok(after) = std::fs::read_to_string(root.join(relative)) else { + continue; + }; + if crate::same_text_ignoring_eol(&before, &after) { + continue; // 本次没改写它,不会制造混合文件 + } + let warning = Some(mixed_file_warning(relative)); + match templates.iter_mut().find(|t| t.name == relative) { + Some(result) => result.warning = warning, + None => { + let action = + if before.contains(crate::commands::blueprint::BLUEPRINT_MANAGED_START) { + "updated" + } else { + "appended" + }; + let mut result = TemplateResult::new( + relative, + action, + "蓝图规则托管块(BLUEPRINT_MANAGED)已同步,块外内容未动", + ); + result.warning = warning; + templates.push(result); + } + } + } + } +} + +/// 模板分发与其后的写入步骤(apply 时为 lefthook 激活与 buff 蓝图规则同步)前后夹混合文件检测: +/// 写入前快照块外未提交改动,写入后只对确被改写的文件给出预警。 +fn dispatch_templates_with_mixed_file_check( + root: &Path, + then: impl FnOnce() -> Result, +) -> Result<(Vec, T), String> { + let guard = MixedFileGuard::snapshot(root); + let mut templates = dispatch_templates(&root.to_string_lossy()); + let rest = then()?; + guard.annotate(root, &mut templates); + Ok((templates, rest)) +} + // ── V1:lefthook 激活(分发 ≠ 生效,文件写入后必须 lefthook install 挂上 hook)── /// lefthook 激活结果。status: "activated" | "missing_binary" | "failed" @@ -324,6 +442,7 @@ pub struct OnboardingReport { } /// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。 +/// 改写了块外带未提交改动的托管块文件时,对应结果带 warning(混合文件预警,见 P10 段)。 #[tauri::command] pub fn apply_onboarding_pack( project_path: String, @@ -335,9 +454,11 @@ pub fn apply_onboarding_pack( if project_path.trim().is_empty() || !root.is_dir() { return Err(format!("项目路径为空或不存在,拒绝分发: {project_path:?}")); } - let templates = dispatch_templates(&project_path); - let hook = run_lefthook_install(Path::new(project_path.trim_end_matches(['/', '\\']))); - let buff = crate::commands::buff::apply_blueprint_buff(project_path, project_id)?; + let (templates, (hook, buff)) = dispatch_templates_with_mixed_file_check(root, || { + let hook = run_lefthook_install(root); + let buff = crate::commands::buff::apply_blueprint_buff(project_path.clone(), project_id)?; + Ok((hook, buff)) + })?; Ok(OnboardingReport { templates, hook, @@ -1168,6 +1289,203 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + // ── P10:混合文件预警 ──────────────────────────────────────────────── + + #[test] + fn outside_managed_content_strips_blocks_and_ignores_eol_and_blank_lines() { + let with_blocks = "# 标题\r\n\r\n项目内容\r\n\r\n\r\n炼境内容\r\n\r\n\r\n\n蓝图\n\n"; + assert_eq!(outside_managed_content(with_blocks), "# 标题\n项目内容"); + assert!(!outside_managed_differs( + Some("# 标题\n\n项目内容\n"), + with_blocks + )); + assert!(outside_managed_differs( + Some("# 标题\n项目内容"), + &with_blocks.replace("项目内容", "改过的项目内容") + )); + // 不在 HEAD 中:有块外内容即算改动,只有托管块则不算 + assert!(outside_managed_differs(None, with_blocks)); + assert!(!outside_managed_differs( + None, + "\nx\n\n" + )); + // 残缺块(无结束标记)不剔除,余下内容按块外处理 + assert_eq!( + outside_managed_content("a\n\nb"), + "a\n\nb" + ); + } + + fn git_in(dir: &Path, args: &[&str]) -> bool { + std::process::Command::new("git") + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + ]) + .args(args) + .current_dir(dir) + .output() + .expect("git 可执行") + .status + .success() + } + + /// 托管块版本改旧(分发时必然升级),块外带项目自己的内容。 + fn outdated_versioned(template: &str, project_line: &str) -> String { + let block = extract_managed_block(template).expect("模板必须带管理块"); + let tpl_ver = block_version(block).expect("模板必须带版本标记"); + let old_block = block.replace(&format!("version=\"{tpl_ver}\""), "version=\"0.0.1\""); + format!("# 项目文档\n\n{project_line}\n\n{old_block}\n") + } + + /// 走与 apply_onboarding_pack 相同的检测夹层;后续写入只做 buff 里的蓝图规则同步 + /// (buff 其余部分要连炼境 DB,lefthook 激活不碰托管块文件,均不在此调用)。 + fn apply_like(root: &Path) -> Vec { + dispatch_templates_with_mixed_file_check(root, || { + crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().into_owned()) + .map(|_| ()) + }) + .expect("分发不应失败") + .0 + } + + fn result_of<'a>(results: &'a [TemplateResult], name: &str) -> &'a TemplateResult { + results + .iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("缺少 {name} 的分发结果")) + } + + #[test] + fn mixed_file_warning_only_for_uncommitted_changes_outside_managed_blocks() { + let base = + std::env::temp_dir().join(format!("lianjing_onb_mixed_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("docs/ai-context")).unwrap(); + let workflow_path = base.join("docs/ai-context/git-workflow.md"); + let workflow = outdated_versioned(TMPL_GIT_WORKFLOW, "项目分支约定"); + std::fs::write( + base.join("AGENTS.md"), + outdated_versioned(TMPL_AGENTS, "项目说明"), + ) + .unwrap(); + std::fs::write(&workflow_path, &workflow).unwrap(); + assert!(git_in(&base, &["init", "-q"])); + assert!(git_in(&base, &["add", "-A"])); + assert!(git_in( + &base, + &["commit", "-q", "--no-verify", "-m", "init"] + )); + + // 情形一:git-workflow.md 块外有未提交改写、CLAUDE.md 未跟踪且含项目内容 → 两者预警; + // AGENTS.md 与 HEAD 一致 → 照常升级、无预警 + std::fs::write( + &workflow_path, + workflow.replace("项目分支约定", "项目分支约定(未提交改写)"), + ) + .unwrap(); + std::fs::write(base.join("CLAUDE.md"), "# 项目自己的 CLAUDE.md\n").unwrap(); + let r = apply_like(&base); + + let wf = result_of(&r, "docs/ai-context/git-workflow.md"); + assert_eq!(wf.action, "updated", "默认仍升级托管块"); + let warning = wf.warning.as_deref().expect("块外有未提交改动应预警"); + assert!( + warning.contains("docs/ai-context/git-workflow.md") + && warning.contains("块外有未提交改动,升级后将成为混合文件"), + "预警须含文件名与提示: {warning}" + ); + let upgraded = std::fs::read_to_string(&workflow_path).unwrap(); + let tpl_ver = extract_managed_block(TMPL_GIT_WORKFLOW) + .and_then(block_version) + .unwrap(); + assert!( + upgraded.contains(&format!("version=\"{tpl_ver}\"")), + "托管块应升级" + ); + assert!( + upgraded.contains("项目分支约定(未提交改写)") && upgraded.starts_with("# 项目文档"), + "块外内容不得改动" + ); + + let claude = result_of(&r, "CLAUDE.md"); + assert_eq!(claude.action, "appended"); + assert!( + claude + .warning + .as_deref() + .is_some_and(|w| w.contains("CLAUDE.md")), + "未跟踪文件含块外内容也算:{claude:?}" + ); + let claude_md = std::fs::read_to_string(base.join("CLAUDE.md")).unwrap(); + assert!(claude_md.starts_with("# 项目自己的 CLAUDE.md")); + + let agents = result_of(&r, "AGENTS.md"); + assert_eq!(agents.action, "updated"); + assert_eq!(agents.warning, None, "块外无未提交改动的文件不应预警"); + + // 情形二:全部提交后只改托管块(版本退回)并整体转成 CRLF → 照常升级、无预警 + assert!(git_in(&base, &["add", "-A"])); + assert!(git_in( + &base, + &["commit", "-q", "--no-verify", "-m", "onboard"] + )); + let agents_path = base.join("AGENTS.md"); + let committed = std::fs::read_to_string(&agents_path).unwrap(); + let tpl_ver = extract_managed_block(TMPL_AGENTS) + .and_then(block_version) + .unwrap(); + let block_only = committed + .replace(&format!("version=\"{tpl_ver}\""), "version=\"0.0.1\"") + .replace('\n', "\r\n"); + std::fs::write(&agents_path, block_only).unwrap(); + let r = apply_like(&base); + assert_eq!(result_of(&r, "AGENTS.md").action, "updated"); + assert!( + r.iter().all(|t| t.warning.is_none()), + "只改托管块(含行尾差异)不应预警: {r:?}" + ); + assert!( + !r.iter().any(|t| t.name == "CLAUDE.md"), + "无预警时不补 CLAUDE.md 结果行" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn mixed_file_warning_is_silent_outside_git() { + let base = + std::env::temp_dir().join(format!("lianjing_onb_nogit_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + if git_in(&base, &["rev-parse", "--git-dir"]) { + // 临时目录位于某个 git 仓库内,构造不出「非 git 仓库」前置条件 + eprintln!("跳过:{} 位于 git 仓库内", base.display()); + let _ = std::fs::remove_dir_all(&base); + return; + } + std::fs::write( + base.join("AGENTS.md"), + outdated_versioned(TMPL_AGENTS, "项目说明"), + ) + .unwrap(); + std::fs::write(base.join("CLAUDE.md"), "# 项目自己的 CLAUDE.md\n").unwrap(); + let r = apply_like(&base); + assert_eq!(result_of(&r, "AGENTS.md").action, "updated"); + assert!( + r.iter().all(|t| t.warning.is_none()), + "非 git 仓库不应预警: {r:?}" + ); + let _ = std::fs::remove_dir_all(&base); + } + // ── V1:接入生效验收纯函数 ──────────────────────────────────────────── #[test] @@ -1574,20 +1892,23 @@ mod tests { #[test] fn skill_is_projected_to_codex_and_claude_from_one_source() { - let base = std::env::temp_dir().join(format!( - "lianjing_skill_projection_{}", - std::process::id() - )); + let base = + std::env::temp_dir().join(format!("lianjing_skill_projection_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); dispatch_templates(base.to_str().unwrap()); - let codex = std::fs::read_to_string(base.join(".agents/skills/lian-jing/SKILL.md")).unwrap(); - let claude = std::fs::read_to_string(base.join(".claude/skills/lian-jing/SKILL.md")).unwrap(); + let codex = + std::fs::read_to_string(base.join(".agents/skills/lian-jing/SKILL.md")).unwrap(); + let claude = + std::fs::read_to_string(base.join(".claude/skills/lian-jing/SKILL.md")).unwrap(); assert_eq!(codex, claude, "两端 skill 必须来自同一模板"); assert!(check_skill(Some(&claude)).0); let packs: serde_json::Value = serde_json::from_str(TMPL_CAPABILITY_PACKS).unwrap(); let artifacts = packs["packs"][0]["project_artifacts"].to_string(); - assert!(artifacts.contains(".claude/skills/lian-jing/SKILL.md"), "能力包清单须登记 Claude 投影"); + assert!( + artifacts.contains(".claude/skills/lian-jing/SKILL.md"), + "能力包清单须登记 Claude 投影" + ); let _ = std::fs::remove_dir_all(&base); } @@ -1679,7 +2000,10 @@ mod tests { let stable = format!( "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%USERPROFILE%\\\\.lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" ); - assert_eq!(check_codex_mcp(Some(&stable), "p1", 27190).state, VerifyState::Ready); + assert_eq!( + check_codex_mcp(Some(&stable), "p1", 27190).state, + VerifyState::Ready + ); } /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。 diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index e50020f..1589b6a 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -315,7 +315,7 @@ pub fn tools_list_result() -> Value { }, { "name": "apply_onboarding_pack", - "description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow / envoy / blueprint-gate 模板(版本化块原位升级、全托管文件内容不一致即覆盖、lefthook 存在即跳过,块外用户内容绝不触碰),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板升级后对存量项目重新调用即可推送新版本;不传 project_id 则对所有已登记项目批量执行。", + "description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow / envoy / blueprint-gate 模板(版本化块原位升级、全托管文件内容不一致即覆盖、lefthook 存在即跳过,块外用户内容绝不触碰;目标文件在 git 中块外已有未提交改动时仍只改托管块,但对该文件给出「混合文件」预警,提交前须先确认块外改动归属),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板升级后对存量项目重新调用即可推送新版本;不传 project_id 则对所有已登记项目批量执行。", "inputSchema": { "type": "object", "properties": { @@ -1240,11 +1240,26 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option = report + .templates + .iter() + .filter(|t| t.warning.is_some()) + .map(|t| t.name.as_str()) + .collect(); + let mixed_note = if mixed.is_empty() { + String::new() + } else { + format!( + " | ⚠️ 混合文件(块外有未提交改动): {}", + mixed.join(", ") + ) + }; lines.push(format!( - "- {}: {} | hook={}", + "- {}: {} | hook={}{}", id, summary.join(", "), - report.hook.status + report.hook.status, + mixed_note )); } Err(e) => { @@ -1278,7 +1293,12 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option "⏭ skipped", _ => "❌ failed", }; - format!(" {icon}: {}", t.name) + match &t.warning { + Some(warning) => { + format!(" {icon}: {}\n ⚠️ {warning}", t.name) + } + None => format!(" {icon}: {}", t.name), + } }) .collect(); let hook_icon = match report.hook.status.as_str() { diff --git a/src/components/dashboard/BlueprintModal.tsx b/src/components/dashboard/BlueprintModal.tsx index a435a32..bbcd936 100644 --- a/src/components/dashboard/BlueprintModal.tsx +++ b/src/components/dashboard/BlueprintModal.tsx @@ -1311,11 +1311,16 @@ function GovernancePanel({ const style = TEMPLATE_ACTION_STYLE[t.action] ?? TEMPLATE_ACTION_STYLE.failed; return ( -

- {style.icon} - {t.name} - {style.label ?? t.detail} -

+
+

+ {style.icon} + {t.name} + {style.label ?? t.detail} +

+ {t.warning && ( +

⚠ {t.warning}

+ )} +
); })} {packHook && ( diff --git a/src/lib/commands.ts b/src/lib/commands.ts index fcfb21c..a33e710 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1626,6 +1626,8 @@ export interface TemplateResult { name: string; action: "written" | "updated" | "appended" | "skipped" | "failed"; detail: string; + /** 混合文件预警:写入前该文件块外已有未提交改动,本次又改写了其中的托管块(只提示不阻断) */ + warning?: string; } export interface HookActivation { -- 2.45.2 From c9b951dd30a0203d2ac9055004af758a27a69d51 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 04:32:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(onboarding/parity):=20=E6=B7=B7?= =?UTF-8?q?=E5=90=88=E6=96=87=E4=BB=B6=E9=A2=84=E8=AD=A6=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E7=A9=BA=E7=99=BD=E5=BD=92=E4=B8=80=E3=80=81=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E4=B8=A2=E9=A2=84=E8=AD=A6=E3=80=81=E6=B5=8B?= =?UTF-8?q?=E4=BD=8F=E6=94=B9=E5=86=99=E5=88=A4=E6=96=AD=EF=BC=88P10=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P10 审查核实的三处缺陷: - 块外比较原先逐行 trim_end 并丢弃全部空行,项目在块外删空行(Markdown 两段 并成一段)或加行尾空格(硬换行)时判为无改动,升级后不预警却产生混合文件。 改为 CRLF 统一为 LF、托管块原位换成占位符,只把文末占位符连同其前空白按 trim_end 归一(正好抵消炼境追加托管块时的 trim_end + 空行);文中托管块两侧 的空行与行尾空格照常比较——炼境只原位替换文中的块,不碰两侧。 - dispatch_templates_with_mixed_file_check 先 `then()?` 后标注:buff 先同步蓝图 规则改写了 AGENTS.md/CLAUDE.md、再写 DB 失败时,预警随 Err 一起丢失。改为 无论成败先标注,失败时把混合文件名并入错误信息;无混合文件则原样返回错误。 - 「块外有未提交改动但托管块已是最新、本次不改写」组合没有测试,删掉 annotate 的改写判断测试照样全绿。主场景测试补情形三。 测试:新增 outside_managed_whitespace_edits_count_as_changes、 mixed_file_warning_survives_failure_after_write,主场景补情形三;纯函数测试的 期望值随归一化收窄更新("# 标题\n项目内容" → "# 标题\n\n项目内容",保留「之前 追加的块尚未提交、只多出块前空行」不算改动的断言)。变异自检:三处修复逐一回退, 对应测试各自失败,已还原。cargo test --locked:lib 272 passed / 5 ignored,0 failed; pnpm typecheck 通过。契约文档 project-context.yaml 与蓝图 P10 卡同步。 Rules-Applied: R07,R12 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 5 +- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/onboarding.rs | 195 ++++++++++++++++++++-- 3 files changed, 187 insertions(+), 15 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 59af8a5..1ab1ad2 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -179,6 +179,7 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - depends: P8 - acceptance: "agent_verify: [cargo test] 目标文件在 git 中有托管块之外的未提交改动时,apply_onboarding_pack 的预览与结果对该文件给出 warning(含文件名与「块外有未提交改动,升级后将成为混合文件」提示),默认仍只改托管块、不改块外内容;无未提交改动或非 git 仓库时无此提示。" - 为什么:2026-10-01 enterprise 的 docs/ai-context/git-workflow.md 在有项目未提交改写的情况下被升级了托管块(1.6.0→1.7.0),产生「同一文件混有炼境与项目两种来源改动」的混合文件。按「禁止字符串手术」铁律它无法拆分提交,只能交人定案,verify_onboarding 也因此持续报版本不符。管家应在制造混合文件之前提示,而不是事后由人收拾。 -- 实现(2026-10-03,分支 feat/agent-client-parity-p10):TemplateResult 新增可选 warning。apply_onboarding_pack 在模板分发与 buff 蓝图规则同步前后夹一层检测:写入前对 AGENTS.md、docs/ai-context/git-workflow.md、CLAUDE.md 跑 `git --no-optional-locks status --porcelain -uall -- <文件>`,有改动再取 `git show HEAD:./<文件>`(不在 HEAD 中按空内容算,即未跟踪/仅暂存/尚无提交),两边去掉 ONBOARDING_MANAGED 与 BLUEPRINT_MANAGED 块后比较(忽略行尾、行末空白与空行);写入后只对本次确被改写的文件给出 warning(含文件名与「块外有未提交改动,升级后将成为混合文件」),CLAUDE.md 不在模板结果里,预警时补一条结果。git 不可用、非仓库或命令失败一律静默。只提示不阻断,块外内容照旧不动。MCP 单项目输出在该文件行下加 ⚠️ 行,批量输出追加「混合文件」清单;炼境 UI 接入包结果行下显示琥珀色预警。 +- 实现(2026-10-03,分支 feat/agent-client-parity-p10):TemplateResult 新增可选 warning。apply_onboarding_pack 在模板分发与 buff 蓝图规则同步前后夹一层检测:写入前对 AGENTS.md、docs/ai-context/git-workflow.md、CLAUDE.md 跑 `git --no-optional-locks status --porcelain -uall -- <文件>`,有改动再取 `git show HEAD:./<文件>`(不在 HEAD 中按空内容算,即未跟踪/仅暂存/尚无提交),两边去掉 ONBOARDING_MANAGED 与 BLUEPRINT_MANAGED 块后比较(只忽略 CRLF/LF 与文末托管块连同其前空白,见下方审查修复);写入后只对本次确被改写的文件给出 warning(含文件名与「块外有未提交改动,升级后将成为混合文件」),CLAUDE.md 不在模板结果里,预警时补一条结果。git 不可用、非仓库或命令失败一律静默。只提示不阻断,块外内容照旧不动。MCP 单项目输出在该文件行下加 ⚠️ 行,批量输出追加「混合文件」清单;炼境 UI 接入包结果行下显示琥珀色预警。 - verification: "2026-10-03:新增 3 个测试——outside_managed_content_strips_blocks_and_ignores_eol_and_blank_lines(纯函数:剔除两种块、忽略 CRLF 与空行、不在 HEAD 时有块外内容才算、残缺块按块外处理);mixed_file_warning_only_for_uncommitted_changes_outside_managed_blocks(临时 git 仓库,走与 apply 相同的检测夹层 + 真实 sync_blueprint_rules:git-workflow.md 块外未提交改写 → updated 且带预警、块外改写原样保留、托管块升到模板版本;未跟踪 CLAUDE.md 含项目内容 → 补 appended 结果并预警;与 HEAD 一致的 AGENTS.md 照常升级无预警;全部提交后只改托管块版本并整体转 CRLF → 升级且全部无预警、不补 CLAUDE.md 行);mixed_file_warning_is_silent_outside_git(非 git 目录升级无预警,--nocapture 确认未走跳过分支)。变异自检:把探测函数强制返回 true,后两个测试均失败,已还原。cargo test --locked:lib 270 passed / 5 ignored,0 failed;pnpm typecheck 通过;pnpm test 16 passed。" -- known_limits: "仓库里没有 apply_onboarding_pack 的预览命令(acceptance 写的「预览」无处可挂),预警随结果返回:检测在写入前完成,但升级照常进行,调用方拿到结果时混合文件已经产生——真正「先提示再决定」要等 P8 的只报告 diff 模式复用本检测。从未提交过的炼境新建文件(块外整段是模板 TODO)在模板升版后重跑也会预警(不在 HEAD 中、无法区分块外内容来源),属安全方向的误报。buff 之外单独调用 sync_blueprint_rules(UI 同步蓝图规则、批量同步)不经此检测。仅比较 HEAD 与工作区:暂存区与工作区不同的中间状态不单独判断。" +- review_fixes: "2026-10-03 审查修复三条:① 原比较逐行 trim_end 并丢弃全部空行,块外删空行(Markdown 并段)或加行尾空格(硬换行)会漏报——改为托管块原位留占位符,只把文末的占位符连同其前空白按 trim_end 归一(抵消炼境追加时的 trim_end + 空行),文中块两侧空行与行尾空格照常比较;② 后续步骤(buff 先同步蓝图规则、再写 DB)报错时预警随 Err 丢失——改为先标注再返回,错误信息并入混合文件名;③ 补「块外有未提交改动但托管块已是最新、本次不改写」情形,测住 annotate 的改写判断。新增测试 outside_managed_whitespace_edits_count_as_changes、mixed_file_warning_survives_failure_after_write,主场景测试补情形三。" +- known_limits: "仓库里没有 apply_onboarding_pack 的预览命令(acceptance 写的「预览」无处可挂),预警随结果返回:检测在写入前完成,但升级照常进行,调用方拿到结果时混合文件已经产生——真正「先提示再决定」要等 P8 的只报告 diff 模式复用本检测。从未提交过的炼境新建文件(块外整段是模板 TODO)在模板升版后重跑也会预警(不在 HEAD 中、无法区分块外内容来源),属安全方向的误报。buff 之外单独调用 sync_blueprint_rules(UI 同步蓝图规则、批量同步)不经此检测。仅比较 HEAD 与工作区:暂存区与工作区不同的中间状态不单独判断。文末托管块之前、之后的纯空白增删(如块前空行数、文件末尾多出空行)为抵消炼境追加差异仍被忽略。" diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 36e3e7d..e99c016 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -26,7 +26,7 @@ contracts: - ".lian-jing/context.json" - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较忽略行尾与空行),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。" - label: Tauri command 接口(前后端契约) paths: diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 344d39a..e993373 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -234,23 +234,29 @@ const STRIPPED_MANAGED_BLOCKS: [(&str, &str); 2] = [ ), ]; -/// 去掉全部托管块(含标记)后的块外内容。忽略行尾、行末空白与空行差异—— -/// 炼境追加托管块时自己插入的空行不应被当成项目改动。 +/// 托管块剔除后在原位留下的占位符(正常文本不含 NUL)。 +const BLOCK_PLACEHOLDER: char = '\0'; + +/// 去掉全部托管块(含标记)后的块外内容。只抵消炼境自己写入带来的差异,其余空白差异 +/// (块外空行增删、行尾空格——Markdown 里分别是段落合并与硬换行)一律算项目改动: +/// - CRLF 统一为 LF(子项目常开 core.autocrlf); +/// - 托管块原位换成占位符:文中的块两侧空行原样参与比较(炼境只原位替换文中的块,不碰两侧); +/// - 结尾的占位符连同其间、其前的空白整体剔除:炼境追加托管块写的是 +/// `原文.trim_end() + "\n\n" + 块 + "\n"`,追加前原文末尾的空白也被 trim_end 吃掉, +/// 所以只有文末这段按 trim_end 归一。 fn outside_managed_content(text: &str) -> String { let mut rest = text.replace("\r\n", "\n"); + let placeholder = BLOCK_PLACEHOLDER.to_string(); for (start, end) in STRIPPED_MANAGED_BLOCKS { while let Some(s) = rest.find(start) { let Some(len) = rest[s..].find(end) else { break; // 残缺块(无结束标记):余下内容按块外处理 }; - rest.replace_range(s..s + len + end.len(), ""); + rest.replace_range(s..s + len + end.len(), &placeholder); } } - rest.lines() - .map(str::trim_end) - .filter(|line| !line.is_empty()) - .collect::>() - .join("\n") + rest.trim_end_matches(|c: char| c.is_whitespace() || c == BLOCK_PLACEHOLDER) + .to_string() } /// HEAD 版本(None = 不在 HEAD 中:未跟踪、仅暂存或仓库尚无提交)与工作区版本的块外内容是否不同。 @@ -352,15 +358,34 @@ impl MixedFileGuard { /// 模板分发与其后的写入步骤(apply 时为 lefthook 激活与 buff 蓝图规则同步)前后夹混合文件检测: /// 写入前快照块外未提交改动,写入后只对确被改写的文件给出预警。 +/// 后续步骤报错时文件可能已被改写(如 buff 先同步了蓝图规则、再写 DB 失败),预警是混合文件 +/// 已产生的唯一信号,不能随 Err 丢失:先标注,再把混合文件名并入错误信息。 fn dispatch_templates_with_mixed_file_check( root: &Path, then: impl FnOnce() -> Result, ) -> Result<(Vec, T), String> { let guard = MixedFileGuard::snapshot(root); let mut templates = dispatch_templates(&root.to_string_lossy()); - let rest = then()?; + let rest = then(); guard.annotate(root, &mut templates); - Ok((templates, rest)) + match rest { + Ok(rest) => Ok((templates, rest)), + Err(e) => { + let mixed: Vec<&str> = templates + .iter() + .filter(|t| t.warning.is_some()) + .map(|t| t.name.as_str()) + .collect(); + if mixed.is_empty() { + Err(e) + } else { + Err(format!( + "{e};注意:以下文件块外有未提交改动,本次已改写其中的炼境托管块,成为混合文件:{}", + mixed.join("、") + )) + } + } + } } // ── V1:lefthook 激活(分发 ≠ 生效,文件写入后必须 lefthook install 挂上 hook)── @@ -1292,13 +1317,18 @@ mod tests { // ── P10:混合文件预警 ──────────────────────────────────────────────── #[test] - fn outside_managed_content_strips_blocks_and_ignores_eol_and_blank_lines() { + fn outside_managed_content_strips_blocks_and_only_normalizes_eol_and_appended_tail() { let with_blocks = "# 标题\r\n\r\n项目内容\r\n\r\n\r\n炼境内容\r\n\r\n\r\n\n蓝图\n\n"; - assert_eq!(outside_managed_content(with_blocks), "# 标题\n项目内容"); + assert_eq!(outside_managed_content(with_blocks), "# 标题\n\n项目内容"); + // 之前追加的块尚未提交、只多出块前空行(原文末尾空白被追加时的 trim_end 吃掉):不算改动 assert!(!outside_managed_differs( Some("# 标题\n\n项目内容\n"), with_blocks )); + assert!(!outside_managed_differs( + Some("# 标题\n\n项目内容 \n\n\n"), + with_blocks + )); assert!(outside_managed_differs( Some("# 标题\n项目内容"), &with_blocks.replace("项目内容", "改过的项目内容") @@ -1316,6 +1346,41 @@ mod tests { ); } + #[test] + fn outside_managed_whitespace_edits_count_as_changes() { + let block = |ver: &str| { + format!( + "\n炼境内容\n" + ) + }; + let head = format!("# 项目文档\n\nA\n\nB\n\n{}\n", block("1.0.0")); + // 托管块版本不同、块外一致(含 CRLF 检出):不算改动 + assert!(!outside_managed_differs( + Some(&head), + &format!("# 项目文档\n\nA\n\nB\n\n{}\n", block("2.0.0")).replace('\n', "\r\n") + )); + // 删掉 A、B 间的空行(Markdown 两段并成一段):算改动 + assert!(outside_managed_differs( + Some(&head), + &format!("# 项目文档\n\nA\nB\n\n{}\n", block("1.0.0")) + )); + // 给 A 加两个行尾空格(Markdown 硬换行):算改动 + assert!(outside_managed_differs( + Some(&head), + &format!("# 项目文档\n\nA \n\nB\n\n{}\n", block("1.0.0")) + )); + // 文中托管块两侧的空行由项目改动:算改动(炼境只原位替换文中的块,不碰两侧) + let middle = format!("A\n\n{}\n\nB\n", block("1.0.0")); + assert!(!outside_managed_differs( + Some(&middle), + &middle.replace("1.0.0", "2.0.0") + )); + assert!(outside_managed_differs( + Some(&middle), + &format!("A\n\n{}\nB\n", block("1.0.0")) + )); + } + fn git_in(dir: &Path, args: &[&str]) -> bool { std::process::Command::new("git") .args([ @@ -1456,6 +1521,112 @@ mod tests { "无预警时不补 CLAUDE.md 结果行" ); + // 情形三:块外有未提交改动、但托管块已是最新(本次不改写)→ 不预警、不补 CLAUDE.md 行 + assert!(git_in(&base, &["add", "-A"])); + assert!(git_in( + &base, + &["commit", "-q", "--no-verify", "-m", "upgrade"] + )); + let agents_now = std::fs::read_to_string(&agents_path).unwrap(); + assert!(agents_now.contains("项目说明")); + std::fs::write( + &agents_path, + agents_now.replacen("项目说明", "项目说明(未提交改写)", 1), + ) + .unwrap(); + let mut workflow_now = std::fs::read_to_string(&workflow_path).unwrap(); + workflow_now.push_str("\n块外追加的项目约定\n"); + std::fs::write(&workflow_path, workflow_now).unwrap(); + let r = apply_like(&base); + assert_eq!(result_of(&r, "AGENTS.md").action, "skipped"); + assert_eq!( + result_of(&r, "docs/ai-context/git-workflow.md").action, + "skipped" + ); + assert!( + r.iter().all(|t| t.warning.is_none()), + "本次没改写的文件即使块外有未提交改动也不应预警: {r:?}" + ); + assert!( + !r.iter().any(|t| t.name == "CLAUDE.md"), + "CLAUDE.md 未被改写时不补结果行" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn mixed_file_warning_survives_failure_after_write() { + let base = std::env::temp_dir().join(format!( + "lianjing_onb_mixed_err_test_{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("docs/ai-context")).unwrap(); + let workflow_path = base.join("docs/ai-context/git-workflow.md"); + let workflow = outdated_versioned(TMPL_GIT_WORKFLOW, "项目分支约定"); + std::fs::write( + base.join("AGENTS.md"), + outdated_versioned(TMPL_AGENTS, "项目说明"), + ) + .unwrap(); + std::fs::write(&workflow_path, &workflow).unwrap(); + assert!(git_in(&base, &["init", "-q"])); + assert!(git_in(&base, &["add", "-A"])); + assert!(git_in( + &base, + &["commit", "-q", "--no-verify", "-m", "init"] + )); + let failing_after_write = || { + dispatch_templates_with_mixed_file_check(&base, || { + crate::commands::blueprint::sync_blueprint_rules( + base.to_string_lossy().into_owned(), + )?; + Err::<(), String>("database is locked".into()) + }) + .map(|_| ()) + }; + + // 无块外未提交改动:错误原样返回 + assert_eq!( + failing_after_write().unwrap_err(), + "database is locked", + "无混合文件时不改写错误信息" + ); + + // 还原到旧版托管块,git-workflow.md 块外改写后再失败:错误里须带混合文件名 + assert!(git_in( + &base, + &[ + "checkout", + "--", + "AGENTS.md", + "docs/ai-context/git-workflow.md" + ] + )); + std::fs::write( + &workflow_path, + workflow.replace("项目分支约定", "项目分支约定(未提交改写)"), + ) + .unwrap(); + let err = failing_after_write().unwrap_err(); + assert!( + err.starts_with("database is locked") + && err.contains("混合文件") + && err.contains("docs/ai-context/git-workflow.md"), + "后续步骤失败时预警须并入错误信息: {err}" + ); + assert!( + !err.contains("AGENTS.md"), + "块外无改动的文件不应列入: {err}" + ); + assert!( + std::fs::read_to_string(&workflow_path) + .unwrap() + .contains("项目分支约定(未提交改写)"), + "块外内容不得改动" + ); + let _ = std::fs::remove_dir_all(&base); } -- 2.45.2