From 7e157539e36c165ee3e3e640a342fc3303695ea2 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 15 May 2026 17:49:15 +0800 Subject: [PATCH] refactor(product-domains): move pure product parsing policies Move MiniApp allowlist and function-agent AI response parsing policy into bitfun-product-domains while keeping host execution, AI calls, JSON extraction, and runtime adapters in core. Add contract tests, boundary-check anchors, and plan docs that mark runtime owner migration as still deferred. --- docs/architecture/core-decomposition.md | 12 +-- docs/plans/core-decomposition-plan.md | 8 +- scripts/check-core-boundaries.mjs | 88 +++++++++++++++++++ .../git-func-agent/ai_service.rs | 21 +---- .../startchat-func-agent/ai_service.rs | 52 +++-------- src/crates/core/src/miniapp/host_dispatch.rs | 13 ++- .../function_agents/git_func_agent/utils.rs | 18 ++++ .../startchat_func_agent/utils.rs | 38 ++++++++ .../src/miniapp/host_routing.rs | 14 +++ .../tests/function_agent_contracts.rs | 83 ++++++++++++++++- .../tests/miniapp_contracts.rs | 25 +++++- 11 files changed, 291 insertions(+), 81 deletions(-) diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index a16ee8cc7..09885920b 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -153,12 +153,14 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate - 中风险:在 owner crate 内为纯模块补 feature group、把 core 中的重依赖改为 optional 但 仍由 `product-full` 启用、把只依赖 port 的 helper 迁入 owner crate。 - 当前 `product-domains` 可继续承载 MiniApp runtime search plan、worker install 命令选择、 - package.json storage-shape helper、lifecycle / revision helper、host routing string helper 等纯决策 / - 解析逻辑;实际 runtime detection、worker pool、storage IO、PathManager、进程执行、 - host dispatch 执行与 builtin asset seeding 仍留在 core product runtime。 + package.json storage-shape helper、lifecycle / revision helper、host routing / allowlist helper、 + customization metadata / permission diff 等纯决策 / 解析逻辑;实际 runtime detection、worker pool、 + storage IO、PathManager、进程执行、host dispatch 执行、customization draft 存储 / 应用与 builtin + asset seeding 仍留在 core product runtime。 - `product-domains` 可以先定义 MiniApp runtime/storage 与 function-agent Git/AI 的 port - contract;core-owned adapter 只能在不改变执行路径的前提下委托现有 service,并先补等价 - 测试。IO/进程/AI/Git 执行 owner 迁移仍属于后续高风险步骤。 + contract,并承载 function-agent 的纯 prompt / AI response parsing policy;core-owned adapter + 只能在不改变执行路径的前提下委托现有 service,并先补等价测试。IO/进程/AI/Git 执行 owner + 迁移仍属于后续高风险步骤。 - 高风险:`ToolUseContext`、product tool registry / manifest / exposure / `GetToolSpec` owner 化、 MCP concrete tool integration、remote-connect、remote SSH runtime、miniapp / function-agent runtime、 agent registry、`bitfun-core default = []` diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 0a048aaf6..a105a69f1 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1120,9 +1120,9 @@ product-full = ["miniapp", "function-agents"] **当前安全迁移状态(2026-05-14):** - 已迁移到 `bitfun-product-domains::miniapp`:`types`、`bridge_builder`、`permission_policy`,core 旧路径继续 re-export。 -- 已迁移到 `bitfun-product-domains::miniapp`:纯 compiler、export DTO、runtime detection DTO、runtime search path plan、worker install result DTO、worker install 命令选择、package.json storage-shape helper,以及 runtime/storage port contract;core `miniapp::compiler::compile` 继续映射为原 `BitFunResult` API,runtime detection / exporter / worker pool / storage IO 执行逻辑仍留在 core,目前仅通过 core-owned storage/runtime adapter 和等价测试保护现有路径。 -- 已迁移到 `bitfun-product-domains::function_agents`:公共 `common` 类型、git/startchat function-agent 的纯 DTO 类型、git function-agent 的纯路径 / 变更分类 / commit summary / message assembly / prompt format / commit type parser helper、startchat prompt / action / git porcelain / diff combine / time-of-day helper、Git/AI port contract,以及只读本地文件的 project context analyzer;core-owned Git snapshot adapter 已由等价测试覆盖,AI client、Git service、prompt template、AI request、JSON extraction 与分析运行逻辑仍留在 core。 -- boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、function-agent Git adapter 仍必须存在,防止把 port contract 误读成 storage IO、worker process 或 Git service runtime 已完成迁移。 +- 已迁移到 `bitfun-product-domains::miniapp`:纯 compiler、export DTO、runtime detection DTO、runtime search path plan、worker install result DTO、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff,以及 runtime/storage port contract;core `miniapp::compiler::compile` 继续映射为原 `BitFunResult` API,runtime detection / exporter / host dispatch 执行 / customization draft 存储与应用 / worker pool / storage IO 执行逻辑仍留在 core,目前仅通过 core-owned storage/runtime adapter 和等价测试保护现有路径。 +- 已迁移到 `bitfun-product-domains::function_agents`:公共 `common` 类型、git/startchat function-agent 的纯 DTO 类型、git function-agent 的纯路径 / 变更分类 / commit summary / message assembly / prompt format / commit type parser / AI response parsing policy、startchat prompt / action / AI response parsing policy / git porcelain / diff combine / time-of-day helper、Git/AI port contract,以及只读本地文件的 project context analyzer;core-owned Git snapshot adapter 已由等价测试覆盖,AI client、Git service、prompt template、AI request、JSON extraction、错误映射与分析运行逻辑仍留在 core。 +- boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization 纯 contract、function-agent Git adapter 与 AI response parsing helper 必须存在,防止把 port contract 或 pure parser 误读成 storage IO、worker process、host dispatch、customization draft runtime、Git/AI service runtime 已完成迁移。 - miniapp runtime/storage/manager/host dispatch/exporter/builtin 与 function-agent 运行逻辑继续迁移前,需要先确认 agent/tool/provider port 和 Git/AI service 边界。 **验证:** @@ -1536,7 +1536,7 @@ cargo check --workspace - 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、tool exposure / manifest / `GetToolSpec` owner 化、concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 - 本次 rebase 后重新核对最新主干 Deep Review capacity/cost/queue、context profile、evidence ledger 与 session manifest 变更:当前 PR 已完成 Git feature group 的 owner crate 归属迁移,但未改动这些 Deep Review 行为路径;后续迁移必须补端口设计和等价测试后再推进。 - 本次 rebase 后重新核对最新主干 tool 变更:on-demand tool spec discovery 新增 collapsed/expanded manifest、`GetToolSpec`、context-aware schema/description 与 unlock state。这不要求回退当前 P2 已完成内容,但要求后续 tool/provider 迁移先补 manifest / catalog / unlock 等价保护,且不得和 PR5 product-domain runtime 收口混合。 -- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string helper、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper;已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试。PathManager、Git/AI service、prompt template、builtin asset seeding、host dispatch 执行、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 +- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / AI response parsing policy / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper;core 只保留 AI client 调用、JSON 提取、错误映射、Git service adapter 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试。PathManager、Git/AI service、prompt template、builtin asset seeding、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 - 本次 P2 后续复核结论:上述高耦合剩余项不是纯文件搬迁;若继续迁移会改变依赖方向或需要新增 port/provider 行为合约。因此当前 PR 将它们显式保留为 core-owned runtime,只完成低风险 owner container 化,并通过 boundary check 防止已拆 owner crate 回流依赖 core。 **后续风险重排(2026-05-13):** diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 0aece7b6a..9d8d2526d 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -1487,6 +1487,70 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/product-domains/src/miniapp/host_routing.rs', + reason: + 'product-domains owns MiniApp host-routing and allowlist string policy while core keeps host execution', + patterns: [ + { + regex: /\bpub fn command_basename_for_allowlist\b/, + message: 'missing MiniApp command basename allowlist helper', + }, + { + regex: /\bpub fn command_basename_allowed\b/, + message: 'missing MiniApp command allowlist policy helper', + }, + { + regex: /\bpub fn host_allowed_by_allowlist\b/, + message: 'missing MiniApp host allowlist policy helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/customization.rs', + reason: + 'product-domains owns MiniApp customization metadata and permission-diff contracts while core keeps draft storage/runtime', + patterns: [ + { + regex: /\bpub struct MiniAppCustomizationMetadata\b/, + message: 'missing MiniApp customization metadata contract', + }, + { + regex: /\bpub struct MiniAppPermissionDiff\b/, + message: 'missing MiniApp permission diff contract', + }, + { + regex: /\bpub fn diff_permissions\b/, + message: 'missing MiniApp permission diff helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs', + reason: + 'product-domains owns pure Startchat function-agent parsing policy while core keeps AI calls and error mapping', + patterns: [ + { + regex: /\bpub struct ParsedCompleteAnalysis\b/, + message: 'missing Startchat complete-analysis parse result contract', + }, + { + regex: /\bpub fn parse_complete_analysis_value\b/, + message: 'missing Startchat complete-analysis value parser', + }, + ], + }, + { + path: 'src/crates/product-domains/src/function_agents/git_func_agent/utils.rs', + reason: + 'product-domains owns pure Git function-agent response parsing policy while core keeps AI calls and error mapping', + patterns: [ + { + regex: /\bpub fn parse_commit_analysis_value\b/, + message: 'missing Git function-agent commit analysis value parser', + }, + ], + }, { path: 'src/crates/core/src/miniapp/js_worker_pool.rs', reason: @@ -2044,6 +2108,30 @@ function runManifestParserSelfTest() { path: 'src/crates/product-domains/src/miniapp/storage.rs', contracts: ['MiniAppStorageLayout', 'META_JSON', 'source_file_path', 'versions_dir'], }, + { + path: 'src/crates/product-domains/src/miniapp/host_routing.rs', + contracts: [ + 'command_basename_for_allowlist', + 'command_basename_allowed', + 'host_allowed_by_allowlist', + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/customization.rs', + contracts: [ + 'MiniAppCustomizationMetadata', + 'MiniAppPermissionDiff', + 'diff_permissions', + ], + }, + { + path: 'src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs', + contracts: ['ParsedCompleteAnalysis', 'parse_complete_analysis_value'], + }, + { + path: 'src/crates/product-domains/src/function_agents/git_func_agent/utils.rs', + contracts: ['parse_commit_analysis_value'], + }, ]; for (const { path, contracts } of requiredContentContracts) { const rule = requiredContentRules.find((rule) => rule.path === path); diff --git a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs index 57402ec54..17cd3ecba 100644 --- a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs @@ -8,7 +8,6 @@ use crate::util::types::Message; * Handles AI client interaction and provides intelligent analysis for commit message generation */ use log::{debug, error, warn}; -use serde_json::Value; use std::sync::Arc; /// Prompt template constants (embedded at compile time) @@ -102,27 +101,11 @@ impl AIAnalysisService { let json_str = crate::util::extract_json_from_ai_response(response) .ok_or_else(|| AgentError::analysis_error("Cannot extract JSON from response"))?; - let value: Value = serde_json::from_str(&json_str).map_err(|e| { + let value: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| { AgentError::analysis_error(format!("Failed to parse AI response: {}", e)) })?; - Ok(AICommitAnalysis { - commit_type: super::utils::parse_commit_type_label( - value["type"].as_str().unwrap_or("chore"), - ), - scope: value["scope"].as_str().map(|s| s.to_string()), - title: value["title"] - .as_str() - .ok_or_else(|| AgentError::analysis_error("Missing title field"))? - .to_string(), - body: value["body"].as_str().map(|s| s.to_string()), - breaking_changes: value["breaking_changes"].as_str().map(|s| s.to_string()), - reasoning: value["reasoning"] - .as_str() - .unwrap_or("AI analysis") - .to_string(), - confidence: value["confidence"].as_f64().unwrap_or(0.8) as f32, - }) + super::utils::parse_commit_analysis_value(&value).map_err(AgentError::analysis_error) } fn truncate_diff_if_needed(&self, diff: &str, max_chars: usize) -> String { diff --git a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs index 51545ef57..096caa65a 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs @@ -115,67 +115,39 @@ impl AIWorkStateService { AgentError::internal_error(format!("Failed to parse complete analysis response: {}", e)) })?; - let summary = parsed["summary"] - .as_str() - .unwrap_or("You were working on development, with multiple files modified.") - .to_string(); + let parsed_analysis = super::utils::parse_complete_analysis_value(&parsed); - let ongoing_work = Vec::new(); - - let predicted_actions = if let Some(actions_array) = parsed["predicted_actions"].as_array() - { - super::utils::parse_predicted_actions_from_values(actions_array) - } else { - Vec::new() - }; - let predicted_actions_count = predicted_actions.len(); - let predicted_actions = super::utils::normalize_predicted_actions(predicted_actions); - - if predicted_actions_count < 3 { + if parsed_analysis.predicted_actions_count < 3 { warn!( "AI generated insufficient predicted actions ({}), adding defaults", - predicted_actions_count + parsed_analysis.predicted_actions_count ); - } else if predicted_actions_count > 3 { + } else if parsed_analysis.predicted_actions_count > 3 { warn!( "AI generated too many predicted actions ({}), truncating to 3", - predicted_actions_count + parsed_analysis.predicted_actions_count ); } - let quick_actions = if let Some(actions_array) = parsed["quick_actions"].as_array() { - super::utils::parse_quick_actions_from_values(actions_array) - } else { - Vec::new() - }; - - let quick_actions_count = quick_actions.len(); - let quick_actions = super::utils::limit_quick_actions(quick_actions); - - if quick_actions_count < 6 { + if parsed_analysis.quick_actions_count < 6 { // Don't fill defaults here, frontend has its own defaultActions with i18n support warn!( "AI generated insufficient quick actions ({}), frontend will use defaults", - quick_actions_count + parsed_analysis.quick_actions_count ); - } else if quick_actions_count > 6 { + } else if parsed_analysis.quick_actions_count > 6 { warn!( "AI generated too many quick actions ({}), truncating to 6", - quick_actions_count + parsed_analysis.quick_actions_count ); } debug!( "Parsing completed: predicted_actions={}, quick_actions={}", - predicted_actions.len(), - quick_actions.len() + parsed_analysis.analysis.predicted_actions.len(), + parsed_analysis.analysis.quick_actions.len() ); - Ok(AIGeneratedAnalysis { - summary, - ongoing_work, - predicted_actions, - quick_actions, - }) + Ok(parsed_analysis.analysis) } } diff --git a/src/crates/core/src/miniapp/host_dispatch.rs b/src/crates/core/src/miniapp/host_dispatch.rs index df081f65c..7afe5ef5c 100644 --- a/src/crates/core/src/miniapp/host_dispatch.rs +++ b/src/crates/core/src/miniapp/host_dispatch.rs @@ -26,8 +26,10 @@ use crate::miniapp::permission_policy::resolve_policy; use crate::miniapp::types::MiniAppPermissions; use crate::util::errors::{BitFunError, BitFunResult}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use bitfun_product_domains::miniapp::host_routing::command_basename_for_allowlist; pub use bitfun_product_domains::miniapp::host_routing::is_host_primitive; +use bitfun_product_domains::miniapp::host_routing::{ + command_basename_allowed, command_basename_for_allowlist, host_allowed_by_allowlist, +}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -364,7 +366,7 @@ async fn dispatch_shell( None => command.split_whitespace().next().unwrap_or(""), }; let base = command_basename_for_allowlist(first_token); - if !allow.is_empty() && !allow.iter().any(|a| a.to_lowercase() == base) { + if !command_basename_allowed(&allow, &base) { return Err(deny(format!("Command not in allowlist: {}", base))); } @@ -485,12 +487,7 @@ async fn dispatch_net(policy: &Value, name: &str, params: &Value) -> BitFunResul .collect() }) .unwrap_or_default(); - if !allow.is_empty() - && !allow.iter().any(|a| a == "*") - && !allow - .iter() - .any(|a| host == *a || host.ends_with(&format!(".{}", a))) - { + if !host_allowed_by_allowlist(&allow, &host) { return Err(deny(format!("Domain not in allowlist: {}", host))); } diff --git a/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs b/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs index e5a4fd992..b026d622f 100644 --- a/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs +++ b/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs @@ -240,3 +240,21 @@ pub fn parse_commit_type_label(label: &str) -> CommitType { _ => CommitType::Chore, } } + +pub fn parse_commit_analysis_value(value: &serde_json::Value) -> Result { + Ok(AICommitAnalysis { + commit_type: parse_commit_type_label(value["type"].as_str().unwrap_or("chore")), + scope: value["scope"].as_str().map(|s| s.to_string()), + title: value["title"] + .as_str() + .ok_or_else(|| "Missing title field".to_string())? + .to_string(), + body: value["body"].as_str().map(|s| s.to_string()), + breaking_changes: value["breaking_changes"].as_str().map(|s| s.to_string()), + reasoning: value["reasoning"] + .as_str() + .unwrap_or("AI analysis") + .to_string(), + confidence: value["confidence"].as_f64().unwrap_or(0.8) as f32, + }) +} diff --git a/src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs b/src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs index 885e392e0..fb56fee5f 100644 --- a/src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs +++ b/src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs @@ -137,6 +137,44 @@ pub fn limit_quick_actions(mut actions: Vec) -> Vec { actions } +pub struct ParsedCompleteAnalysis { + pub analysis: AIGeneratedAnalysis, + pub predicted_actions_count: usize, + pub quick_actions_count: usize, +} + +pub fn parse_complete_analysis_value(parsed: &serde_json::Value) -> ParsedCompleteAnalysis { + let summary = parsed["summary"] + .as_str() + .unwrap_or("You were working on development, with multiple files modified.") + .to_string(); + + let predicted_actions = parsed["predicted_actions"] + .as_array() + .map(|actions_array| parse_predicted_actions_from_values(actions_array)) + .unwrap_or_default(); + let predicted_actions_count = predicted_actions.len(); + let predicted_actions = normalize_predicted_actions(predicted_actions); + + let quick_actions = parsed["quick_actions"] + .as_array() + .map(|actions_array| parse_quick_actions_from_values(actions_array)) + .unwrap_or_default(); + let quick_actions_count = quick_actions.len(); + let quick_actions = limit_quick_actions(quick_actions); + + ParsedCompleteAnalysis { + analysis: AIGeneratedAnalysis { + summary, + ongoing_work: Vec::new(), + predicted_actions, + quick_actions, + }, + predicted_actions_count, + quick_actions_count, + } +} + pub fn parse_action_priority_label(label: &str) -> ActionPriority { match label { "High" => ActionPriority::High, diff --git a/src/crates/product-domains/src/miniapp/host_routing.rs b/src/crates/product-domains/src/miniapp/host_routing.rs index 43c3cc3c6..10bc55f3c 100644 --- a/src/crates/product-domains/src/miniapp/host_routing.rs +++ b/src/crates/product-domains/src/miniapp/host_routing.rs @@ -27,3 +27,17 @@ pub fn command_basename_for_allowlist(command: &str) -> String { .unwrap_or(file_name) .to_lowercase() } + +pub fn command_basename_allowed(allowlist: &[String], basename: &str) -> bool { + allowlist.is_empty() + || allowlist + .iter() + .any(|allowed| allowed.to_lowercase() == basename) +} + +pub fn host_allowed_by_allowlist(allowlist: &[String], host: &str) -> bool { + allowlist.is_empty() + || allowlist.iter().any(|allowed| { + allowed == "*" || host == allowed || host.ends_with(&format!(".{}", allowed)) + }) +} diff --git a/src/crates/product-domains/tests/function_agent_contracts.rs b/src/crates/product-domains/tests/function_agent_contracts.rs index 5cf1b54f0..ccfad912f 100644 --- a/src/crates/product-domains/tests/function_agent_contracts.rs +++ b/src/crates/product-domains/tests/function_agent_contracts.rs @@ -3,9 +3,9 @@ use bitfun_product_domains::function_agents::{ git_func_agent::{ assemble_commit_message, build_changes_summary_from_paths, build_commit_prompt, - detect_change_patterns, extract_module_name, infer_file_type, parse_commit_type_label, - ChangePattern, CommitFormat, CommitMessageOptions, CommitType, FileChange, FileChangeType, - ProjectContext, + detect_change_patterns, extract_module_name, infer_file_type, parse_commit_analysis_value, + parse_commit_type_label, ChangePattern, CommitFormat, CommitMessageOptions, CommitType, + FileChange, FileChangeType, ProjectContext, }, ports::{ CommitAiAnalysisRequest, FunctionAgentAiPort, FunctionAgentFuture, FunctionAgentGitPort, @@ -13,7 +13,7 @@ use bitfun_product_domains::function_agents::{ }, startchat_func_agent::{ build_complete_analysis_prompt, combine_git_diffs, limit_quick_actions, - normalize_predicted_actions, parse_git_status_porcelain, + normalize_predicted_actions, parse_complete_analysis_value, parse_git_status_porcelain, parse_predicted_actions_from_values, parse_quick_actions_from_values, time_of_day_for_hour, ActionPriority, GitWorkState, QuickActionType, TimeOfDay, WorkStateOptions, }, @@ -172,6 +172,37 @@ fn git_function_agent_summary_helpers_preserve_commit_shape() { assert_eq!(title_only, "chore: tidy"); } +#[test] +fn git_function_agent_analysis_parser_preserves_defaults_and_required_title() { + let analysis = parse_commit_analysis_value(&serde_json::json!({ + "type": "feature", + "scope": "core", + "title": "feat(core): add helper", + "body": "Move pure parsing policy.", + "breaking_changes": "none", + "confidence": 0.95 + })) + .expect("valid commit analysis"); + + assert_eq!(analysis.commit_type, CommitType::Feat); + assert_eq!(analysis.scope.as_deref(), Some("core")); + assert_eq!(analysis.title, "feat(core): add helper"); + assert_eq!(analysis.reasoning, "AI analysis"); + assert!((analysis.confidence - 0.95).abs() < f32::EPSILON); + + let fallback = parse_commit_analysis_value(&serde_json::json!({ + "title": "chore: tidy" + })) + .expect("fallback commit analysis"); + assert_eq!(fallback.commit_type, CommitType::Chore); + assert_eq!(fallback.confidence, 0.8); + + let missing_title = parse_commit_analysis_value(&serde_json::json!({ + "type": "fix" + })); + assert_eq!(missing_title.unwrap_err(), "Missing title field"); +} + #[test] fn startchat_options_preserve_existing_defaults() { let options = WorkStateOptions::default(); @@ -238,6 +269,50 @@ fn startchat_action_helpers_preserve_limits_and_defaults() { assert_eq!(quick[5].title, "Custom 2"); } +#[test] +fn startchat_complete_analysis_parser_preserves_defaults_and_limits() { + let parsed = parse_complete_analysis_value(&serde_json::json!({ + "summary": "Working on refactor boundaries.", + "predicted_actions": [ + {"description": "Review changes", "priority": "High", "icon": "search", "is_reminder": true}, + {"description": "Run tests", "priority": "Medium", "icon": "check"}, + {"description": "Open PR", "priority": "Low", "icon": "git-pull-request"}, + {"description": "Extra", "priority": "Low", "icon": "more"} + ], + "quick_actions": [ + {"title": "Continue", "command": "/continue", "action_type": "Continue"}, + {"title": "Status", "command": "/status", "action_type": "ViewStatus"}, + {"title": "Commit", "command": "/commit", "action_type": "Commit"}, + {"title": "Visualize", "command": "/visualize", "action_type": "Visualize"}, + {"title": "Custom 1", "command": "one"}, + {"title": "Custom 2", "command": "two"}, + {"title": "Custom 3", "command": "three"} + ] + })); + + assert_eq!(parsed.predicted_actions_count, 4); + assert_eq!(parsed.quick_actions_count, 7); + assert_eq!(parsed.analysis.summary, "Working on refactor boundaries."); + assert_eq!(parsed.analysis.predicted_actions.len(), 3); + assert_eq!( + parsed.analysis.predicted_actions[0].priority, + ActionPriority::High + ); + assert!(parsed.analysis.predicted_actions[0].is_reminder); + assert_eq!(parsed.analysis.quick_actions.len(), 6); + assert_eq!(parsed.analysis.quick_actions[5].title, "Custom 2"); + + let fallback = parse_complete_analysis_value(&serde_json::json!({})); + assert_eq!(fallback.predicted_actions_count, 0); + assert_eq!(fallback.quick_actions_count, 0); + assert_eq!( + fallback.analysis.summary, + "You were working on development, with multiple files modified." + ); + assert_eq!(fallback.analysis.predicted_actions.len(), 3); + assert!(fallback.analysis.quick_actions.is_empty()); +} + #[test] fn startchat_git_status_helpers_preserve_porcelain_contract() { let (unstaged, staged, files) = parse_git_status_porcelain( diff --git a/src/crates/product-domains/tests/miniapp_contracts.rs b/src/crates/product-domains/tests/miniapp_contracts.rs index b3781d87c..f7a6b76fe 100644 --- a/src/crates/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/product-domains/tests/miniapp_contracts.rs @@ -4,7 +4,8 @@ use bitfun_product_domains::miniapp::bridge_builder::build_csp_content; use bitfun_product_domains::miniapp::compiler::compile; use bitfun_product_domains::miniapp::exporter::{ExportCheckResult, ExportTarget}; use bitfun_product_domains::miniapp::host_routing::{ - command_basename_for_allowlist, is_host_primitive, + command_basename_allowed, command_basename_for_allowlist, host_allowed_by_allowlist, + is_host_primitive, }; use bitfun_product_domains::miniapp::lifecycle::{ build_deps_revision, build_runtime_state, build_source_revision, build_worker_revision, @@ -223,6 +224,28 @@ fn miniapp_host_routing_preserves_existing_primitive_and_allowlist_contract() { assert_eq!(command_basename_for_allowlist("git.exe"), "git"); assert_eq!(command_basename_for_allowlist("/usr/bin/git"), "git"); assert_eq!(command_basename_for_allowlist("CARGO"), "cargo"); + + assert!(command_basename_allowed(&[], "git")); + assert!(command_basename_allowed(&["Git".to_string()], "git")); + assert!(!command_basename_allowed(&["cargo".to_string()], "git")); + + assert!(host_allowed_by_allowlist(&[], "api.example.com")); + assert!(host_allowed_by_allowlist( + &["*".to_string()], + "api.example.com" + )); + assert!(host_allowed_by_allowlist( + &["example.com".to_string()], + "api.example.com" + )); + assert!(host_allowed_by_allowlist( + &["api.example.com".to_string()], + "api.example.com" + )); + assert!(!host_allowed_by_allowlist( + &["example.com".to_string()], + "badexample.com" + )); } #[test]