fix(onboarding): 审查修复——JSON 按语义比较,CI workflow 与生成文件同内容不重写
All checks were successful
Push & PR Check / check (pull_request) Successful in 1m3s
All checks were successful
Push & PR Check / check (pull_request) Successful in 1m3s
对抗审查(9 个 agent,2 条成立): - settings.json / context.json 改为按 JSON 语义比较:Claude Code 改写后键序与末尾换行不同, 按文本比较仍会让每次注入整文件重排 - plan_cicd_workflow 忽略行尾判 noop:原按字节比较,autocrlf 检出的同内容 workflow 会被判 update, 并请用户确认一次无变化的写入;apply 与 generate_workflow 只在内容变化时落盘 - 同类遗漏统一改用 write_if_content_changed:agent_infra 生成文件(未变时报 skipped)、notes-archive 回填 - lib.rs 抽出 same_text_ignoring_eol,与 write_if_content_changed 共用同一比较规则 验证:cargo test 245 passed / 5 ignored(新增 JSON 语义与 CI CRLF 回归断言)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d098905f28
commit
019d81c60e
@ -161,10 +161,11 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
|
|||||||
### ✅ P3b — 接入写入幂等:同内容不重写、Codex 托管块不累积空行
|
### ✅ P3b — 接入写入幂等:同内容不重写、Codex 托管块不累积空行
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: S
|
- complexity: S
|
||||||
- files: src-tauri/src/lib.rs, src-tauri/src/mcp_inject.rs, src-tauri/src/commands/blueprint.rs
|
- files: src-tauri/src/lib.rs, src-tauri/src/mcp_inject.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/commands/cicd.rs, src-tauri/src/commands/agent_infra.rs, src-tauri/src/mcp_tools.rs
|
||||||
- acceptance: "agent_verify: [cargo test] 连续 upsert Codex 托管块两次字节不变,已累积的空行被吸收,块外后续内容保留;CRLF 检出的同内容 AGENTS/CLAUDE 托管块返回 unchanged 且不被 LF 重写;[MCP inject_mcp ×2] enterprise 的 .codex/config.toml、.claude/settings.json、.lian-jing/context.json、.claude/liangjing.json 第二次注入修改时间不变。"
|
- acceptance: "agent_verify: [cargo test] 连续 upsert Codex 托管块两次字节不变,已累积的空行被吸收,块外后续内容保留;CRLF 检出的同内容 AGENTS/CLAUDE 托管块返回 unchanged 且不被 LF 重写;语义相同但键序/行尾/末尾换行不同的 settings.json 不被重写;CRLF 检出的同内容 CI workflow 的 plan 为 noop;[MCP inject_mcp ×2] enterprise 的 .codex/config.toml、.claude/settings.json、.lian-jing/context.json、.claude/liangjing.json 第二次注入修改时间不变。"
|
||||||
- 为什么:2026-10-01 enterprise 未提交改动归属梳理发现两处炼境侧缺陷。① Codex 托管块自带结尾换行,又原样保留 END 之后的换行,每注入一次多一个空行(enterprise 已累积 16 行)。② sync_blueprint_rules 与注入在内容不变时也用 LF 重写文件:子项目开 core.autocrlf,CRLF 检出的同内容文件因大小变化被 git status 误报为改动(CONVENTIONS.md、context.json),修改时间也被刷新,干扰了「这次改动是谁做的」的判断。
|
- 为什么:2026-10-01 enterprise 未提交改动归属梳理发现两处炼境侧缺陷。① Codex 托管块自带结尾换行,又原样保留 END 之后的换行,每注入一次多一个空行(enterprise 已累积 16 行)。② sync_blueprint_rules 与注入在内容不变时也用 LF 重写文件:子项目开 core.autocrlf,CRLF 检出的同内容文件因大小变化被 git status 误报为改动(CONVENTIONS.md、context.json),修改时间也被刷新,干扰了「这次改动是谁做的」的判断。
|
||||||
- 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)需在子项目里由人刷新一次索引。"
|
- 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 — 接入包升级前检测目标文件的块外未提交改动
|
### 📋 P10 — 接入包升级前检测目标文件的块外未提交改动
|
||||||
- status: todo
|
- status: todo
|
||||||
|
|||||||
@ -489,12 +489,17 @@ fn write_file(path: &std::path::PathBuf, content: &str, rel: &str) -> FileResult
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match std::fs::write(path, content) {
|
match crate::write_if_content_changed(path, content) {
|
||||||
Ok(_) => FileResult {
|
Ok(true) => FileResult {
|
||||||
path: rel.into(),
|
path: rel.into(),
|
||||||
action: "written".into(),
|
action: "written".into(),
|
||||||
detail: "已写入".into(),
|
detail: "已写入".into(),
|
||||||
},
|
},
|
||||||
|
Ok(false) => FileResult {
|
||||||
|
path: rel.into(),
|
||||||
|
action: "skipped".into(),
|
||||||
|
detail: "内容未变,未重写".into(),
|
||||||
|
},
|
||||||
Err(e) => FileResult {
|
Err(e) => FileResult {
|
||||||
path: rel.into(),
|
path: rel.into(),
|
||||||
action: "failed".into(),
|
action: "failed".into(),
|
||||||
|
|||||||
@ -309,8 +309,13 @@ pub fn plan_cicd_workflow_for(
|
|||||||
let (content, filename, required_secrets, toolchain) =
|
let (content, filename, required_secrets, toolchain) =
|
||||||
build_workflow_for_type(&root, &config, workflow_type)?;
|
build_workflow_for_type(&root, &config, workflow_type)?;
|
||||||
let target = workflow_target(&root, &filename)?;
|
let target = workflow_target(&root, &filename)?;
|
||||||
|
// 忽略行尾比较:autocrlf 检出的同内容 workflow 不应被判为 update、再让用户确认一次无变化的写入
|
||||||
let change = match std::fs::read(&target) {
|
let change = match std::fs::read(&target) {
|
||||||
Ok(existing) if existing == content.as_bytes() => "noop",
|
Ok(existing)
|
||||||
|
if crate::same_text_ignoring_eol(&String::from_utf8_lossy(&existing), &content) =>
|
||||||
|
{
|
||||||
|
"noop"
|
||||||
|
}
|
||||||
Ok(_) => "update",
|
Ok(_) => "update",
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => "create",
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => "create",
|
||||||
Err(error) => return Err(format!("读取现有 workflow 失败:{error}")),
|
Err(error) => return Err(format!("读取现有 workflow 失败:{error}")),
|
||||||
@ -371,11 +376,9 @@ pub fn apply_cicd_workflow_for(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let written = plan.change != "noop";
|
let written = plan.change != "noop"
|
||||||
if written {
|
&& crate::write_if_content_changed(&target, &plan.content)
|
||||||
std::fs::write(&target, plan.content.as_bytes())
|
|
||||||
.map_err(|error| format!("写入 workflow 失败:{error}"))?;
|
.map_err(|error| format!("写入 workflow 失败:{error}"))?;
|
||||||
}
|
|
||||||
plan.path = target.to_string_lossy().to_string();
|
plan.path = target.to_string_lossy().to_string();
|
||||||
|
|
||||||
Ok(WorkflowApplyResult { plan, written })
|
Ok(WorkflowApplyResult { plan, written })
|
||||||
@ -486,7 +489,7 @@ pub fn generate_workflow(
|
|||||||
let workflows_dir = project_root.join(".gitea").join("workflows");
|
let workflows_dir = project_root.join(".gitea").join("workflows");
|
||||||
std::fs::create_dir_all(&workflows_dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&workflows_dir).map_err(|e| e.to_string())?;
|
||||||
let file_path = workflows_dir.join(&filename);
|
let file_path = workflows_dir.join(&filename);
|
||||||
std::fs::write(&file_path, &content).map_err(|e| e.to_string())?;
|
crate::write_if_content_changed(&file_path, &content).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(WorkflowResult {
|
Ok(WorkflowResult {
|
||||||
content,
|
content,
|
||||||
@ -1119,6 +1122,11 @@ mod tests {
|
|||||||
assert!(!second_apply.written);
|
assert!(!second_apply.written);
|
||||||
assert_eq!(second_apply.plan.change, "noop");
|
assert_eq!(second_apply.plan.change, "noop");
|
||||||
|
|
||||||
|
// 回归:autocrlf 检出的同内容 workflow(CRLF)仍判 noop,不请求确认一次无变化的写入
|
||||||
|
std::fs::write(&target, plan.content.replace('\n', "\r\n")).unwrap();
|
||||||
|
let crlf_plan = plan_cicd_workflow_for(&conn, "p1", "pr_ci").unwrap();
|
||||||
|
assert_eq!(crlf_plan.change, "noop");
|
||||||
|
|
||||||
std::fs::remove_dir_all(root).unwrap();
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,16 +18,21 @@ pub fn mcp_client_auth_headers_json(project_digest: &str) -> Result<String, Stri
|
|||||||
.map_err(|_| "MCP 认证头生成失败".to_string())
|
.map_err(|_| "MCP 认证头生成失败".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 两段文本忽略行尾差异(CRLF/LF)后是否相同。
|
||||||
|
pub(crate) fn same_text_ignoring_eol(left: &str, right: &str) -> bool {
|
||||||
|
left.replace("\r\n", "\n") == right.replace("\r\n", "\n")
|
||||||
|
}
|
||||||
|
|
||||||
/// 内容(忽略行尾差异)未变时不重写文件,返回是否真的写入。
|
/// 内容(忽略行尾差异)未变时不重写文件,返回是否真的写入。
|
||||||
/// 子项目常开 core.autocrlf:用 LF 重写 CRLF 检出的同内容文件会改变大小与修改时间,
|
/// 子项目常开 core.autocrlf:用 LF 重写 CRLF 检出的同内容文件会改变大小与修改时间,
|
||||||
/// git status 因此误报改动,修改时间也不再能说明「最后是谁改的」(2026-10-01 enterprise 归属梳理实测)。
|
/// git status 因此误报改动,修改时间也不再能说明「最后是谁改的」(2026-10-01 enterprise 归属梳理实测)。
|
||||||
|
/// 凡向子项目受跟踪文件写入,都应经此函数(或在其之前做更强的语义比较,如 JSON)。
|
||||||
pub(crate) fn write_if_content_changed(
|
pub(crate) fn write_if_content_changed(
|
||||||
path: &std::path::Path,
|
path: &std::path::Path,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> std::io::Result<bool> {
|
) -> std::io::Result<bool> {
|
||||||
let normalize = |text: &str| text.replace("\r\n", "\n");
|
|
||||||
if let Ok(existing) = std::fs::read_to_string(path) {
|
if let Ok(existing) = std::fs::read_to_string(path) {
|
||||||
if normalize(&existing) == normalize(content) {
|
if same_text_ignoring_eol(&existing, content) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -194,7 +194,19 @@ fn read_context_object(path: &PathBuf, label: &str) -> Result<Value, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 现有 JSON 文件与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写:
|
||||||
|
/// Claude Code 等客户端改写这些文件时用自己的键序与格式,按文本比较会让炼境每次注入都整文件重排。
|
||||||
|
fn json_file_equals(path: &std::path::Path, value: &Value) -> bool {
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|text| serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok())
|
||||||
|
.is_some_and(|existing| &existing == value)
|
||||||
|
}
|
||||||
|
|
||||||
fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> {
|
fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> {
|
||||||
|
if json_file_equals(path, value) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.map_err(|error| format!("创建 {label} 目录失败: {error}"))?;
|
.map_err(|error| format!("创建 {label} 目录失败: {error}"))?;
|
||||||
@ -1040,6 +1052,9 @@ fn read_json(path: &PathBuf) -> Result<Value, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
||||||
|
if json_file_equals(path, value) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
||||||
}
|
}
|
||||||
@ -1416,6 +1431,33 @@ mod tests {
|
|||||||
|
|
||||||
/// 回归:托管块原先每次注入都在 END 之后多留一个空行(enterprise 已累积 15 行),
|
/// 回归:托管块原先每次注入都在 END 之后多留一个空行(enterprise 已累积 15 行),
|
||||||
/// 且内容不变也会用 LF 重写 CRLF 检出的文件。
|
/// 且内容不变也会用 LF 重写 CRLF 检出的文件。
|
||||||
|
/// 回归:Claude Code 改写 settings.json 后键序与末尾换行不同,语义未变时注入不应整文件重排。
|
||||||
|
#[test]
|
||||||
|
fn json_writes_skip_semantically_equal_files() {
|
||||||
|
let base = std::env::temp_dir().join(format!("lianjing_json_idem_{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let path = base.join("settings.json");
|
||||||
|
let client_written = "{\r\n \"permissions\": {\"allow\": []},\r\n \"mcpServers\": {\"lian-jing\": {\"type\": \"sse\", \"url\": \"u\"}}\r\n}\r\n";
|
||||||
|
std::fs::write(&path, client_written).unwrap();
|
||||||
|
let value: Value = serde_json::from_str(client_written).unwrap();
|
||||||
|
write_json(&path, &value).unwrap();
|
||||||
|
write_context_object(&path, &value, "settings.json").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&path).unwrap(),
|
||||||
|
client_written,
|
||||||
|
"语义相同,保留客户端的键序、行尾与末尾换行"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut changed = value.clone();
|
||||||
|
changed["mcpServers"]["lian-jing"]["url"] = json!("u2");
|
||||||
|
write_json(&path, &changed).unwrap();
|
||||||
|
let written: Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||||
|
assert_eq!(written, changed, "语义变化时照常写入");
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn codex_mcp_upsert_is_idempotent_and_absorbs_accumulated_blank_lines() {
|
fn codex_mcp_upsert_is_idempotent_and_absorbs_accumulated_blank_lines() {
|
||||||
let base = std::env::temp_dir().join(format!("lianjing_codex_idem_{}", std::process::id()));
|
let base = std::env::temp_dir().join(format!("lianjing_codex_idem_{}", std::process::id()));
|
||||||
|
|||||||
@ -1744,7 +1744,8 @@ fn backfill_notes_archive(conn: &rusqlite::Connection) -> Result<String, String>
|
|||||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
let lines: Vec<&str> = entries.iter().map(|(_, l)| l.as_str()).collect();
|
let lines: Vec<&str> = entries.iter().map(|(_, l)| l.as_str()).collect();
|
||||||
std::fs::write(&path, lines.join("\n") + "\n").map_err(|e| e.to_string())?;
|
crate::write_if_content_changed(&path, &(lines.join("\n") + "\n"))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
archived_notes += entries.len();
|
archived_notes += entries.len();
|
||||||
report.push_str(&format!(
|
report.push_str(&format!(
|
||||||
"- ✅ {}:{} 条 → {}\n",
|
"- ✅ {}:{} 条 → {}\n",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user