fix(onboarding): recognize custom lefthook checks #10

Merged
lanrtop merged 1 commits from fix/onboarding-custom-lefthook-check into master 2026-09-21 21:56:04 +08:00
3 changed files with 81 additions and 19 deletions
Showing only changes of commit 057c2b3a10 - Show all commits

View File

@ -465,7 +465,7 @@ modules:
area: infra area: infra
status: done status: done
progress: 100 progress: 100
note: "P2 完成:工具无关 canonical context、版本化能力包自检与 Codex Skill-only 投影已闭环;不复制 MCP、凭证或部署能力。" note: "P2 完成:工具无关 canonical context、版本化能力包自检、Codex Skill-only 投影与项目自定义 lefthook 等价能力识别已闭环;不复制 MCP、凭证或部署能力。"
updated: 2026-09-21 updated: 2026-09-21
position: [2250, 800] position: [2250, 800]
edges: edges:

View File

@ -56,3 +56,10 @@ Codex 用户级插件
- files: AGENTS.md, docs/ai-context/project-brief.md, docs/ai-context/project-context.yaml, docs/templates/agents-meta-rules.md, resources/onboarding/ - files: AGENTS.md, docs/ai-context/project-brief.md, docs/ai-context/project-context.yaml, docs/templates/agents-meta-rules.md, resources/onboarding/
- acceptance: "[cargo test] 接入包升级检查 → 旧 context/旧能力包给出明确 upgrade_hint 且不自动覆盖用户配置;[rg] 常驻架构文档将炼境描述为项目控制面、客户端描述为 adapter,且不把客户端插件表述为凭证或部署所有者。" - acceptance: "[cargo test] 接入包升级检查 → 旧 context/旧能力包给出明确 upgrade_hint 且不自动覆盖用户配置;[rg] 常驻架构文档将炼境描述为项目控制面、客户端描述为 adapter,且不把客户端插件表述为凭证或部署所有者。"
- done_note: "2026-09-21:base-onboarding 升至 v1.1.0;旧能力包会显式返回 version_mismatch 与升级提示。托管模板、Git 工作流与常驻架构文档统一为 canonical context / 控制面 / 客户端 adapter 边界。" - done_note: "2026-09-21:base-onboarding 升至 v1.1.0;旧能力包会显式返回 version_mismatch 与升级提示。托管模板、Git 工作流与常驻架构文档统一为 canonical context / 控制面 / 客户端 adapter 边界。"
### ✅ P2-E. 自定义 lefthook 等价能力自检
- status: done
- complexity: S
- files: src-tauri/src/commands/onboarding.rs
- acceptance: "[cargo test] verify_onboarding 的 lefthook 自检 → web/API 分层 typecheck 与 commit-msg scripts 中的 Conventional 校验被识别为 ready;缺少等价能力时仍返回 missing,不因注释或无关键误报。"
- done_note: "2026-09-21:自检保留模板为基线,但支持命名为 typecheck-* 的分层 pre-commit 命令,以及 commit-msg scripts 中含 conventional 的校验;其余基线能力仍要求明确存在,避免自定义配置被误报或宽松放行。"

View File

