新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
@@ -1,52 +0,0 @@
|
||||
//! 项目上下文 — 项目运行时的环境与配置信息
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::ProjectId;
|
||||
|
||||
/// 项目上下文
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectContext {
|
||||
/// 项目 ID
|
||||
pub project_id: ProjectId,
|
||||
/// 项目根目录(本地文件系统路径)
|
||||
pub root_path: Option<String>,
|
||||
/// Git 仓库 URL
|
||||
pub repo_url: Option<String>,
|
||||
/// 当前分支
|
||||
pub current_branch: Option<String>,
|
||||
/// 环境变量
|
||||
pub env_vars: std::collections::HashMap<String, String>,
|
||||
/// 技术栈
|
||||
pub tech_stack: Vec<String>,
|
||||
/// AI 上下文(项目相关的 AI 记忆)
|
||||
pub ai_context: Option<AiContext>,
|
||||
}
|
||||
|
||||
/// AI 上下文信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiContext {
|
||||
/// 项目摘要
|
||||
pub summary: String,
|
||||
/// 架构描述
|
||||
pub architecture: Option<String>,
|
||||
/// 关键决策记录
|
||||
pub decisions: Vec<String>,
|
||||
/// 最近修改摘要
|
||||
pub recent_changes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProjectContext {
|
||||
/// 创建空上下文
|
||||
pub fn new(project_id: ProjectId) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
root_path: None,
|
||||
repo_url: None,
|
||||
current_branch: None,
|
||||
env_vars: std::collections::HashMap::new(),
|
||||
tech_stack: Vec::new(),
|
||||
ai_context: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
//! df-project: 项目管理 — 项目创建、调度、上下文、时间线
|
||||
//! df-project: 项目管理 — 项目创建、技术栈探测
|
||||
|
||||
pub mod context;
|
||||
pub mod manager;
|
||||
pub mod scheduler;
|
||||
pub mod timeline;
|
||||
pub mod scan;
|
||||
|
||||
303
crates/df-project/src/scan.rs
Normal file
303
crates/df-project/src/scan.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
|
||||
//!
|
||||
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
|
||||
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
|
||||
//!
|
||||
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
|
||||
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// 探测目录的技术栈
|
||||
///
|
||||
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
|
||||
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
|
||||
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
|
||||
// ── 后端/系统语言 ──
|
||||
if root.join("Cargo.toml").exists() {
|
||||
push_unique(&mut stack, "rust");
|
||||
}
|
||||
if root.join("go.mod").exists() {
|
||||
push_unique(&mut stack, "go");
|
||||
}
|
||||
if root.join("pom.xml").exists()
|
||||
|| root.join("build.gradle").exists()
|
||||
|| root.join("build.gradle.kts").exists()
|
||||
{
|
||||
push_unique(&mut stack, "java");
|
||||
}
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
push_unique(&mut stack, "python");
|
||||
}
|
||||
// C#: 根目录存在 .csproj 文件(仅一层)
|
||||
if has_file_with_ext(root, "csproj") {
|
||||
push_unique(&mut stack, "csharp");
|
||||
}
|
||||
|
||||
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
|
||||
if root.join("src-tauri").is_dir() {
|
||||
push_unique(&mut stack, "tauri");
|
||||
}
|
||||
|
||||
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
|
||||
if root.join("package.json").exists() {
|
||||
if let Ok(deps) = read_package_deps(root.join("package.json")) {
|
||||
if deps.iter().any(|d| d == "vue") {
|
||||
push_unique(&mut stack, "vue");
|
||||
}
|
||||
if deps.iter().any(|d| d == "react" || d == "react-dom") {
|
||||
push_unique(&mut stack, "react");
|
||||
}
|
||||
if deps.iter().any(|d| d == "@angular/core") {
|
||||
push_unique(&mut stack, "angular");
|
||||
}
|
||||
if deps.iter().any(|d| d == "svelte") {
|
||||
push_unique(&mut stack, "svelte");
|
||||
}
|
||||
if deps.iter().any(|d| d == "next") {
|
||||
push_unique(&mut stack, "next");
|
||||
}
|
||||
if deps.iter().any(|d| d == "vite") {
|
||||
push_unique(&mut stack, "vite");
|
||||
}
|
||||
if deps.iter().any(|d| d == "typescript") {
|
||||
push_unique(&mut stack, "typescript");
|
||||
}
|
||||
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
|
||||
push_unique(&mut stack, "node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stack)
|
||||
}
|
||||
|
||||
/// 解析 package.json,合并 dependencies + devDependencies 的包名
|
||||
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
|
||||
let content = std::fs::read_to_string(path.as_ref())
|
||||
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
|
||||
let mut names = Vec::new();
|
||||
for key in &["dependencies", "devDependencies"] {
|
||||
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
|
||||
for k in obj.keys() {
|
||||
names.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// 目录下是否存在指定扩展名的文件(仅一层)
|
||||
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| x == ext)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
|
||||
// ============================================================
|
||||
|
||||
/// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSample {
|
||||
pub readme: Option<String>,
|
||||
pub tree: Vec<String>,
|
||||
/// (文件名, 截断内容)
|
||||
pub manifests: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
const SAMPLE_README_MAX: usize = 2000;
|
||||
const SAMPLE_MANIFEST_MAX: usize = 1500;
|
||||
const SAMPLE_TREE_MAX: usize = 80;
|
||||
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
|
||||
const SAMPLE_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
|
||||
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
|
||||
".turbo", ".angular", ".gradle", "vendor",
|
||||
];
|
||||
|
||||
/// 采集项目采样(README + 目录树 + 清单),供 LLM 分析填基础信息
|
||||
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
Ok(ProjectSample {
|
||||
readme: read_readme(root),
|
||||
tree: collect_tree(root),
|
||||
manifests: collect_manifests(root),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_readme(root: &Path) -> Option<String> {
|
||||
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
|
||||
let p = root.join(name);
|
||||
if p.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
return Some(truncate_chars(&content, SAMPLE_README_MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
|
||||
fn collect_tree(root: &Path) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut count = 0usize;
|
||||
collect_tree_level(root, "", &mut lines, &mut count, false);
|
||||
lines
|
||||
}
|
||||
|
||||
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut items: Vec<_> = entries.flatten().collect();
|
||||
items.sort_by_key(|e| e.file_name());
|
||||
for e in items {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
if is_dir {
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}/", prefix, name));
|
||||
*count += 1;
|
||||
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
|
||||
if !is_sub {
|
||||
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
|
||||
}
|
||||
} else {
|
||||
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
|
||||
if name.starts_with('.') && name != ".gitignore" {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}", prefix, name));
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
let p = root.join(name);
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 按字符数截断(避免截断 UTF-8 多字节边界)
|
||||
fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max).collect();
|
||||
format!("{}…(已截断)", truncated)
|
||||
}
|
||||
|
||||
/// 去重 push
|
||||
fn push_unique(stack: &mut Vec<String>, s: &str) {
|
||||
if !stack.iter().any(|x| x == s) {
|
||||
stack.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dir_errors() {
|
||||
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rust() {
|
||||
let d = scratch("rust");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_full_stack() {
|
||||
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
|
||||
let d = scratch("full");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
fs::create_dir(d.join("src-tauri")).unwrap();
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
assert!(s.contains(&"tauri".to_string()));
|
||||
assert!(s.contains(&"vue".to_string()));
|
||||
assert!(s.contains(&"vite".to_string()));
|
||||
assert!(s.contains(&"typescript".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dir_returns_empty() {
|
||||
let d = scratch("empty");
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.is_empty());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sample() {
|
||||
let d = scratch("sample");
|
||||
fs::write(d.join("README.md"), "# Test\nA test project.\nMore.").unwrap();
|
||||
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
|
||||
fs::create_dir(d.join("src")).unwrap();
|
||||
fs::write(d.join("src/main.ts"), "x").unwrap();
|
||||
fs::create_dir(d.join("node_modules")).unwrap();
|
||||
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
assert!(s.readme.as_deref().unwrap_or("").contains("test project"));
|
||||
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
|
||||
assert!(s.tree.iter().any(|t| t.contains("src")));
|
||||
// node_modules 应被过滤
|
||||
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//! 项目调度器 — 自动化任务分配与调度
|
||||
|
||||
use df_core::types::{ProjectId, TaskId};
|
||||
|
||||
/// 调度策略
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum SchedulingStrategy {
|
||||
/// FIFO(先进先出)
|
||||
Fifo,
|
||||
/// 按优先级调度
|
||||
Priority,
|
||||
/// 最短任务优先
|
||||
ShortestFirst,
|
||||
/// AI 智能调度
|
||||
AiDriven,
|
||||
}
|
||||
|
||||
/// 调度决策
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SchedulingDecision {
|
||||
pub project_id: ProjectId,
|
||||
pub task_order: Vec<TaskId>,
|
||||
pub strategy: SchedulingStrategy,
|
||||
}
|
||||
|
||||
/// 项目调度器
|
||||
pub struct ProjectScheduler {
|
||||
strategy: SchedulingStrategy,
|
||||
}
|
||||
|
||||
impl ProjectScheduler {
|
||||
/// 创建调度器
|
||||
pub fn new(strategy: SchedulingStrategy) -> Self {
|
||||
Self { strategy }
|
||||
}
|
||||
|
||||
/// 为项目生成调度计划
|
||||
///
|
||||
/// TODO: 实现基于策略的调度算法
|
||||
pub fn schedule(&self, project_id: &ProjectId) -> anyhow::Result<SchedulingDecision> {
|
||||
match self.strategy {
|
||||
SchedulingStrategy::Fifo => {
|
||||
// TODO: 按创建时间排序
|
||||
tracing::info!("FIFO 调度,项目: {}", project_id);
|
||||
}
|
||||
SchedulingStrategy::Priority => {
|
||||
// TODO: 按优先级排序
|
||||
tracing::info!("优先级调度,项目: {}", project_id);
|
||||
}
|
||||
SchedulingStrategy::ShortestFirst => {
|
||||
// TODO: 按预估工作量排序
|
||||
tracing::info!("最短任务优先调度,项目: {}", project_id);
|
||||
}
|
||||
SchedulingStrategy::AiDriven => {
|
||||
// TODO: 接入 AI 进行智能调度
|
||||
tracing::info!("AI 智能调度,项目: {}", project_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SchedulingDecision {
|
||||
project_id: project_id.clone(),
|
||||
task_order: Vec::new(),
|
||||
strategy: self.strategy,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//! 项目时间线 — 里程碑与进度追踪
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::ProjectId;
|
||||
|
||||
/// 里程碑
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Milestone {
|
||||
/// 唯一 ID
|
||||
pub id: String,
|
||||
/// 所属项目 ID
|
||||
pub project_id: ProjectId,
|
||||
/// 里程碑名称
|
||||
pub name: String,
|
||||
/// 描述
|
||||
pub description: String,
|
||||
/// 计划完成时间
|
||||
pub due_date: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// 实际完成时间
|
||||
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// 进度百分比 (0-100)
|
||||
pub progress: u8,
|
||||
/// 是否已完成
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
/// 项目时间线
|
||||
pub struct Timeline {
|
||||
pub project_id: ProjectId,
|
||||
pub milestones: Vec<Milestone>,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
/// 创建空时间线
|
||||
pub fn new(project_id: ProjectId) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
milestones: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加里程碑
|
||||
pub fn add_milestone(&mut self, milestone: Milestone) {
|
||||
self.milestones.push(milestone);
|
||||
}
|
||||
|
||||
/// 计算整体进度
|
||||
pub fn overall_progress(&self) -> f64 {
|
||||
if self.milestones.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let total: f64 = self.milestones.iter().map(|m| m.progress as f64).sum();
|
||||
total / self.milestones.len() as f64
|
||||
}
|
||||
|
||||
/// 获取下一个待完成的里程碑
|
||||
pub fn next_milestone(&self) -> Option<&Milestone> {
|
||||
self.milestones
|
||||
.iter()
|
||||
.filter(|m| !m.completed)
|
||||
.min_by_key(|m| m.due_date)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user