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:
lanrtop 2026-06-29 18:07:40 +09:00
parent 773f78a649
commit 2246df8e2a
5 changed files with 394 additions and 2 deletions

View File

@ -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.jsonserde_json 解析 deps和 Cargo.toml字符串匹配识别 Drizzle/Prisma/Hono/Axum 等框架,返回带 confidence 的 DetectedContract 列表;有单元测试覆盖 drizzle/prisma/hono 识别

View 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 {
/// 人读标签,如"数据库 schemaDrizzle"
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: "数据库 schemaDrizzle".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: "数据库 schemaPrisma".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: "数据库 schemaSeaORM".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(&"数据库 schemaDrizzle"), "应识别 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(&"数据库 schemaPrisma"), "应识别 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();
}
}

View File

@ -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;

View File

@ -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,

View File

@ -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;