feat(agent-infra): 卡A——框架层扫描 + 契约路径推断
新增 agent_infra.rs:从 package.json(serde_json)和 Cargo.toml 识别 Drizzle/Prisma/Hono/Axum/tRPC/pnpm-monorepo 等框架, 返回带 confidence 的 DetectedContract 列表;6 条单元测试全过。 前端封装:scanAgentInfraStack(projectPath)
This commit is contained in:
parent
773f78a649
commit
2246df8e2a
@ -86,8 +86,8 @@
|
|||||||
> /architect 已完成(2026-06-29),复查自洽后拆为以下 5 张可执行卡。
|
> /architect 已完成(2026-06-29),复查自洽后拆为以下 5 张可执行卡。
|
||||||
> 执行顺序:A 和 C 可并行 → B(依赖 A)→ D(依赖 A+B)→ E(依赖 C)
|
> 执行顺序:A 和 C 可并行 → B(依赖 A)→ D(依赖 A+B)→ E(依赖 C)
|
||||||
|
|
||||||
### 📋 A. 框架层扫描 + 契约路径推断
|
### ✅ A. 框架层扫描 + 契约路径推断
|
||||||
- status: todo
|
- status: done
|
||||||
- complexity: M
|
- 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
|
- 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 识别
|
- 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 识别
|
||||||
|
|||||||
371
src-tauri/src/commands/agent_infra.rs
Normal file
371
src-tauri/src/commands/agent_infra.rs
Normal file
@ -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<String>,
|
||||||
|
/// 变更时的文档同步提示
|
||||||
|
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<DetectedContract>,
|
||||||
|
/// 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<String> {
|
||||||
|
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<DetectedContract> {
|
||||||
|
let mut contracts: Vec<DetectedContract> = 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<AgentInfraStack, String> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,3 +34,4 @@ pub mod source_library;
|
|||||||
pub mod cloud_products;
|
pub mod cloud_products;
|
||||||
pub mod cloud_db_databases;
|
pub mod cloud_db_databases;
|
||||||
pub mod docker_apps;
|
pub mod docker_apps;
|
||||||
|
pub mod agent_infra;
|
||||||
|
|||||||
@ -267,6 +267,8 @@ pub fn run() {
|
|||||||
list_buffed_projects,
|
list_buffed_projects,
|
||||||
// onboarding pack
|
// onboarding pack
|
||||||
commands::onboarding::apply_onboarding_pack,
|
commands::onboarding::apply_onboarding_pack,
|
||||||
|
// agent infrastructure
|
||||||
|
commands::agent_infra::scan_agent_infra_stack,
|
||||||
commands::commit_metrics::get_commit_stats,
|
commands::commit_metrics::get_commit_stats,
|
||||||
// mentor
|
// mentor
|
||||||
get_mentor_context,
|
get_mentor_context,
|
||||||
|
|||||||
@ -1615,6 +1615,24 @@ export interface OnboardingReport {
|
|||||||
export const applyOnboardingPack = (projectPath: string, projectId: string) =>
|
export const applyOnboardingPack = (projectPath: string, projectId: string) =>
|
||||||
invoke<OnboardingReport>("apply_onboarding_pack", { projectPath, projectId });
|
invoke<OnboardingReport>("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<AgentInfraStack>('scan_agent_infra_stack', { projectPath });
|
||||||
|
|
||||||
export interface CommitStats {
|
export interface CommitStats {
|
||||||
total: number;
|
total: number;
|
||||||
conventional: number;
|
conventional: number;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user