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 张可执行卡。
|
||||
> 执行顺序: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 识别
|
||||
|
||||
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_db_databases;
|
||||
pub mod docker_apps;
|
||||
pub mod agent_infra;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1615,6 +1615,24 @@ export interface OnboardingReport {
|
||||
export const applyOnboardingPack = (projectPath: string, projectId: string) =>
|
||||
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 {
|
||||
total: number;
|
||||
conventional: number;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user