diff --git a/.blueprint/modules/agent-infrastructure.md b/.blueprint/modules/agent-infrastructure.md index d8d7308..bb4fdcc 100644 --- a/.blueprint/modules/agent-infrastructure.md +++ b/.blueprint/modules/agent-infrastructure.md @@ -86,8 +86,8 @@ > /architect 已完成(2026-06-29),复查自洽后拆为以下 5 张可执行卡。 > 执行顺序:A 和 C 可并行 → B(依赖 A)→ D(依赖 A+B)→ E(依赖 C) -### 📋 A. 框架层扫描 + 契约路径推断 -- status: todo +### ✅ A. 框架层扫描 + 契约路径推断 +- status: done - complexity: M - files: src-tauri/src/commands/agent_infra.rs, src-tauri/src/commands/mod.rs, src-tauri/src/lib.rs, src/lib/commands.ts - acceptance: `scan_agent_infra_stack(project_path, project_id)` 能从 package.json(serde_json 解析 deps)和 Cargo.toml(字符串匹配)识别 Drizzle/Prisma/Hono/Axum 等框架,返回带 confidence 的 DetectedContract 列表;有单元测试覆盖 drizzle/prisma/hono 识别 diff --git a/src-tauri/src/commands/agent_infra.rs b/src-tauri/src/commands/agent_infra.rs new file mode 100644 index 0000000..0094fdf --- /dev/null +++ b/src-tauri/src/commands/agent_infra.rs @@ -0,0 +1,371 @@ +//! Agent 友好基础设施生成(接入包 v2)。 +//! +//! 卡 A:扫描项目框架层技术栈,推断外部契约路径,为后续生成三件套做准备。 +//! 与 project_scan.rs(工具层)互补:本模块专注框架/ORM/API 层识别。 + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +// ── 公共数据结构 ────────────────────────────────────────────────────────────── + +/// 推断出的外部契约条目(由用户确认后传给 generate_agent_infra_files)。 +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DetectedContract { + /// 人读标签,如"数据库 schema(Drizzle)" + pub label: String, + /// 推断的路径列表(可含 glob,如 "src/routes/**") + pub paths: Vec, + /// 变更时的文档同步提示 + pub doc: String, + /// "high":路径文件在磁盘上存在;"medium":仅从依赖推断;"low":弱信号 + pub confidence: String, +} + +/// scan_agent_infra_stack 的完整返回值。 +#[derive(Debug, Serialize, Deserialize)] +pub struct AgentInfraStack { + /// 识别到的契约列表(已去重,按 label 排序) + pub detected_contracts: Vec, + /// AGENTS.md 是否已含"## 文档防腐元规则"段落 + pub meta_rules_present: bool, + /// docs/ai-context/project-context.yaml 是否已存在 + pub context_yaml_exists: bool, +} + +// ── package.json 解析 ───────────────────────────────────────────────────────── + +/// 从 package.json 提取 dependencies + devDependencies 的所有 key。 +fn pkg_json_dep_keys(root: &Path) -> Vec { + let content = match std::fs::read_to_string(root.join("package.json")) { + Ok(c) => c, + Err(_) => return vec![], + }; + let v: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return vec![], + }; + let mut keys = Vec::new(); + for section in &["dependencies", "devDependencies"] { + if let Some(obj) = v.get(section).and_then(|d| d.as_object()) { + keys.extend(obj.keys().cloned()); + } + } + keys +} + +/// 从 Cargo.toml 内容字符串中粗略判断某 crate 是否在依赖中。 +fn cargo_has(content: &str, crate_name: &str) -> bool { + // 匹配 `crate_name = ...` 或 `crate_name = { ... }` 形式 + content.contains(&format!("\n{crate_name} ")) + || content.contains(&format!("\n{crate_name}=")) + || content.contains(&format!("\"{crate_name}\"")) +} + +// ── 框架识别核心 ────────────────────────────────────────────────────────────── + +fn path_exists(root: &Path, rel: &str) -> bool { + root.join(rel).exists() +} + +fn confidence(root: &Path, path: &str) -> String { + if path.contains('*') { + "medium".into() + } else if path_exists(root, path) { + "high".into() + } else { + "medium".into() + } +} + +fn detect_contracts(root: &Path) -> Vec { + let mut contracts: Vec = Vec::new(); + let pkg_deps = pkg_json_dep_keys(root); + let cargo_content = std::fs::read_to_string(root.join("Cargo.toml")).unwrap_or_default(); + + // ── Node.js 框架层 ──────────────────────────────────────────────────────── + + // Drizzle ORM + if pkg_deps.iter().any(|k| k == "drizzle-orm") { + let p = if path_exists(root, "drizzle/schema.ts") { + "drizzle/schema.ts" + } else if path_exists(root, "src/db/schema.ts") { + "src/db/schema.ts" + } else { + "drizzle/schema.ts" + }; + contracts.push(DetectedContract { + label: "数据库 schema(Drizzle)".into(), + paths: vec![p.into()], + doc: "schema 变更需同步更新数据库文档,检查 project-context.yaml".into(), + confidence: confidence(root, p), + }); + } + + // Prisma + if pkg_deps.iter().any(|k| k == "prisma" || k == "@prisma/client") { + let p = "prisma/schema.prisma"; + contracts.push(DetectedContract { + label: "数据库 schema(Prisma)".into(), + paths: vec![p.into()], + doc: "schema 变更需同步更新数据库文档,检查 project-context.yaml".into(), + confidence: confidence(root, p), + }); + } + + // Hono + if pkg_deps.iter().any(|k| k == "hono") { + let p = if path_exists(root, "src/routes") { + "src/routes/**" + } else { + "src/index.ts" + }; + contracts.push(DetectedContract { + label: "API 接口(Hono)".into(), + paths: vec![p.into()], + doc: "接口路由变更需同步更新 API 文档或 openapi.yaml".into(), + confidence: "medium".into(), + }); + } + + // Express + if pkg_deps.iter().any(|k| k == "express") { + contracts.push(DetectedContract { + label: "API 接口(Express)".into(), + paths: vec!["src/routes/**".into()], + doc: "接口路由变更需同步更新 API 文档".into(), + confidence: "medium".into(), + }); + } + + // Fastify + if pkg_deps.iter().any(|k| k == "fastify") { + contracts.push(DetectedContract { + label: "API 接口(Fastify)".into(), + paths: vec!["src/routes/**".into()], + doc: "接口路由变更需同步更新 API 文档".into(), + confidence: "medium".into(), + }); + } + + // tRPC + if pkg_deps.iter().any(|k| k == "@trpc/server") { + let p = if path_exists(root, "src/router") { + "src/router/**" + } else { + "src/trpc/**" + }; + contracts.push(DetectedContract { + label: "API 接口(tRPC)".into(), + paths: vec![p.into()], + doc: "router 变更需同步 client 类型,检查 project-context.yaml".into(), + confidence: "medium".into(), + }); + } + + // pnpm monorepo 共享类型 + if path_exists(root, "pnpm-workspace.yaml") { + let p = if path_exists(root, "packages/types") { + "packages/types/**" + } else if path_exists(root, "packages/shared") { + "packages/shared/**" + } else { + "packages/types/**" + }; + contracts.push(DetectedContract { + label: "共享类型(pnpm monorepo)".into(), + paths: vec![p.into()], + doc: "共享类型只在此处定义,禁止在其他包内手抄".into(), + confidence: "medium".into(), + }); + } + + // .env.example → 环境变量声明 + if path_exists(root, ".env.example") { + contracts.push(DetectedContract { + label: "环境变量声明".into(), + paths: vec![".env.example".into()], + doc: "新增环境变量必须在 .env.example 中同步声明".into(), + confidence: "high".into(), + }); + } + + // ── Rust 框架层 ─────────────────────────────────────────────────────────── + + if !cargo_content.is_empty() { + // Axum + if cargo_has(&cargo_content, "axum") { + let p = if path_exists(root, "src/routes.rs") { + "src/routes.rs" + } else if path_exists(root, "src/routes") { + "src/routes/**" + } else { + "src/routes/**" + }; + contracts.push(DetectedContract { + label: "API 接口(Axum)".into(), + paths: vec![p.into()], + doc: "路由变更需同步 API 文档".into(), + confidence: if p.contains('*') { "medium".into() } else { "high".into() }, + }); + } + + // SeaORM + if cargo_has(&cargo_content, "sea-orm") { + let p = "src/entities"; + contracts.push(DetectedContract { + label: "数据库 schema(SeaORM)".into(), + paths: vec![format!("{p}/**")], + doc: "entity 变更需同步数据库文档".into(), + confidence: if path_exists(root, p) { "high".into() } else { "medium".into() }, + }); + } + + // Tauri(自身的 command 层) + if cargo_has(&cargo_content, "tauri") && path_exists(root, "src-tauri/src/commands") { + contracts.push(DetectedContract { + label: "Tauri command 接口(前后端契约)".into(), + paths: vec![ + "src-tauri/src/commands/**/*.rs".into(), + "src/lib/commands.ts".into(), + ], + doc: "新增 command 时前后端必须同步".into(), + confidence: "high".into(), + }); + } + } + + contracts +} + +// ── Tauri Command ───────────────────────────────────────────────────────────── + +/// 扫描项目框架层技术栈,推断外部契约路径。 +/// project_path 为 Windows 本地项目根目录(绝对路径)。 +#[tauri::command] +pub fn scan_agent_infra_stack( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim_end_matches(['/', '\\'])); + if !root.exists() { + return Err(format!("目录不存在: {}", root.display())); + } + + let detected_contracts = detect_contracts(root); + + let meta_rules_present = std::fs::read_to_string(root.join("AGENTS.md")) + .map(|c| c.contains("## 文档防腐元规则")) + .unwrap_or(false); + + let context_yaml_exists = root.join("docs/ai-context/project-context.yaml").exists(); + + Ok(AgentInfraStack { + detected_contracts, + meta_rules_present, + context_yaml_exists, + }) +} + +// ── 单元测试 ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup(dir: &std::path::Path) { + fs::create_dir_all(dir).unwrap(); + } + + fn write(dir: &std::path::Path, rel: &str, content: &str) { + let p = dir.join(rel); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(p, content).unwrap(); + } + + #[test] + fn detects_drizzle() { + let dir = std::env::temp_dir().join(format!("ai_infra_test_drizzle_{}", std::process::id())); + setup(&dir); + write(&dir, "package.json", r#"{"dependencies":{"drizzle-orm":"^0.30.0","hono":"^4.0.0"}}"#); + write(&dir, "drizzle/schema.ts", "export const users = {};"); + + let contracts = detect_contracts(&dir); + let labels: Vec<&str> = contracts.iter().map(|c| c.label.as_str()).collect(); + assert!(labels.contains(&"数据库 schema(Drizzle)"), "应识别 Drizzle"); + assert!(labels.contains(&"API 接口(Hono)"), "应识别 Hono"); + + let drizzle = contracts.iter().find(|c| c.label.contains("Drizzle")).unwrap(); + assert_eq!(drizzle.confidence, "high", "schema.ts 存在应为 high"); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn detects_prisma() { + let dir = std::env::temp_dir().join(format!("ai_infra_test_prisma_{}", std::process::id())); + setup(&dir); + write(&dir, "package.json", r#"{"devDependencies":{"prisma":"^5.0.0"},"dependencies":{"@prisma/client":"^5.0.0"}}"#); + + let contracts = detect_contracts(&dir); + let labels: Vec<&str> = contracts.iter().map(|c| c.label.as_str()).collect(); + assert!(labels.contains(&"数据库 schema(Prisma)"), "应识别 Prisma"); + + let prisma = contracts.iter().find(|c| c.label.contains("Prisma")).unwrap(); + // schema.prisma 不存在,confidence 应为 medium + assert_eq!(prisma.confidence, "medium"); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn detects_axum_from_cargo() { + let dir = std::env::temp_dir().join(format!("ai_infra_test_axum_{}", std::process::id())); + setup(&dir); + write(&dir, "Cargo.toml", "[dependencies]\naxum = \"0.7\"\ntokio = { version = \"1\" }\n"); + + let contracts = detect_contracts(&dir); + let labels: Vec<&str> = contracts.iter().map(|c| c.label.as_str()).collect(); + assert!(labels.contains(&"API 接口(Axum)"), "应识别 Axum"); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn detects_tauri_contract() { + let dir = std::env::temp_dir().join(format!("ai_infra_test_tauri_{}", std::process::id())); + setup(&dir); + write(&dir, "Cargo.toml", "[dependencies]\ntauri = \"2\"\n"); + fs::create_dir_all(dir.join("src-tauri/src/commands")).unwrap(); + + let contracts = detect_contracts(&dir); + let found = contracts.iter().any(|c| c.label.contains("Tauri command")); + assert!(found, "应识别 Tauri command 契约"); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn empty_project_returns_empty() { + let dir = std::env::temp_dir().join(format!("ai_infra_test_empty_{}", std::process::id())); + setup(&dir); + let contracts = detect_contracts(&dir); + assert!(contracts.is_empty(), "无框架文件应返回空列表"); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn meta_rules_detection() { + let dir = std::env::temp_dir().join(format!("ai_infra_test_meta_{}", std::process::id())); + setup(&dir); + write(&dir, "AGENTS.md", "# Title\n## 文档防腐元规则\nsome content"); + write(&dir, "package.json", "{}"); + + let result = scan_agent_infra_stack(dir.to_str().unwrap().to_string()).unwrap(); + assert!(result.meta_rules_present); + assert!(!result.context_yaml_exists); + + fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2081817..4a50b57 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -34,3 +34,4 @@ pub mod source_library; pub mod cloud_products; pub mod cloud_db_databases; pub mod docker_apps; +pub mod agent_infra; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ab08c3..31167c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -267,6 +267,8 @@ pub fn run() { list_buffed_projects, // onboarding pack commands::onboarding::apply_onboarding_pack, + // agent infrastructure + commands::agent_infra::scan_agent_infra_stack, commands::commit_metrics::get_commit_stats, // mentor get_mentor_context, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 8ea25f5..86a4e9c 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1615,6 +1615,24 @@ export interface OnboardingReport { export const applyOnboardingPack = (projectPath: string, projectId: string) => invoke("apply_onboarding_pack", { projectPath, projectId }); +// ── Agent Infrastructure ────────────────────────────────────────────────────── + +export interface DetectedContract { + label: string; + paths: string[]; + doc: string; + confidence: 'high' | 'medium' | 'low'; +} + +export interface AgentInfraStack { + detected_contracts: DetectedContract[]; + meta_rules_present: boolean; + context_yaml_exists: boolean; +} + +export const scanAgentInfraStack = (projectPath: string) => + invoke('scan_agent_infra_stack', { projectPath }); + export interface CommitStats { total: number; conventional: number;