@ -592,27 +592,39 @@ fn check_origin(origin_host: Option<&str>, instance_hosts: &[String]) -> (bool,
} }
} }
/// 从 lefthook.yml 内容提取 commands 键集合(` commands:` 下缩进 4 空格的键行)。 /// 从指定 hook 的指定段提取缩进四空格的键集合。
/// 纯函数;注释与更深缩进(run: | 多行体等)忽略。用于 U1 检查项自省—— /// 纯函数;注释与更深缩进(run: | 多行体等)忽略。
/// 期望键集合取自内嵌模板,禁止硬编码具体检查项名(模板演进校验器零维护)。 fn lefthook_section_keys(yml: &str, hook: &str, section: &str) -> Vec<String> {
pub fn lefthook_command_keys(yml: &str) -> Vec<String> {
let mut keys = Vec::new(); let mut keys = Vec::new();
let mut in_commands = false; let mut in_hook = false;
let mut in_section = false;
for line in yml.lines() { for line in yml.lines() {
let trimmed = line.trim_start(); let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') { if trimmed.is_empty() || trimmed.starts_with('#') {
continue; continue;
} }
let indent = line.len() - trimmed.len(); let indent = line.len() - trimmed.len();
if indent == 2 && trimmed.trim_end() == "commands:" { if indent == 0 && trimmed.ends_with(':') {
in_commands = true; in_hook = trimmed.trim_end().trim_end_matches(':') == hook;
in_section = false;
continue; continue;
} }
if indent < 4 { if !in_hook {
in_commands = false;
continue; continue;
} }
if in_commands && indent == 4 { if indent == 2 && trimmed.trim_end() == format!("{section}:") {
in_section = true;
continue;
}
if indent == 2 {
in_section = false;
continue;
}
if indent < 2 {
in_section = false;
continue;
}
if in_section && indent == 4 {
if let Some(key) = trimmed.trim_end().strip_suffix(':') { if let Some(key) = trimmed.trim_end().strip_suffix(':') {
keys.push(key.to_string()); keys.push(key.to_string());
} }
@ -621,23 +633,43 @@ pub fn lefthook_command_keys(yml: &str) -> Vec<String> {
keys keys
} }
/// U1 检查:项目 lefthook.yml 是否缺少模板要求的检查段(lefthook 不自动覆盖,只检测落后)。 /// 从 lefthook.yml 内容提取 commands 键集合。
pub fn lefthook_command_keys(yml: &str) -> Vec<String> {
let mut keys = lefthook_section_keys(yml, "pre-commit", "commands");
keys.extend(lefthook_section_keys(yml, "commit-msg", "commands"));
keys
}
/// U1 检查:项目 lefthook.yml 是否缺少模板要求的检查能力。
///
/// lefthook.yml 是项目可定制文件,不能把模板中的键名当成唯一实现。保留模板
/// 自省作为基线,但识别两类等价能力:分层 typecheck 命令,以及 commit-msg
/// 目录中的 Conventional script。其余模板检查仍要求同名命令,避免将任意注释或
/// 无关脚本误报为 ready。
fn check_lefthook_keys(project_yml: Option<&str>, template_yml: &str) -> (bool, String) { fn check_lefthook_keys(project_yml: Option<&str>, template_yml: &str) -> (bool, String) {
let Some(yml) = project_yml else { let Some(yml) = project_yml else {
return (false, "lefthook.yml 缺失——重跑接入包写入".to_string()); return (false, "lefthook.yml 缺失——重跑接入包写入".to_string());
}; };
let have: Vec<String> = lefthook_command_keys(yml); let commands = lefthook_command_keys(yml);
let commit_msg_scripts = lefthook_section_keys(yml, "commit-msg", "scripts");
let missing: Vec<String> = lefthook_command_keys(template_yml) let missing: Vec<String> = lefthook_command_keys(template_yml)
.into_iter() .into_iter()
.filter(|k| !have.contains(k)) .filter(|required| match required.as_str() {
"typecheck" => !commands.iter().any(|key| key.contains("typecheck")),
"conventional" => !commands
.iter()
.chain(commit_msg_scripts.iter())
.any(|key| key.contains("conventional")),
_ => !commands.contains(required),
})
.collect(); .collect();
if missing.is_empty() { if missing.is_empty() {
(true, "模板要求的检查段齐全".to_string()) (true, "模板要求的检查能力齐全".to_string())
} else { } else {
( (
false, false,
format!( format!(
"缺少检查段: {}——lefthook.yml 不自动覆盖,请手动对照模板补齐", "缺少检查能力: {}——lefthook.yml 不自动覆盖,请手动对照模板补齐",
missing.join(", ") missing.join(", ")
), ),
) )
@ -1201,14 +1233,14 @@ mod tests {
); );
// 项目文件与模板一致 → pass // 项目文件与模板一致 → pass
let (pass, _) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK); let (pass, detail) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK);
assert!(pass); assert!(pass, "模板应满足自身检查: {detail}");
// 缺段 → fail 且列出缺失键 // 缺段 → fail 且列出缺失键
let partial = "pre-commit:\n commands:\n typecheck:\n run: pnpm typecheck\n"; let partial = "pre-commit:\n commands:\n typecheck:\n run: pnpm typecheck\n";
let (pass, detail) = check_lefthook_keys(Some(partial), TMPL_LEFTHOOK); let (pass, detail) = check_lefthook_keys(Some(partial), TMPL_LEFTHOOK);
assert!(!pass); assert!(!pass);
assert!(detail.contains("缺少检查段"), "应列出缺失键: {detail}"); assert!(detail.contains("缺少检查能力"), "应列出缺失键: {detail}");
assert!( assert!(
!detail.contains("typecheck,") && detail.contains("blueprint-gate"), !detail.contains("typecheck,") && detail.contains("blueprint-gate"),
"已有键不在缺失列表: {detail}" "已有键不在缺失列表: {detail}"
@ -1216,6 +1248,29 @@ mod tests {
// 文件缺失 → fail // 文件缺失 → fail
assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0); assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0);
// 项目可将 typecheck 拆到 web/API,并把 Conventional 校验声明为 commit-msg script。
let custom = r#"
pre-commit:
commands:
typecheck-web:
run: pnpm --filter web typecheck
typecheck-api:
run: pnpm --filter api typecheck
blueprint-gate:
run: node scripts/blueprint-gate.cjs || true
commit-msg:
scripts:
"check-conventional.sh":
runner: bash
commands:
feature-confirmed:
run: test -n "$1"
"#;
assert!(
check_lefthook_keys(Some(custom), TMPL_LEFTHOOK).0,
"等价的项目自定义检查不应被误报缺失"
);
} }
// ── B4:使者回传覆盖率(沉默项目信号)──────────────────────────────────── // ── B4:使者回传覆盖率(沉默项目信号)────────────────────────────────────