用户按指引"重新生成三件套"两轮无法清除红色诊断:生成只改工作区文件, 而评分比较的是 git 提交时间戳。修复指引现明确要求 commit。 enterprise-system 实测:commit project-context.yaml 后诊断转绿。 Rules-Applied: R11
1136 lines
42 KiB
Rust
1136 lines
42 KiB
Rust
//! 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_from_file(path: &Path) -> Vec<String> {
|
||
let content = match std::fs::read_to_string(path) {
|
||
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
|
||
}
|
||
|
||
/// 收集根目录 + monorepo 子包(apps/*、packages/*)的所有依赖 key。
|
||
fn collect_all_pkg_deps(root: &Path) -> Vec<String> {
|
||
let mut keys = pkg_json_dep_keys_from_file(&root.join("package.json"));
|
||
if root.join("pnpm-workspace.yaml").exists() {
|
||
for dir in &["apps", "packages"] {
|
||
if let Ok(entries) = std::fs::read_dir(root.join(dir)) {
|
||
for entry in entries.flatten() {
|
||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||
let pkg = entry.path().join("package.json");
|
||
if pkg.exists() {
|
||
keys.extend(pkg_json_dep_keys_from_file(&pkg));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
keys.sort();
|
||
keys.dedup();
|
||
}
|
||
keys
|
||
}
|
||
|
||
/// monorepo 中在 apps/* 子包里找第一个含 sub_path 的目录,返回相对根的路径。
|
||
fn find_in_apps(root: &Path, sub_path: &str) -> Option<String> {
|
||
let apps_dir = root.join("apps");
|
||
if !apps_dir.is_dir() {
|
||
return None;
|
||
}
|
||
let mut names: Vec<_> = std::fs::read_dir(&apps_dir)
|
||
.ok()?
|
||
.flatten()
|
||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||
.collect();
|
||
names.sort();
|
||
for name in names {
|
||
let rel = format!("apps/{name}/{sub_path}");
|
||
if root.join(&rel).exists() {
|
||
return Some(rel);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// 从 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 = collect_all_pkg_deps(root);
|
||
// 纯 Rust 项目:Cargo.toml 在根;Tauri 项目:Cargo.toml 在 src-tauri/
|
||
let cargo_content = std::fs::read_to_string(root.join("Cargo.toml"))
|
||
.or_else(|_| std::fs::read_to_string(root.join("src-tauri/Cargo.toml")))
|
||
.unwrap_or_default();
|
||
// Tauri 项目:commands 目录在 src-tauri/src/commands/
|
||
let tauri_commands_path = if path_exists(root, "src-tauri/src/commands") {
|
||
"src-tauri/src/commands"
|
||
} else {
|
||
"src/commands"
|
||
};
|
||
|
||
// ── Node.js 框架层 ────────────────────────────────────────────────────────
|
||
|
||
// Drizzle ORM
|
||
if pkg_deps.iter().any(|k| k == "drizzle-orm") {
|
||
// monorepo:优先在 apps/* 子包里找 drizzle 目录
|
||
let p: String = find_in_apps(root, "src/drizzle")
|
||
.map(|d| format!("{d}/**"))
|
||
.or_else(|| find_in_apps(root, "drizzle").map(|d| format!("{d}/**")))
|
||
.unwrap_or_else(|| {
|
||
if path_exists(root, "drizzle/schema.ts") {
|
||
"drizzle/schema.ts".into()
|
||
} else if path_exists(root, "src/db/schema.ts") {
|
||
"src/db/schema.ts".into()
|
||
} else {
|
||
"drizzle/schema.ts".into()
|
||
}
|
||
});
|
||
contracts.push(DetectedContract {
|
||
label: "数据库 schema(Drizzle)".into(),
|
||
paths: vec![p.clone()],
|
||
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") {
|
||
// monorepo:优先在 apps/* 子包里找 routes 目录
|
||
let p: String = find_in_apps(root, "src/routes")
|
||
.map(|d| format!("{d}/**"))
|
||
.unwrap_or_else(|| {
|
||
if path_exists(root, "src/routes") {
|
||
"src/routes/**".into()
|
||
} else {
|
||
"src/index.ts".into()
|
||
}
|
||
});
|
||
contracts.push(DetectedContract {
|
||
label: "API 接口(Hono)".into(),
|
||
paths: vec![p],
|
||
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") {
|
||
// 优先已知命名约定;否则列出 packages/ 下实际存在的子包目录
|
||
let paths: Vec<String> = if path_exists(root, "packages/types") {
|
||
vec!["packages/types/**".into()]
|
||
} else if path_exists(root, "packages/shared") {
|
||
vec!["packages/shared/**".into()]
|
||
} else if let Ok(entries) = std::fs::read_dir(root.join("packages")) {
|
||
let mut dirs: Vec<String> = entries
|
||
.flatten()
|
||
.filter(|e| {
|
||
e.file_type().map(|t| t.is_dir()).unwrap_or(false)
|
||
&& e.path().join("package.json").exists()
|
||
})
|
||
.map(|e| format!("packages/{}/**", e.file_name().to_string_lossy()))
|
||
.collect();
|
||
dirs.sort();
|
||
dirs.truncate(3);
|
||
if dirs.is_empty() { vec!["packages/**".into()] } else { dirs }
|
||
} else {
|
||
vec!["packages/**".into()]
|
||
};
|
||
contracts.push(DetectedContract {
|
||
label: "共享包(pnpm monorepo)".into(),
|
||
paths,
|
||
doc: "共享包变更影响所有 apps,禁止在各 app 内手抄类型".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") {
|
||
// Tauri 项目:Axum 通常用于嵌入式 MCP/HTTP 服务器,路由在 src-tauri/src/
|
||
let p = if path_exists(root, "src-tauri/src/mcp_server.rs") {
|
||
"src-tauri/src/mcp_server.rs"
|
||
} else 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 层)
|
||
let tauri_frontend_ts = if path_exists(root, "src/lib/commands.ts") {
|
||
"src/lib/commands.ts"
|
||
} else {
|
||
"src/commands.ts"
|
||
};
|
||
if cargo_has(&cargo_content, "tauri") && path_exists(root, tauri_commands_path) {
|
||
contracts.push(DetectedContract {
|
||
label: "Tauri command 接口(前后端契约)".into(),
|
||
paths: vec![
|
||
format!("{tauri_commands_path}/**/*.rs"),
|
||
tauri_frontend_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,
|
||
})
|
||
}
|
||
|
||
// ── 三件套生成 ────────────────────────────────────────────────────────────────
|
||
|
||
/// 用户确认后的契约条目(前端传入)。
|
||
#[derive(Debug, Deserialize, Clone)]
|
||
pub struct ContractInput {
|
||
pub label: String,
|
||
pub paths: Vec<String>,
|
||
pub doc: String,
|
||
}
|
||
|
||
/// 单个文件的生成结果。
|
||
#[derive(Debug, Serialize)]
|
||
pub struct FileResult {
|
||
/// 相对项目根的路径
|
||
pub path: String,
|
||
/// "written" | "updated" | "skipped" | "failed"
|
||
pub action: String,
|
||
pub detail: String,
|
||
}
|
||
|
||
/// generate_agent_infra_files 的完整返回值。
|
||
#[derive(Debug, Serialize)]
|
||
pub struct GenerateReport {
|
||
pub files: Vec<FileResult>,
|
||
}
|
||
|
||
// 元规则段落内容(技术栈无关,编译期内嵌)
|
||
const META_RULES_SECTION: &str = r#"
|
||
---
|
||
|
||
## Agent 友好工程四支柱
|
||
|
||
修改代码前,确认这个项目满足四支柱:
|
||
|
||
**可导航**:凭文件名/目录名可在 30 秒内定位某功能的代码。通用能力有独立家,不寄生在业务模块下。
|
||
|
||
**可推理**:公共类型只在一处定义。改一个接口,类型检查能报出所有受影响处。
|
||
|
||
**可验证**:typecheck / lint / test 各有一条命令真能跑。关键路径有冒烟测试。
|
||
|
||
**可记忆**:规则按场景注入(不是一份巨型文档)。决策只写"为什么",代码自己回答"是什么"。
|
||
|
||
---
|
||
|
||
## 文档防腐元规则
|
||
|
||
做任何改动时,判断它属于哪类变更:
|
||
|
||
### T1 外部契约变更(最高优先级)
|
||
其他代码、服务、或 agent 依赖的定义——接口、共享类型、数据结构、配置键名、环境变量名。
|
||
→ 查 `docs/ai-context/project-context.yaml` 找到记录该契约的文档,检查并同步更新。
|
||
|
||
### T2 架构边界变更
|
||
模块划分、目录结构、依赖方向、包边界调整。
|
||
→ 检查架构文档是否需要同步。
|
||
|
||
### T3 工具链变更
|
||
引入或替换库、框架、构建工具。
|
||
→ 更新 AGENTS.md 技术栈声明;通知炼境重新扫描并更新 `project-context.yaml`。
|
||
|
||
### T4 常驻上下文过期
|
||
AGENTS.md 或 `docs/ai-context/project-brief.md` 描述的现实已经改变。
|
||
→ 立即更新。这两份文档常驻 agent 上下文,过期危害最大。
|
||
|
||
### 不触发检查
|
||
内部重构、性能优化、bug fix(外部行为不变)、测试文件、临时脚本。
|
||
|
||
---
|
||
|
||
## 文档防腐铁律
|
||
|
||
- 文档只写稳定的:为什么、铁律、意图、架构决策。
|
||
- 易变的(端口、路由、表名、命令清单)交给工具自省,不写死进常驻文档。写死 = 定时炸弹。
|
||
- 单一事实来源:两处说同一事时,删掉派生的那处。
|
||
- 两份文档打架时,信代码,不信文档。
|
||
|
||
---
|
||
|
||
## 定期防腐自查
|
||
|
||
每隔一阵或大改后,对照以下清单:
|
||
|
||
- [ ] AGENTS.md 里的技术栈声明还和代码一致吗?
|
||
- [ ] `project-brief.md` 最近 30 天有没有随架构改动更新?
|
||
- [ ] 有没有两份文档说同一件事且打架?
|
||
- [ ] `project-context.yaml` 的契约路径还准确吗?
|
||
"#;
|
||
|
||
fn write_file(path: &std::path::PathBuf, content: &str, rel: &str) -> FileResult {
|
||
if let Some(parent) = path.parent() {
|
||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||
return FileResult {
|
||
path: rel.into(),
|
||
action: "failed".into(),
|
||
detail: format!("建目录失败: {e}"),
|
||
};
|
||
}
|
||
}
|
||
match std::fs::write(path, content) {
|
||
Ok(_) => FileResult { path: rel.into(), action: "written".into(), detail: "已写入".into() },
|
||
Err(e) => FileResult { path: rel.into(), action: "failed".into(), detail: format!("写入失败: {e}") },
|
||
}
|
||
}
|
||
|
||
/// 生成 docs/ai-context/project-context.yaml
|
||
fn gen_context_yaml(root: &Path, contracts: &[ContractInput]) -> FileResult {
|
||
let rel = "docs/ai-context/project-context.yaml";
|
||
let target = root.join(rel);
|
||
let existed = target.exists();
|
||
|
||
let mut yaml = String::from(
|
||
"# 项目契约路径映射\n\
|
||
# 由炼境根据技术栈扫描生成,技术栈变更后重新运行接入包生成\n\
|
||
# 供 AGENTS.md 元规则 T1 查询:「哪个文件是这个项目的外部契约?」\n\n\
|
||
contracts:\n",
|
||
);
|
||
for c in contracts {
|
||
yaml.push_str(&format!(" - label: {}\n paths:\n", c.label));
|
||
for p in &c.paths {
|
||
yaml.push_str(&format!(" - \"{p}\"\n"));
|
||
}
|
||
yaml.push_str(&format!(" doc: \"{}\"\n\n", c.doc.replace('"', "'")));
|
||
}
|
||
yaml.push_str(
|
||
"architecture_docs:\n - \"AGENTS.md\"\n\n\
|
||
agent_docs:\n - \"AGENTS.md\"\n - \"docs/ai-context/project-brief.md\"\n",
|
||
);
|
||
|
||
let mut result = write_file(&target, &yaml, rel);
|
||
if result.action == "written" && existed {
|
||
result.action = "updated".into();
|
||
result.detail = "已更新(覆盖上次生成版本)".into();
|
||
}
|
||
result
|
||
}
|
||
|
||
/// 向 AGENTS.md 注入元规则段落(已有则跳过,幂等)
|
||
fn inject_meta_rules(root: &Path) -> FileResult {
|
||
let rel = "AGENTS.md";
|
||
let target = root.join(rel);
|
||
|
||
match std::fs::read_to_string(&target) {
|
||
Ok(content) if content.contains("## 文档防腐元规则") => FileResult {
|
||
path: rel.into(),
|
||
action: "skipped".into(),
|
||
detail: "元规则段落已存在,未修改".into(),
|
||
},
|
||
Ok(mut content) => {
|
||
content.push_str(META_RULES_SECTION);
|
||
match std::fs::write(&target, content) {
|
||
Ok(_) => FileResult {
|
||
path: rel.into(),
|
||
action: "updated".into(),
|
||
detail: "已追加元规则段落".into(),
|
||
},
|
||
Err(e) => FileResult {
|
||
path: rel.into(),
|
||
action: "failed".into(),
|
||
detail: format!("写入失败: {e}"),
|
||
},
|
||
}
|
||
}
|
||
Err(_) => FileResult {
|
||
path: rel.into(),
|
||
action: "skipped".into(),
|
||
detail: "AGENTS.md 不存在,请先应用接入包(v1)".into(),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 生成 .claude/rules/doc-freshness.md(含项目特定 globs)
|
||
fn gen_doc_freshness_rule(root: &Path, contracts: &[ContractInput]) -> FileResult {
|
||
let rel = ".claude/rules/doc-freshness.md";
|
||
let target = root.join(rel);
|
||
|
||
// 收集所有非 glob 的具体文件路径作为 globs frontmatter
|
||
let globs: Vec<String> = contracts
|
||
.iter()
|
||
.flat_map(|c| c.paths.iter().cloned())
|
||
.collect();
|
||
let globs_str = globs.join(", ");
|
||
|
||
let content = format!(
|
||
"---\nglobs: {globs_str}\n---\n\n\
|
||
这些是本项目的外部契约文件。修改时检查 `docs/ai-context/project-context.yaml` 是否需要同步更新。\n\n\
|
||
参考 AGENTS.md 元规则 **T1 外部契约变更**。\n"
|
||
);
|
||
|
||
write_file(&target, &content, rel)
|
||
}
|
||
|
||
/// 根据用户确认的契约列表生成三件套文件。
|
||
#[tauri::command]
|
||
pub fn generate_agent_infra_files(
|
||
project_path: String,
|
||
contracts: Vec<ContractInput>,
|
||
) -> Result<GenerateReport, String> {
|
||
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
|
||
if !root.exists() {
|
||
return Err(format!("目录不存在: {}", root.display()));
|
||
}
|
||
|
||
let files = vec![
|
||
gen_context_yaml(root, &contracts),
|
||
inject_meta_rules(root),
|
||
gen_doc_freshness_rule(root, &contracts),
|
||
];
|
||
|
||
Ok(GenerateReport { files })
|
||
}
|
||
|
||
// ── 文档腐化评分 ──────────────────────────────────────────────────────────────
|
||
|
||
/// 单条契约的腐化评估。
|
||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||
pub struct FreshnessDetail {
|
||
/// 契约路径(来自 project-context.yaml)
|
||
pub contract_path: String,
|
||
/// 契约文件最后一次 commit 的 Unix 时间戳;None = 从未提交
|
||
pub contract_ts: Option<i64>,
|
||
/// project-context.yaml 最后一次 commit 的 Unix 时间戳
|
||
pub yaml_ts: Option<i64>,
|
||
/// 契约比 yaml 新了多少天(负数 = yaml 更新)
|
||
pub gap_days: Option<i64>,
|
||
/// "green" | "yellow" | "red"
|
||
pub level: String,
|
||
}
|
||
|
||
/// scan_doc_freshness 的完整返回值。
|
||
#[derive(Debug, Serialize)]
|
||
pub struct FreshnessResult {
|
||
/// 综合评分(所有 detail 中最差的)
|
||
pub level: String,
|
||
pub details: Vec<FreshnessDetail>,
|
||
/// ISO 8601 检查时间
|
||
pub checked_at: String,
|
||
}
|
||
|
||
/// 对 git log 路径做简化:glob 路径取目录部分供 git log 使用。
|
||
fn git_query_path(path: &str) -> &str {
|
||
if let Some(pos) = path.find("/**") {
|
||
&path[..pos]
|
||
} else if path.ends_with("**") {
|
||
&path[..path.len() - 2]
|
||
} else {
|
||
path
|
||
}
|
||
}
|
||
|
||
/// 获取某文件(或目录)在本地 git 仓库的最后 commit 时间戳(Unix 秒)。
|
||
/// 返回 None 表示路径从未被提交。
|
||
fn git_last_commit_ts(project_root: &Path, rel_path: &str) -> Option<i64> {
|
||
let query = git_query_path(rel_path);
|
||
let output = crate::silent_cmd("git")
|
||
.args(["log", "--format=%at", "-1", "--", query])
|
||
.current_dir(project_root)
|
||
.output()
|
||
.ok()?;
|
||
if !output.status.success() {
|
||
return None;
|
||
}
|
||
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
s.parse::<i64>().ok()
|
||
}
|
||
|
||
/// 从生成的 project-context.yaml 中提取 contracts.*.paths 列表。
|
||
fn extract_yaml_contract_paths(content: &str) -> Vec<String> {
|
||
let mut paths = Vec::new();
|
||
let mut in_paths_block = false;
|
||
|
||
for line in content.lines() {
|
||
// " paths:" 在 contracts 条目内(4 空格缩进)
|
||
if line.starts_with(" paths:") {
|
||
in_paths_block = true;
|
||
continue;
|
||
}
|
||
if in_paths_block {
|
||
// 路径条目:6 空格 + "- "
|
||
if line.starts_with(" - ") {
|
||
let raw = line
|
||
.trim_start_matches(|c: char| c.is_whitespace())
|
||
.trim_start_matches("- ")
|
||
.trim_matches('"');
|
||
if !raw.is_empty() {
|
||
paths.push(raw.to_string());
|
||
}
|
||
} else {
|
||
in_paths_block = false;
|
||
}
|
||
}
|
||
}
|
||
paths
|
||
}
|
||
|
||
fn level_from_gap(gap_days: Option<i64>) -> &'static str {
|
||
match gap_days {
|
||
Some(d) if d > 30 => "red",
|
||
Some(d) if d > 14 => "yellow",
|
||
_ => "green",
|
||
}
|
||
}
|
||
|
||
fn worst_level(a: &str, b: &str) -> String {
|
||
match (a, b) {
|
||
("red", _) | (_, "red") => "red".into(),
|
||
("yellow", _) | (_, "yellow") => "yellow".into(),
|
||
_ => "green".into(),
|
||
}
|
||
}
|
||
|
||
/// 扫描文档腐化风险,结果写入 doc_freshness_cache。
|
||
#[tauri::command]
|
||
pub fn scan_doc_freshness(
|
||
project_path: String,
|
||
project_id: String,
|
||
) -> Result<FreshnessResult, String> {
|
||
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
|
||
if !root.exists() {
|
||
return Err(format!("目录不存在: {}", root.display()));
|
||
}
|
||
|
||
let yaml_rel = "docs/ai-context/project-context.yaml";
|
||
let yaml_path = root.join(yaml_rel);
|
||
|
||
// project-context.yaml 不存在 → 直接 RED
|
||
if !yaml_path.exists() {
|
||
let result = FreshnessResult {
|
||
level: "red".into(),
|
||
details: vec![FreshnessDetail {
|
||
contract_path: yaml_rel.into(),
|
||
contract_ts: None,
|
||
yaml_ts: None,
|
||
gap_days: None,
|
||
level: "red".into(),
|
||
}],
|
||
checked_at: chrono::Local::now().to_rfc3339(),
|
||
};
|
||
cache_result(&project_id, &result);
|
||
return Ok(result);
|
||
}
|
||
|
||
let yaml_content = std::fs::read_to_string(&yaml_path).map_err(|e| e.to_string())?;
|
||
let contract_paths = extract_yaml_contract_paths(&yaml_content);
|
||
let yaml_ts = git_last_commit_ts(root, yaml_rel);
|
||
|
||
let mut details: Vec<FreshnessDetail> = Vec::new();
|
||
let mut overall = String::from("green");
|
||
|
||
if contract_paths.is_empty() {
|
||
// yaml 存在但无契约 → green(基础设施已就绪,只是项目没有外部契约)
|
||
details.push(FreshnessDetail {
|
||
contract_path: "(无契约路径)".into(),
|
||
contract_ts: None,
|
||
yaml_ts,
|
||
gap_days: None,
|
||
level: "green".into(),
|
||
});
|
||
} else {
|
||
for cp in &contract_paths {
|
||
let contract_ts = git_last_commit_ts(root, cp);
|
||
let gap_days = match (contract_ts, yaml_ts) {
|
||
(Some(ct), Some(yt)) => {
|
||
let diff = ct - yt;
|
||
Some(diff / 86400) // 秒转天
|
||
}
|
||
(Some(_), None) => Some(999), // 有契约提交但 yaml 从未提交 → 高风险
|
||
_ => None,
|
||
};
|
||
let level = level_from_gap(gap_days).to_string();
|
||
overall = worst_level(&overall, &level);
|
||
details.push(FreshnessDetail {
|
||
contract_path: cp.clone(),
|
||
contract_ts,
|
||
yaml_ts,
|
||
gap_days,
|
||
level,
|
||
});
|
||
}
|
||
}
|
||
|
||
let checked_at = chrono::Local::now().to_rfc3339();
|
||
let result = FreshnessResult {
|
||
level: overall,
|
||
details,
|
||
checked_at,
|
||
};
|
||
|
||
cache_result(&project_id, &result);
|
||
Ok(result)
|
||
}
|
||
|
||
fn cache_result(project_id: &str, result: &FreshnessResult) {
|
||
let details_json = serde_json::to_string(&result.details).unwrap_or_default();
|
||
if let Some(Ok(conn)) = crate::db::try_pool().map(|p| p.get()) {
|
||
let _ = conn.execute(
|
||
"INSERT INTO doc_freshness_cache (project_id, score, details, checked_at)
|
||
VALUES (?1, ?2, ?3, ?4)
|
||
ON CONFLICT(project_id) DO UPDATE SET
|
||
score = excluded.score,
|
||
details = excluded.details,
|
||
checked_at = excluded.checked_at",
|
||
rusqlite::params![project_id, result.level, details_json, result.checked_at],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 读取缓存的腐化评分(供看板 E 卡使用,不触发 git 扫描)。
|
||
#[tauri::command]
|
||
pub fn get_doc_freshness_cache(project_id: String) -> Option<CachedFreshness> {
|
||
let conn = crate::db::try_pool()?.get().ok()?;
|
||
conn.query_row(
|
||
"SELECT score, details, checked_at FROM doc_freshness_cache WHERE project_id = ?1",
|
||
rusqlite::params![project_id],
|
||
|row| {
|
||
Ok(CachedFreshness {
|
||
score: row.get(0)?,
|
||
details: row.get(1)?,
|
||
checked_at: row.get(2)?,
|
||
})
|
||
},
|
||
).ok()
|
||
}
|
||
|
||
/// 看板 E 卡读取的缓存结构。
|
||
#[derive(Debug, Serialize)]
|
||
pub struct CachedFreshness {
|
||
pub score: String,
|
||
pub details: String, // JSON 字符串,前端自行解析
|
||
pub checked_at: String,
|
||
}
|
||
|
||
// ── Agent 健康诊断 ─────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct AgentHealthIssue {
|
||
pub level: String, // "error" | "warn"
|
||
pub title: String,
|
||
pub fix: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct AgentHealthReport {
|
||
pub overall: String, // "green" | "yellow" | "red"
|
||
pub issues: Vec<AgentHealthIssue>,
|
||
/// 可直接复制给子项目 agent 的修正指令文本(Markdown)
|
||
pub fix_prompt: String,
|
||
pub checked_at: String,
|
||
}
|
||
|
||
/// 诊断项目 agent 友好度,生成可复制的修正指令。
|
||
/// 内部调用 scan_agent_infra_stack + scan_doc_freshness,同时刷新 doc_freshness_cache。
|
||
#[tauri::command]
|
||
pub fn get_agent_health(
|
||
project_path: String,
|
||
project_id: String,
|
||
) -> Result<AgentHealthReport, String> {
|
||
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
|
||
if !root.exists() {
|
||
return Err(format!("目录不存在: {}", root.display()));
|
||
}
|
||
|
||
let stack = scan_agent_infra_stack(project_path.clone())?;
|
||
let freshness = scan_doc_freshness(project_path.clone(), project_id.clone())?;
|
||
|
||
let mut issues: Vec<AgentHealthIssue> = Vec::new();
|
||
let mut overall = "green".to_string();
|
||
|
||
if !stack.context_yaml_exists {
|
||
issues.push(AgentHealthIssue {
|
||
level: "error".into(),
|
||
title: "缺少 docs/ai-context/project-context.yaml".into(),
|
||
fix: "在炼境「Agent 基础设施」面板点击「扫描技术栈」→「确认并生成三件套」".into(),
|
||
});
|
||
overall = "red".to_string();
|
||
}
|
||
|
||
if !stack.meta_rules_present {
|
||
issues.push(AgentHealthIssue {
|
||
level: "warn".into(),
|
||
title: "AGENTS.md 缺少「文档防腐元规则」段落".into(),
|
||
fix: "在炼境「Agent 基础设施」面板生成三件套,或手动在 AGENTS.md 末尾添加 ## 文档防腐元规则 章节".into(),
|
||
});
|
||
if overall != "red" {
|
||
overall = "yellow".to_string();
|
||
}
|
||
}
|
||
|
||
match freshness.level.as_str() {
|
||
"red" if !stack.context_yaml_exists => {
|
||
// yaml 不存在导致的 red 已在上面记录,不重复
|
||
}
|
||
"red" => {
|
||
let stale_count = freshness.details.iter().filter(|d| d.level == "red").count();
|
||
issues.push(AgentHealthIssue {
|
||
level: "error".into(),
|
||
title: format!("文档严重腐化:{stale_count} 个契约文件更新超 30 天未同步 project-context.yaml"),
|
||
fix: "更新(或重新生成)docs/ai-context/project-context.yaml 后【必须 git commit 该文件】——评分基于 git 提交时间而非文件修改时间,只改不提交无法清除本项".into(),
|
||
});
|
||
overall = "red".to_string();
|
||
}
|
||
"yellow" => {
|
||
let stale_count = freshness.details.iter()
|
||
.filter(|d| d.level == "yellow" || d.level == "red")
|
||
.count();
|
||
issues.push(AgentHealthIssue {
|
||
level: "warn".into(),
|
||
title: format!("文档腐化风险:{stale_count} 个契约文件更新后 14-30 天未同步"),
|
||
fix: "更新 docs/ai-context/project-context.yaml 中的契约描述并 git commit(评分基于提交时间)".into(),
|
||
});
|
||
if overall != "red" {
|
||
overall = "yellow".to_string();
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
let proj_name = root
|
||
.file_name()
|
||
.and_then(|n| n.to_str())
|
||
.unwrap_or("未知项目");
|
||
let fix_prompt = build_fix_prompt(proj_name, &overall, &issues, &stack, &freshness);
|
||
|
||
Ok(AgentHealthReport {
|
||
overall,
|
||
issues,
|
||
fix_prompt,
|
||
checked_at: freshness.checked_at,
|
||
})
|
||
}
|
||
|
||
fn build_fix_prompt(
|
||
project_name: &str,
|
||
overall: &str,
|
||
issues: &[AgentHealthIssue],
|
||
stack: &AgentInfraStack,
|
||
freshness: &FreshnessResult,
|
||
) -> String {
|
||
let status_emoji = match overall {
|
||
"red" => "🔴",
|
||
"yellow" => "🟡",
|
||
_ => "🟢",
|
||
};
|
||
let status_label = match overall {
|
||
"red" => "需要修复",
|
||
"yellow" => "存在风险",
|
||
_ => "良好",
|
||
};
|
||
let date_label = freshness.checked_at.get(..10).unwrap_or(&freshness.checked_at);
|
||
|
||
let mut lines = vec![
|
||
format!("# Agent 友好度诊断 — {project_name}"),
|
||
format!("整体状态:{status_emoji} {status_label}(检测时间:{date_label})"),
|
||
String::new(),
|
||
];
|
||
|
||
if issues.is_empty() {
|
||
lines.push("✅ 所有检查通过,项目 agent 友好度良好,无需修复。".into());
|
||
} else {
|
||
lines.push("## 待修复项".into());
|
||
lines.push(String::new());
|
||
for issue in issues {
|
||
let icon = if issue.level == "error" { "❌" } else { "⚠️" };
|
||
lines.push(format!("{icon} **{}**", issue.title));
|
||
lines.push(format!(" 修复方法:{}", issue.fix));
|
||
lines.push(String::new());
|
||
}
|
||
}
|
||
|
||
let mut passed = Vec::new();
|
||
if stack.context_yaml_exists {
|
||
passed.push("docs/ai-context/project-context.yaml 已存在".to_string());
|
||
}
|
||
if stack.meta_rules_present {
|
||
passed.push("AGENTS.md 已包含文档防腐元规则".to_string());
|
||
}
|
||
if !stack.detected_contracts.is_empty() && freshness.level == "green" {
|
||
passed.push(format!(
|
||
"{} 个契约路径均在 14 天内同步",
|
||
stack.detected_contracts.len()
|
||
));
|
||
}
|
||
|
||
if !passed.is_empty() {
|
||
lines.push("## 已通过项".into());
|
||
lines.push(String::new());
|
||
for p in &passed {
|
||
lines.push(format!("✅ {p}"));
|
||
}
|
||
lines.push(String::new());
|
||
}
|
||
|
||
lines.push("---".into());
|
||
lines.push("*由炼境 dev-manager 自动生成。请根据上述修复项逐项处理,完成后在炼境中点击「重新检测」刷新状态。*".into());
|
||
|
||
lines.join("\n")
|
||
}
|
||
|
||
// ── 单元测试 ──────────────────────────────────────────────────────────────────
|
||
|
||
#[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 generate_creates_three_files() {
|
||
let dir = std::env::temp_dir().join(format!("ai_infra_test_gen_{}", std::process::id()));
|
||
setup(&dir);
|
||
// AGENTS.md 存在但无元规则
|
||
write(&dir, "AGENTS.md", "# My Project\n\n## 项目概况\n\nsome content\n");
|
||
|
||
let contracts = vec![
|
||
ContractInput {
|
||
label: "数据库 schema(Drizzle)".into(),
|
||
paths: vec!["drizzle/schema.ts".into()],
|
||
doc: "schema 变更需同步".into(),
|
||
},
|
||
ContractInput {
|
||
label: "API 接口(Hono)".into(),
|
||
paths: vec!["src/routes/**".into()],
|
||
doc: "接口变更需同步".into(),
|
||
},
|
||
];
|
||
|
||
let report = generate_agent_infra_files(dir.to_str().unwrap().to_string(), contracts).unwrap();
|
||
let actions: Vec<(&str, &str)> = report.files.iter()
|
||
.map(|f| (f.path.as_str(), f.action.as_str()))
|
||
.collect();
|
||
|
||
// project-context.yaml 写入
|
||
assert!(actions.iter().any(|(p, a)| p.contains("project-context.yaml") && *a == "written"),
|
||
"应写入 project-context.yaml,实际: {actions:?}");
|
||
// AGENTS.md 注入元规则
|
||
assert!(actions.iter().any(|(p, a)| p.contains("AGENTS.md") && *a == "updated"),
|
||
"应追加元规则到 AGENTS.md,实际: {actions:?}");
|
||
// .claude/rules/doc-freshness.md 写入
|
||
assert!(actions.iter().any(|(p, a)| p.contains("doc-freshness.md") && *a == "written"),
|
||
"应写入 doc-freshness.md,实际: {actions:?}");
|
||
|
||
// 验证 doc-freshness.md 含 globs
|
||
let freshness = fs::read_to_string(dir.join(".claude/rules/doc-freshness.md")).unwrap();
|
||
assert!(freshness.contains("drizzle/schema.ts"), "doc-freshness 应含契约路径");
|
||
assert!(freshness.contains("globs:"), "doc-freshness 应有 globs frontmatter");
|
||
|
||
// 验证幂等:重复生成 AGENTS.md 应 skipped
|
||
let contracts2 = vec![ContractInput {
|
||
label: "L".into(), paths: vec!["x.ts".into()], doc: "d".into(),
|
||
}];
|
||
let report2 = generate_agent_infra_files(dir.to_str().unwrap().to_string(), contracts2).unwrap();
|
||
let agents_result = report2.files.iter().find(|f| f.path.contains("AGENTS.md")).unwrap();
|
||
assert_eq!(agents_result.action, "skipped", "元规则已存在,第二次应跳过");
|
||
|
||
fs::remove_dir_all(&dir).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn extract_yaml_paths_works() {
|
||
let yaml = "\
|
||
contracts:\n - label: \"数据库 schema\"\n paths:\n - \"drizzle/schema.ts\"\n - \"src/db.ts\"\n doc: \"xxx\"\n - label: \"API\"\n paths:\n - \"src/routes/**\"\n doc: \"yyy\"\narchitecture_docs:\n - \"AGENTS.md\"\n";
|
||
let paths = extract_yaml_contract_paths(yaml);
|
||
assert_eq!(paths, vec!["drizzle/schema.ts", "src/db.ts", "src/routes/**"]);
|
||
}
|
||
|
||
#[test]
|
||
fn freshness_red_when_no_yaml() {
|
||
let dir = std::env::temp_dir().join(format!("ai_infra_test_fresh_{}", std::process::id()));
|
||
setup(&dir);
|
||
// 初始化一个假 git 仓库(让 git log 不报错)
|
||
let _ = std::process::Command::new("git").args(["init"]).current_dir(&dir).output();
|
||
|
||
let result = scan_doc_freshness(
|
||
dir.to_str().unwrap().to_string(),
|
||
"test-proj-id".into(),
|
||
).unwrap();
|
||
assert_eq!(result.level, "red", "无 project-context.yaml 应返回 RED");
|
||
|
||
fs::remove_dir_all(&dir).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn git_query_path_strips_glob() {
|
||
assert_eq!(git_query_path("src/routes/**"), "src/routes");
|
||
assert_eq!(git_query_path("drizzle/schema.ts"), "drizzle/schema.ts");
|
||
assert_eq!(git_query_path("packages/types/**"), "packages/types");
|
||
}
|
||
|
||
#[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();
|
||
}
|
||
}
|