From 0d727df2a3632ce50e6c7dd81c699e1595981e8d Mon Sep 17 00:00:00 2001 From: juice094 Date: Sat, 16 May 2026 22:50:51 +0800 Subject: [PATCH 01/12] =?UTF-8?q?docs:=20=E7=B2=BE=E7=AE=80=20AGENTS.md=20?= =?UTF-8?q?+=20=E7=94=9F=E4=BA=A7=E5=B0=B1=E7=BB=AA=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: 705→258 行,保留核心约束(环境指引/关键约定/安全原则/架构红线/禁止事项) - docs/AGENTS-full.md: 保留完整版(历史记录/路线图/详细讨论) - docs/production-readiness.md: 新增生产就绪检查清单 - 6 大维度:稳定性/性能/MCP/Agent集成/文档/发布流程 - 4 阶段推进计划:Phase0(当前)→Phase1(稳定化)→Phase2(Agent试点)→Phase3(v1.0) - 所有条目客观可验证,禁止主观描述 --- AGENTS.md | 451 +--------------------- docs/AGENTS-full.md | 705 +++++++++++++++++++++++++++++++++++ docs/production-readiness.md | 90 +++++ 3 files changed, 797 insertions(+), 449 deletions(-) create mode 100644 docs/AGENTS-full.md create mode 100644 docs/production-readiness.md diff --git a/AGENTS.md b/AGENTS.md index e72fc34..04ceedf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -247,455 +247,6 @@ done --- -## 技术债登记簿(Technical Debt Ledger) - -> 已识别的架构债,按严重程度排序。清偿前不得新增同类债务。 - -| 债项 | 严重 | 当前值 | 目标阈值 | 清理路径 | 引入 Wave | -|---|---|---|---|---|---| -| `main.rs` 上帝文件 | 🟢 | 778 行 | ≤1000 行 | 拆分为 `commands/simple.rs` + `commands/skill.rs` + `commands/workflow.rs` + `commands/limit.rs`;全部 22 个命令/子命令树已迁移 | ≤15 | -| Workspace crate 版本号混乱 | 🟢 | **已完成**:全部 19 个 crate 统一为 `version.workspace = true`,workspace 版本 `0.20.0` | 全部统一为 `version.workspace = true` 或 `0.20.0` | 批量修正 `Cargo.toml` | v0.20.1 | -| RF-7 高耦合模块超标 | 🟢 | **已完成**:`scan.rs` 18→7、`digest.rs` 17→5、`registry.rs` 20→4;全部 `src/*.rs` ≤ 15 `crate::` 引用 | ≤15 | use 语句规范化消除 self-reference;`registry.rs` 保留 4 个(2 use + 2 dependency_graph)作为 facade | v0.20.1 | -| `init_db()` 全局路径 | 🟢 | `AppContext` 已集成到全部 commands/ 模块;`main()` 通过 `AppContext` 分发配置;`init_db()` 无外部调用 | 0 | 已完成:`StorageBackend` trait + `AppContext` 全面替代;`db_path`/`workspace_dir`/`index_path`/`backup_dir` 已统一 | ≤15 | -| Tantivy+SQLite 双写一致性 | 🟡 | 无事务协调;**已添加反向检测**(`repair_tantivy_consistency` 现在检测 SQLite→Tantivy 缺失) | 补偿机制 | 长期:事务协调或 SQLite FTS5 替代;短期:反向检测 + 日志已落地(`fe14c81`) | 7 | -| 主从表切换 | 🟢 | Phase 1 全部完成:`repos` 表已删除,entities 为唯一数据源 | `entities` 为第一公民 | Phase 2 类型系统开放(新增 entity_type 无需改表结构) | v0.12.0 | -| vault/paper/workflow entities 缺口 | 🟢 | Stage C+D+E 全部完成:`vault_notes`/`papers`/`workflows` 表已删除,`skills` 保留(embedding BLOB) | 0 缺口 | — | v0.12.0 | -| scan 路径排除 | 🟢 | `discover_repos` + `collect_tasks` 均支持 `scan.exclude_paths`;scan 和 sync 双阶段过滤 | 0 缺口 | 排除路径使用 `Path::starts_with` 组件级匹配,避免字符串前缀误杀;相对路径在 sync 场景(无 root)下被忽略 | v0.12.0 | -| tree-sitter 编译成本 | 🟢 | ~15-20s grammar C compilation | 可控 | 已完成 feature-gate:`lang-rust`/`lang-python`/`lang-js-ts`/`lang-go` 四个 feature,默认全启,可选关闭减少编译;`--no-default-features` 编译通过 | 8 | -| Feature flags 缺失 | 🟢 | 4 个可选 feature (tui, watch, mcp, embedding) | ≥3 | 已完成:`tui`/`watch`/`mcp`/`embedding` 均为 optional;`--no-default-features` 编译通过 | ≤15 | -| Vault 无版本历史 | 🟢 | `devkit_vault_history` + git2 revwalk + blob diff 行级统计 | 历史可回溯 | 用户侧将 vault 目录作为 Git 子模块管理 | v0.20.0 | -| `LOCALAPPDATA` 测试模式残留 | 🟢 | 0 处 | 0 | 全面废弃 `LOCALAPPDATA` 环境变量覆盖,统一为 `DEVBASE_DATA_DIR`;mcp/tests.rs 修复 cleanup 逻辑(remove_var 目标从 LOCALAPPDATA 修正为 DEVBASE_DATA_DIR) | 47 | -| 单体职责膨胀(代码智能+知识库+仓库管理+工作流+Skill+Syncthing) | 🟡 | 6 个核心领域耦合于单一二进制(31MB);`workflow`/`skill` 与 Claude Code Agent 能力重叠 | 按领域拆分为 `devbase-core`(代码+vault)+ `devbase-sync`(仓库管理)+ `devbase-bridge`(Syncthing);冻结 workflow/skill 新增 | 外部审查 2026-05-11 | - -**清偿原则**: -1. 禁止在清偿现有 🔴 债务前新增同类别债务。 -2. 每个债务必须关联至少一个 `TODO(#)` 或 `FIXME` 代码注释。 -3. 每季度(90 天)由 MODE-O 审查一次,更新当前值与优先级。 - ---- - -## 历史 Waves - -| Wave | 主题 | 关键产出 | Commit | -|------|------|---------|--------| -| 42-44 | 测试基础设施 | 22 个 smoke tests, CLI 集成测试层 (`tests/cli.rs`), Criterion 基准测试 (`benches/registry_bench.rs`) | — | -| 45-47 | Tier 1 测试收尾 | 28 个 smoke tests 覆盖 embeddings/semantic_search/cross-repo/search/workflow/backup/registry;`SCHEMA_DDL` 补录 4 表;`init_db()` 并发安全 (`BEGIN EXCLUSIVE`);测试数据隔离统一为 `DEVBASE_DATA_DIR` | — | -| 1 | 代码质量 | `rustfmt.toml`, clippy 清零 | `4efcd58` | -| 2 | 模块拆分 | `sync/`, `registry/`, `mcp/tests.rs` | `4efcd58` | -| 3 | 工程化 | `src/lib.rs`, CI workflow, `main.rs` 简化 | `4efcd58` | -| 4 | 依赖/审计 | `notify` 8.2.0, `tokei` 14.0.0 | `4efcd58` | -| 5 | TUI 美学与工程学 | 主题系统, Tabs, Help Overlay, Render 拆分 | `6b9be88` | -| 6 | 数据层深度能力 (MVP) | 语义索引、调用图、依赖图、死代码检测、Python 依赖解析 | `9fbf7c4` | -| 7 | 向量语义搜索 | `embedding.rs`, `code_embeddings` 表, `devkit_semantic_search` | `4d400b1` | -| 8 | 多语言符号提取 | tree-sitter-python/typescript/go, Rust/Python/JS/Go 符号 + Call Graph | `4f4911b` | -| 9 | scan panic 修复 + arXiv/CMake | `block_on_async` 安全封装, arXiv API 元数据, CMakeLists.txt 依赖解析 | `881cd32` | -| 10 | OpLog 结构化 | Schema v12, OplogEventType 枚举, JSON metadata, duration_ms | `7aa2a65` | -| 11 | 性能基准 | criterion benches: index_repo_full, cosine_similarity, extract_symbols, CMake | `8e0f236` | -| 12 | 混合检索核心 | `search::hybrid.rs`: RRF 归并, keyword_search, hybrid_search_symbols | `7fca714` | -| 13 | 外部 Embedding Provider | Python CLI `tools/embedding-provider/`, Ollama 批量生成, 字节兼容序列化 | `574fb96` | -| 14a | 跨 repo 语义聚合 | `cross_repo_search_symbols()` INTERSECT tag 过滤, `devkit_cross_repo_search` | `8e762c7` | -| 14b | 知识覆盖报告 | `oplog_analytics.rs`: 表存在性容错, 覆盖度/健康度/活动流, `devkit_knowledge_report` | `869bcbf` | -| 15a | 显式知识链接 | Schema v13 `code_symbol_links`, Jaccard 签名相似度, 同文件聚类, `devkit_related_symbols` | `d462209` | -| 15b | 混合检索 MCP Tool | `devkit_hybrid_search`: 向量+RRF+关键词自动降级, 推荐默认搜索入口 | `6df6106` | -| 16a | Skill Runtime Schema | `skills` + `skill_executions` 表, SKILL.md 解析器, Registry CRUD, 3 内置 skills | `e41eccb` | -| 16b | Skill 发现与搜索 | 文本搜索 + 语义搜索 (`--semantic`), skill embedding 生成脚本 | `48b96c6` | -| 17 | Skill 执行引擎 | Process-based executor, interpreter 自动解析, timeout, stdout/stderr 捕获, 执行审计 | `99d818e` | -| 18 | MCP Skill 集成 | `devkit_skill_list` / `devkit_skill_search` / `devkit_skill_run` 3 个 tools | `c80fdec` | -| 19a | Skill 生态(安装/发布) | `install_skill_from_git` (git2 clone), `publish` (validate + git tag + push remote) | `8120e4d` | -| 19b | Skill 生态(同步/TUI) | `sync --target clarity` (导出为 Clarity plan JSON), TUI Skill Panel (`k` keybinding) | `678c70c` | -| 20 | Skill 依赖管理 | Schema v15 `dependencies` 列,Kahn 拓扑排序,DFS 环检测,自动安装缺失依赖,`install`/`run`/`validate` 集成 | `75fed3c` | -| 21 | 统一实体模型 + 自动封装 | Schema v16 `entities/entity_types/relations`,渐进双写;`discover` 命令(Rust/Node/Python/Go/Docker/Generic 检测 + SKILL.md 自动生成 + entry_script 包装器);分类推断(ai/dev/data/infra/communication) | — | -| 22 | AppContext Pool 化 | `r2d2::Pool` 替代单 Connection;22 个 commands/TUI/MCP 全链路迁移;`init_db()` 89→5 处;MCP 测试临时目录隔离;search 多线程竞态自愈 | — | -| 23 | Registry God Object 拆解 | 提取 10 子模块(repo/vault/workspace/health/metrics/links/known_limits/knowledge_meta/knowledge)为 free-function 模块;`WorkspaceRegistry` 退化为向后兼容门面;~150 处调用点迁移;0 测试回归 | `dfc43d4` | - -## 敏感文件清单(禁止提交) - -| 文件/模式 | 原因 | .gitignore 覆盖 | -|-----------|------|----------------| -| `*.db` | SQLite 数据库含用户仓库元数据 | ✅ | -| `.devbase/` | 本地 sync 标记和工作区状态 | ✅ | -| `*.log` | 可能含路径或错误堆栈信息 | ✅ | -| `.env*` | 环境变量和 secrets | ✅ | -| `*.local.toml` | 本地覆盖配置 | ✅ | -| `target/` | 构建产物 | ✅ | - -## 跨项目接口 - -- **clarity-core**:已解除路径依赖。devbase 不再被 clarity-core 调用,LLM 能力内联为纯 reqwest -- **syncthing-rust**:`.syncdone` 标记格式已对齐 - -## 架构讨论摘要(来自 2026-04-24 会话) - -以下为本项目相关的粗粒度架构决策与待探索方向。 - -### 1. 自指知识库:五层知识模型 - -devbase 作为知识库存储层,需支持 L0-L4 五层索引: - -| 层级 | 内容 | 生长信号 | 遗忘机制 | -|------|------|---------|---------| -| L0 对象 | 外部知识块(代码、文档、日志) | 检索频率、引用次数 | 版本冻结 | -| L1 方法 | 操作知识的方法(检索/分块/向量化) | 检索成功率、延迟分布 | A/B 测试 | -| L2 哲学 | 设计原则(本地优先、奥卡姆剃刀) | 架构决策事后验证 | 外部论文扰动 | -| L3 风险 | 系统弱点图谱 | 故障事件、异常日志 | 红队攻击 | -| L4 元认知 | 关于 L1-L3 的元知识 | 人类纠正、跨会话一致性 | 形式化验证 | - -**决策**:粗粒度与细粒度知识保留独立索引;细粒度存 SQLite(快速查询),粗粒度存 Vector DB(语义检索)。 - -### 2. 审计日志(OpLog) - -- P3 不可靠交付的使用追踪写入 OpLog,实现事后追溯 -- 边界图谱版本历史、探索任务结果写入 OpLog -- 所有验证消息(请求+响应+共识)写入 OpLog - -### 3. 外部资源调度器 - -devbase 承载外部资源调度的抽象接口: - -- **形式化工具**:TLA+/Coq/Lean(本地路径或远程地址) -- **人类专家**:异步审批,不阻塞夜间批处理 -- **P2P 节点**:复用 syncthing-rust 的 Device ID 与传输层 -- **文献检索**:arXiv / Semantic Scholar API - -**决策**:定义资源请求的抽象接口与排队策略;具体调度算法不进当前 scope。 - -### 4. 边界图谱存储 - -- `BoundaryMap` 存储已知限制(KnownLimit)的版本历史 -- `ExplorationTask` 队列记录边界外待探索任务 -- 跨实例同步:通过 syncthing-rust P2P 网络同步边界快照 - -### 5. 安全计算(MPC/TEE) - -- 当前四个项目中无密码学层归属 -- **短期**:devbase MCP 接口可封装外部 TEE 服务(如 Azure Confidential Computing) -- **长期**:如需自建,新建 `clarity-tee` 或 `devbase-secure` 子项目 - -## 当前阶段待办(v0.15.0 推进中) - -v0.11.3 已交付(tagged)。v0.12.0-alpha 全部功能已完成,进入发布治理阶段。 - -| 方向 | 状态 | 说明 | -|------|------|------| -| `init_db()` → `AppContext` 迁移 | 🟢 | Pool 化完成,`init_db()` 从 89 处降至 5 处合法保留,全部 commands/TUI/MCP 已接入 | -| Tantivy+SQLite 双写一致性 | 🟡 | 无事务协调,需补偿机制或 FTS5 替代评估 | -| tree-sitter 编译成本 | 🟡 | ~15-20s,评估 ccache 或 grammar 预编译 | -| Feature flags 扩展 | 🟡 | 2/3(tui, watch),mcp 等模块待评估 | - ---- - -## 历史完成记录(v0.4.0 – v0.10.0) - -### 阶段二任务(v0.4.0 AI Skill 编排基础设施) - -| 波次 | 任务 | 状态 | 交付物 | -|------|------|------|--------| -| Wave 21 | Schema v16 + 自动封装 | ✅ 已完成 | `entity_types/entities/relations` + `devbase skill discover` | -| Wave 22 | discover 硬化 | ✅ 已完成 | `--install` 真正注册 + Git URL 直接克隆封装 | -| Wave 23 | Workflow 预留 | ✅ 规范已完成 | `docs/architecture/workflow-dsl.md` | -| Wave 24 | Workflow Engine v0.5.0 | ✅ 已完成 | YAML 解析 + 拓扑调度 + batch 并行执行 + 5 step 类型 | -| Wave 25 | TUI Workflow 可执行 | ✅ 已完成 | `[w]` 详情页 `r/Enter` 运行 + 结果弹窗 | -| Wave 26 | NLQ 自然语言查询 v0.7.0 | ✅ 已完成 | `[:]` 触发 embedding 语义搜索 + fallback 降级 | -| Wave 27 | Mind Market 评分 v0.6.0 | ✅ 已完成 | `success_rate`/`usage_count`/`rating` + `recalc-scores`/`top`/`recommend` | -| Wave 28 | 7 个风险点修复 v0.7.1 | ✅ 已完成 | EnvGuard、NLQ fallback、StepType 显式标签、跨平台解释器探测 | -| Wave 29 | Workflow 子类型执行 v0.8.0 | ✅ 已完成 | Subworkflow 递归 + Parallel 聚合 + Condition 表达式求值 | -| Wave 30 | 生产代码 unwrap 清零 | ✅ 已完成 | 29 个生产代码 unwrap → 0,`cargo clippy -D warnings` 全绿 | -| Wave 31 | NLQ 结果可执行 v0.8.1 | ✅ 已完成 | `[:]` 搜索结果按 Enter 直接运行 skill,event+state+render 三文件修改 | -| Wave 32 | NLQ smoke test | ✅ 已完成 | `run_nlp_selected_skill` 空列表/无技能/执行管道测试,267 tests passed | -| Wave 33 | TUI SkillPanel 拆分 | ✅ 已完成 | 7 个 skill 字段提取到 `SkillPanelState`,App 51→44 字段 | -| Wave 34 | Workflow Loop Step 硬化 | ✅ 已完成 | `StepType::Loop { body }` + `execute_loop_step` + `${loop.item}` / `${loop.index}` | -| Wave 35 | L3 Risk Layer MVP | ✅ 已完成 | Schema v18 `known_limits` + Registry CRUD + MCP tools + CLI `limit` + OpLog 集成 | -| Wave 36 | L4 元认知层 MVP | ✅ 已完成 | Schema v19 `knowledge_meta` + Registry CRUD + `--reason` resolve + L3-L4 联动 | -| Wave 37 | Hard Veto 运行时守卫 | ✅ 已完成 | `skill_runtime::executor` 执行前检查未解决 hard veto,警告注入 stderr + OpLog 审计 | - -### 明确不做(已排除) - -- SSE transport(stdio 已覆盖主流 Client) -- `.devbase` 目录规范(无外部采纳者) -- MCP 协议扩展提案(Star = 0,不会被采纳) -- 商业化 / 付费版 -- ~~拆分 crate(50+ tools 后再评估)~~ → **重新评估**:已触发外部架构审查(§九 耦合检查,6 领域耦合),`workflow`/`skill` 与 Claude Code Agent 重叠,v0.16.0 需输出拆分方案(`devbase-core` / `devbase-sync` / `devbase-bridge`) - -### Future / Icebox(无排期) - -1. ~~输出 L0-L4 五层知识的 TOML/JSON Schema 草案~~(保持开放,非阻塞) -2. ~~输出 OpLog 审计事件类型清单~~(已有基础枚举,保持增量) -3. ~~输出外部资源调度的请求格式草案~~(保持开放) -4. **不做**:调度算法、边界图谱引擎、哲学规则库内容、密码学协议 - -### Post-Wave 19 triage 结论(2026-04-25) - -| 优先级 | 事项 | 状态 | -|--------|------|------| -| P1 | SSE 传输状态与 README 一致性 | ✅ 已完成 — README 修正为 "stdio only; SSE in development",见 commit `935dd61` | -| P2 | 架构预拆分评估 | ✅ 已完成 — 评估报告位于 `docs/architecture/pre-split-evaluation.md`,结论:22.7 KLOC 单 crate 仍最优, defer 至 50+ tools 或编译 > 60s | -| P3 | 竞品定位标语 | ✅ 已完成 — README 顶部标语更新为 "AI 无法识别你的 GUI,devbase 是它的眼镜。" | -| P4 | 开发者 onboarding 文档 | ✅ 已完成 — `CONTRIBUTING.md` + README Contributing 章节(devbase + clarity) | - -- **Tag**: `v0.10.0` 已打标(最新);`v0.2.4` 及之前标签见 Git history -- **Roadmap**: `docs/ROADMAP.md` 为唯一活跃主路线图 - -## Embedding 策略长期规划(已决策) - -**方向**:混合方案 — 模型向量语义搜索 + tantivy BM25 降级 - -| 层级 | 触发条件 | 技术方案 | 状态 | -|------|----------|----------|------| -| L1 向量语义 | `code_embeddings` 表有数据 | Ollama/OpenAI-compatible 生成 768-dim embedding,余弦相似度 Top-K | 已实现,待激活(需 Ollama 运行) | -| L2 全文搜索 | `code_embeddings` 为空或服务不可用时 | tantivy 索引代码符号(function name + signature + doc comment),BM25 评分 | 基础设施就绪,待接入 `semantic_search_symbols` | -| L3 纯符号匹配 | 查询为精确标识符 | SQLite `LIKE '%name%'` 快速匹配 | 已有 | - -**关键决策**:不绑定 Ollama 为唯一 provider。未来可能替换 embedding 生成层为: -- 本地 C++ 推理引擎(如 llama.cpp / onnxruntime) -- 纯 Rust 推理引擎(如 rust-bert / candle) -- 外部 MCP / Skill 封装(embedding 作为独立服务) - -**Embedding 状态**: -- `code_embeddings`: **56,722** 行(37.0% 覆盖率),覆盖 10 个仓库 -- `skills.embedding`: 3 个 builtin skill 已有 384-dim 向量 -- 生成工具:`tools/embedding-provider/skills.py`(sentence-transformers `all-MiniLM-L6-v2`) -- 激活路径:启动 Ollama + `devbase index ` 生成 embedding,或配置远程 provider 于 `config.toml [embedding]` 段 - -### 2026-05-04 索引性能实验记录 - -**发现**:Candle CPU BERT `batch_size=32` forward 比 `rayon` 并行单条慢 **5.2×**(88s vs 16s)。 -- 根因:Candle CPU matmul 对大 padded batch 不友好;batch 内序列长度差异导致大量无效 padding token 计算。 -- **决策**:`generate_and_save_embeddings` 回滚到 `rayon::par_iter()` 单条编码;保留 `EmbeddingProvider::encode_batch` trait 方法供未来 GPU/ONNX provider。 -- **新增**:`devbase index --skip-embeddings` 跳过 embedding 生成,纯符号/调用图索引从 ~16s 降至 ~250ms。 - -**外部参考**:知识蒸馏 Pipeline 设计规格(六阶段:噪声过滤→语义分割→主题聚类→层级展开→可信度标注→结构化输出),来源见 `docs/_audit/2026-04-26-embedding-research.md` §2026-05-04 补充。该规格提出通过 devbase MCP 暴露 `devkit_knowledge_distill` 工具,与 Vault 系统形成输入-处理-输出闭环。状态:设计规格级,待验证后评估集成优先级。 - -## 上下文安全机制(Context Safety Mechanism) - -> 长期架构原则:在多 Agent / 子代理协作场景下,保证工作区状态的一致性与可恢复性。 - -### 1. 子代理执行隔离 - -**教训**(2026-04-25 实际发生):多个子代理在同一 Git 工作目录并行执行 `git checkout`/`git commit` 会导致严重的分支混乱。`agent-publish` 和 `agent-tui` 的修改互相覆盖,最终 commit 被错误地放置到对方分支, stash 中混入了不相关的代码。 - -**规则**: -- **串行优先**:多个子代理任务必须串行执行,每次 commit 后切回 main 再启动下一个 -- **目录隔离**:若必须并行,每个子代理在独立的 `git clone` 临时目录工作,完成后由主会话 cherry-pick -- **禁止共享工作目录**:多个 Agent 绝不能同时操作同一个 `.git` 目录 -- **编译检查**:任何子代理返回前必须通过 `cargo test --lib`,否则标记为脏状态 - -### 2. MCP 工具幂等性 - -**原则**:所有通过 MCP 暴露的状态变更操作必须是幂等的。 - -**实现**: -- `save_embeddings` — `ON CONFLICT(repo_id, symbol_name) DO UPDATE` -- `save_symbol_links` — `ON CONFLICT(source_repo, source_symbol, target_repo, target_symbol, link_type) DO NOTHING` -- `index_repo` — 先 `DELETE` 旧数据再 `INSERT`(而非追加) -- 所有批量操作包裹在 SQLite transaction 中 - -### 3. 状态变更审计追踪 - -**原则**:任何对 registry 的写入都必须留下不可变的审计痕迹。 - -**实现**: -- OpLog Schema v12+:`event_type` 枚举 + JSON metadata + `duration_ms` -- 所有 `scan`/`sync`/`health`/`index` 操作自动记录 -- Schema 迁移前自动生成 `backup-YYYYMMDD-HHMMSS.db` 快照 -- `registry export --format json` 支持用户自主备份 - -### 4. 知识库一致性契约 - -**原则**:存储层(devbase)与计算层(Clarity/Skill)之间的接口契约必须显式、可版本化。 - -**当前契约**: -| 方向 | 接口 | 版本 | -|------|------|------| -| 外部 → devbase | `devkit_embedding_store(repo_id, symbol_name, embedding[])` | v1 | -| devbase → 外部 | `devkit_hybrid_search(repo_id, query_text, query_embedding?, limit)` | v1 | -| devbase → 外部 | `devkit_knowledge_report(repo_id?, activity_limit)` | v1 | - -**变更规则**:MCP tool schema 的 breaking change 必须通过新增 tool(如 `devkit_hybrid_search_v2`)而非修改现有 tool。 - -### 5. Sync Fail-Safe Defaults(Managed-Gate) - -**原则**:危险操作(修改本地 Git 分支)必须默认拒绝,仅对显式授权仓库生效。 - -**实现**(v0.12.0-alpha+): -- `scan --register` 不再自动分配 `"discovered"` 标签;新注册仓库标签为空。 -- 默认 `devbase sync`(无 `--filter-tags`)仅操作带有**管理标签**的仓库: - `mirror`, `reference`, `third-party`, `collaborative`, `team`, - `own-project`, `tool`, `active`, `managed`。 -- 未管理仓库仍被追踪(`health`/`index`/`query` 不受影响),但 `sync` 会跳过它们并提示: - `ℹ️ N registered repositories are not managed. Use 'devbase tag managed' to enable sync.` -- `--filter-tags` 显式过滤时绕过管理门控,允许用户按需选择任意标签组合。 -- 现有 DB 中带有旧 `"discovered"` 标签的仓库自动变为未管理状态(无需迁移,即安全修复)。 - -**用户操作路径**: -1. `devbase scan --register` → 仓库入库,标签为空。 -2. `devbase tag managed`(或 `mirror` / `active` 等)→ 授权该仓库进入自动同步池。 -3. `devbase sync` → 仅对已授权仓库执行同步。 - ---- - -## 上下文压缩缓解(Kimi CLI / LLM Agent 会话恢复) - -> Kimi CLI 等 Agent 环境存在上下文窗口限制,长会话会被压缩/截断。以下措施确保压缩后新 Agent 能独立恢复工作上下文。 - -### 1. 文件自描述原则 - -所有 `src/` 根目录模块和 `crates/*/src/lib.rs` 必须在文件顶部包含: -- **一句话职责**:这个文件解决什么问题 -- **边界说明**:它依赖谁、谁依赖它(或明确声明"零内部依赖") -- **关键决策注释**:任何非直观的设计选择必须有 `// NOTE:` 或 `// DECISION:` 注释 - -### 2. 状态锚点文件(Context Anchor Files) - -以下文件必须保持最新,作为会话压缩后的恢复点: - -| 文件 | 用途 | 更新触发条件 | -|------|------|-------------| -| `docs/ai-protocol.md` | 架构快照、待办、耦合地图 | 每次架构变更后 | -| `AGENTS.md` | 环境指引、红线规则、历史决策 | 每次红线变更后 | -| `Cargo.toml` workspace 声明 | 模块结构、crate 列表 | 每次提取/新增 crate | -| 各 `crates/*/Cargo.toml` | 独立 crate 的依赖与版本 | 每次依赖变更 | - -### 3. 反模式:禁止在代码中埋藏隐式上下文 - -- ❌ `// 之前讨论过这个方案` — 压缩后无法追溯 -- ❌ `// 见上文的 TODO` — "上文"已丢失 -- ✅ `// DECISION(2026-05-01): 使用 rusqlite::Connection 而非 r2d2 Pool,因为单个写入线程不需要连接池` — 自包含 - -### 4. 会话交接模板 - -当检测到上下文可能被压缩时,Agent 应在恢复后执行以下自检: - -```bash -# 1. 确认当前架构状态 -cargo test --workspace 2>&1 | grep "test result" -# 2. 确认 workspace 成员 -cargo metadata --format-version 1 | jq '.workspace_members' -# 3. 确认耦合地图(快速扫描高耦合模块) -grep -rc 'crate::' src/*.rs | sort -t: -k2 -n | tail -5 -``` - -## 知识库生产级缺口与补齐路线(Knowledge Base Production Gap) - -> 该章节记录 devbase 作为知识基础设施与生产级要求之间的真实差距,以及消除"玩具感"的补齐路径。 -> **核心原则**:devbase 首先是一个可靠的本地知识基础设施,然后才是一个 World Model Compiler。AI 层是编译器的输出接口,但如果存储层不可靠,AI 就是沙上建塔。 - -### 缺口诊断(与生产级知识库对比) - -| 能力维度 | 当前现状 | 生产级要求 | 缺口等级 | -|:---|:---|:---|:---:| -| **存储可靠性** | SQLite 单文件;Schema 迁移前自动快照 | WAL 并发模式、增量备份、索引损坏自动检测重建、点对点恢复 | 🔴 **严重** | -| **检索质量** | BM25 + 768-dim `cosine_similarity` SQL UDF | Hybrid RRF 调优、Re-rank、多路召回、查询延迟可观测 | 🔴 **严重** | -| **知识图谱** | `relation_store/query` 简单三元组 | 双向链接图遍历、Transitive Closure、社区发现、本体约束 | 🟠 **显著** | -| **版本历史** | 代码有 Git;Vault 笔记无版本 | 笔记块级历史、分支、冲突合并策略 | 🟠 **显著** | -| **规模化** | 单机 Rayon;未验证 >100 仓库 / >10k 文档场景 | 索引分片、增量更新、查询缓存、内存上限保护 | 🟠 **显著** | -| **互操作性** | Vault 读写 Markdown | Obsidian 兼容(frontmatter/wikilink)、标准导入导出、避免 Vendor Lock-in | 🟡 **中等** | -| **多模态** | 文本为主 | PDF 解析、图片 OCR、音频转录 | 🟡 **可延期** | -| **协作** | 单用户 + Syncthing 文件级同步 | 冲突解决(CRDT/OT 或至少 last-write-win)、多设备状态一致性 | 🟠 **显著** | - -### 补齐路线图 - -#### 🔴 v0.19.0:存储可靠性加固(消除"玩具感"的最快路径) - -| 任务 | 优先级 | 验收标准 | -|:---|:---:|:---| -| SQLite WAL 模式默认启用 | P1 | 并发写入无锁定冲突;`PRAGMA journal_mode=WAL` 持久化 | -| Tantivy 索引健康检查 `devkit_index_health` | P1 | 检测索引损坏、版本不匹配、孤儿文档;返回健康评分 0-100 | -| 自动重建策略 | P1 | 索引损坏时自动 fallback 全量重建,而非静默失败;重建过程写入 OpLog | -| 查询性能基线测试 | P1 | CI 中测试 1k/10k/100k 文档量级的检索延迟;建立性能回归红线 | -| Vault 批量导出(Markdown + frontmatter) | P2 | `devkit_vault_export` 支持 PARA 结构完整导出;消除 Vendor Lock-in 焦虑 | -| Redis 缓存评估 | P2 | 完成 Session/向量缓存需求分析;决策:引入 / 自建 / 放弃 | - -#### 🟠 v0.20.0:知识完备性(从"能存"到"好用") - -| 任务 | 优先级 | 验收标准 | -|:---|:---:|:---| -| Vault 双向链接图遍历 | P1 | `vault_backlinks` 升级为图查询:最短路径、共同引用、引用频次 | -| 笔记变更追踪 | P1 | Vault 笔记历史基于 Git 追踪(vault 目录作为 Git 子模块)或 SQLite 增量历史表 | -| 混合检索质量监控 | P1 | RRF 参数可调(`k`、`weights`)、召回率/精确率指标、`devkit_search_quality` 工具 | -| 笔记块级引用 `[[note#block]]` | P2 | 从文档级粒度下沉到块级;支持标题块、列表块、代码块引用 | -| middleware.ts 错误修复 | P2 | 解决已知未解决错误,见技术债登记簿 | - -#### 🟡 v0.21.0+:外部能力嫁接(不重复造轮子) - -| 任务 | 来源 | 集成方式 | -|:---|:---|:---| -| 多说话人播客/测验生成管道 | Open Notebook | 提取生成模块作为外部 MCP Tool,devbase 提供文档输入 | -| Agent 协作与多 LLM 路由 | SurfSense | 参考 Agent 架构,融入 Clarity 三角色世界模型 | -| 时序观测基础设施 | GreptimeDB | Standalone 模式起步,替代 Prometheus,监控索引和查询健康度 | -| 向量索引统一(远期) | GreptimeDB v1.1 | 评估替代 Tantivy+SQLite 双写架构的可行性 | - -### 技术债关联更新 - -| 债项 | 严重 | 状态变更 | 清理路径 | -|:---|:---:|:---|:---| -| Tantivy+SQLite 双写一致性 | 🟡 | **从长期降级至 v0.19.0 P1** | WAL + 补偿机制 + `devkit_index_health` | -| SQLite 单文件并发 | 🔴 | **新增** | v0.19.0 WAL 模式启用 | -| 查询性能不可观测 | 🔴 | **新增** | v0.19.0 CI 性能基线 + OpLog 延迟指标 | -| Vault 无版本历史 | 🟠 | **新增** | v0.20.0 Git 追踪或增量表 | - -### 决策约束 - -1. **v0.19.0 禁止新增非可靠性相关的 MCP Tool**。所有新增 Tool 必须与存储健康、可观测性、或索引修复直接相关。 -2. **v0.19.0 禁止引入外部数据库依赖**(包括 GreptimeDB、Redis、PostgreSQL)。可靠性加固必须在现有 SQLite + Tantivy 技术栈内完成。 -3. **世界模型研究(Spark/Flink/时序图神经网络)保持独立仓库**,主仓库继续执行"不得引入 Spark/Flink 依赖"红线。 - ---- - -## 架构演进方向:世界模型战略(World Model Strategy) - -> 该章节记录 devbase 从"静态情境编译器"向"动态世界模型"演进的战略认知。 -> 完整推导见 `vault/research/world-model-spark-flink-strategy.md`,精简认知见 `vault/ideas/world-model-cognition-card.md`。 - -### 核心认知 - -devbase 的终极壁垒不是"管理仓库的工具",而是**把静态代码库编译成 AI 可推理的动态世界模型**。 - -当前 devbase 是**静态世界模型编译器**——能把代码库的"当前快照"编译成 AI 可读的符号表征(调用图、知识图谱、Agent Memory),但不具备**时间维度**和**因果维度**的建模能力。 - -### 三层缺口分析 - -| 层级 | 当前能力 | 缺口 | 研究价值 | -|:---|:---|:---|:---:| -| **感知层** | AST、Git 状态、Vault 索引 | 时序演化感知、群体协作行为 | 中 | -| **世界模型层** | 调用图、知识图谱、向量空间 | 动态转移预测、因果结构、反事实推演 | **高** | -| **策略应对层** | 预设 Workflow 规则 | 自动规划、风险预测、基于模型的决策 | **高** | - -### 关键决策原则 - -1. **产品核心**:坚持 Local-first、Rust-native、zero ML runtime。世界模型训练可在云端,**推理必须下沉到本地**。 -2. **技术选型**:Spark/Flink 是可替换的数据工程管道,不是竞争壁垒。 -3. **差异化**:静态→动态的世界模型升级,是学术+工程的双重壁垒。 - -### Spark/Flink 定位 - -从世界模型视角,Spark/Flink 仅处于**数据工程层**: -- **Spark**:批量构建全局代码演化图谱、分布式因果发现(变量 > 10k 时有用) -- **Flink**:实时事件处理、多开发者世界模型同步 - -在单机/小团队场景下,两者均可用 `rayon` + `tokio` + `SQLite WAL` 替代。真正的研究核心在于**时序图神经网络、因果发现、世界模型压缩**,而非分布式框架本身。 - -### 两条验证路径 - -| 路径 | 形式 | 产出 | 与主仓库关系 | -|:---|:---|:---|:---| -| **学术原型** | 独立仓库 `devbase-worldmodel-research` | ICSE/FSE/NeurIPS Workshop 论文 | 复用 devbase AST 逻辑做数据预处理,模型通过 MCP 接入 | -| **求职映射** | 简历话语 | "基于 Spark/Flink 构建代码知识图谱的动态演化分析系统" | 实际支撑:devbase 符号提取 + 独立研究仓库分布式训练 | - -### 待验证假设 - -- [ ] 时序图神经网络能否预测模块缺陷爆发时间窗口? -- [ ] 因果发现算法能否从 git history 提取可靠的干预建议? -- [ ] 世界模型压缩后,本地推理延迟能否控制在 < 100ms? - -### 关联笔记(双向联动) - -| 笔记 | 类型 | 用途 | -|:---|:---|:---| -| `vault/research/world-model-spark-flink-strategy.md` | 完整推导 | 世界模型三层架构、Spark/Flink 定位、研究方向建议 | -| `vault/ideas/world-model-cognition-card.md` | 精炼认知 | 快速查阅:一句话认知、决策原则、反常识洞察 | - -> **认知同步原则**:AGENTS.md 是项目级**约束文档**,Vault 笔记是**探索空间**。若 Vault 中的假设被验证,应反向同步到 AGENTS.md 的"待验证假设"中并打勾;若 AGENTS.md 的决策原则变更,应同步更新 Vault 认知卡片。 - ## 禁止事项 - 不得修改 `dev\third_party\*` 外部仓库 @@ -703,3 +254,5 @@ devbase 的终极壁垒不是"管理仓库的工具",而是**把静态代码 - 不得引入已 deprecated 的协议 - **不得在主仓库引入 Spark/Flink 依赖**(研究性质代码必须置于独立仓库,保持主仓库轻量) - **不得在任何源码文件中硬编码真实 token、api_key 或密码**(包括注释和测试数据) + +> 完整版(含历史记录、路线图、详细讨论):见 docs/AGENTS-full.md diff --git a/docs/AGENTS-full.md b/docs/AGENTS-full.md new file mode 100644 index 0000000..e72fc34 --- /dev/null +++ b/docs/AGENTS-full.md @@ -0,0 +1,705 @@ +# Agent 环境指引 + +`devbase` 是 **本地情境编译器(Local Context Compiler)** —— AI agent 在本地数字世界中的海马体。 + +> 它将本地数字资产的原始数据(代码库、笔记、Skill、工作流)编译为 AI 可决策的结构化情境,不负责思考,不负责执行,只负责感知、编码、持久化、检索。 + +- **当前阶段**:阶段十一 — v0.20.0 已发布(知识完备性) +- **当前版本**:v0.20.0(Schema 34,68 MCP tools,451 tests) +- **已完成里程碑**:Registry God Object 完全拆解(10 子模块提取)+ 18 workspace crates 提取 + MCP Python SDK 1.16.0 兼容修复 + repo.rs trait 化 + flaky 测试根治(RF-2.1/2.2/2.3)+ 许可证迁移 + health 性能优化(-44%)+ index skip-embeddings + batch encoding 实验 + RF-6 清零 + 架构治理文档(ADR/不变量清单)+ Tantivy BM25 代码符号搜索(P1)+ AppContext 职责拆分 Phase 1/2(storage.rs 860→430 行)+ 架构不变量 CI(G5/T11/T12)+ Embedding 多后端(Candle/Ollama 配置切换, P3)+ EnvVersionCache 扩展(9 工具链检测, P4)+ **v0.16.0 Agent Contexts(P1/P2/P3)**:`agent_contexts`/`agent_memories`/`context_entity_links` Schema + 9 个 Session MCP tools + Context-aware Skill Runtime(`DEVBASE_ACTIVE_CONTEXT` 注入)+ **v0.16.1 Workflow-Session Binding**:`workflow_executions.context_id` + 执行自动绑定 Active Context + **v0.17.0 Embedding Externalization**:`embedding` 从 default features 移除(Candle/Ollama 降级为 opt-in `llm-backend`)+ Schema 34 向量存储 + `cosine_similarity` SQLite UDF + `devkit_session_recall` / `devkit_session_index`(60 tools)+ **v0.18.0 ClaudeCode Integration**:`devkit_project_brief`(Markdown 项目简报)+ `devkit_impact_analysis`(修改影响范围分析)+ `devkit_session_export` / `devkit_session_import` + `scripts/devbase-claude.ps1` 启动器(自动注入 `.claude/CLAUDE.md`)+ RFC `docs/RFC/claudecode-workflow-integration.md`(64 tools)+ **v0.18.0 发布收尾**:PR 合并 + 双平台二进制构建 + GitHub Release + 根目录治理 + 世界模型战略认知沉淀(Vault + AGENTS 双向联动)+ NotebookLM 生态消化(5 项目注册)+ GreptimeDB 互补分析 + **v0.19.0 知识基础设施硬化**:SQLite WAL 默认启用 + `devkit_index_health`(Beta)+ Vault 导出(`devkit_vault_export`)+ Redis ADR 决策(放弃引入)+ **v0.20.0 知识完备性**:Vault 双向链接 BFS 图遍历(`devkit_vault_graph` 扩展)+ Vault Git-based 历史追踪(`devkit_vault_history`,第 67 个 tool)+ 混合检索质量监控(`devkit_search_quality`,第 68 个 tool,`HybridSearchMetrics`)+ Block 引用支持(`WikiLink.anchor`:`[[note#heading]]` / `[[note#^block-id]]`)+ 性能回归基线(`#[ignore]` 1k/10k 阈值测试)+ 客户端无关原则(Client-Agnostic Principle)落地 + `skill sync` 泛化接口(零硬编码客户端路径) +- **核心方向**:让 Kimi CLI 在调用文件工具之前,先通过 devbase 获得"该读哪些文件、为什么读、它们之间的关系" +- **本质分析**:见 `vault/99-Meta/devbase-essence-analysis-20260430.md` 与 `docs/architecture/redefinition.md` +- **设计文档**: + - [`docs/architecture/workflow-dsl.md`](docs/architecture/workflow-dsl.md) — Workflow DSL 规范 + - [`docs/architecture/workspace-as-schema.md`](docs/architecture/workspace-as-schema.md) — 统一实体模型设计 + - [`docs/RFC/agent-memory-vector-storage.md`](docs/RFC/agent-memory-vector-storage.md) — v0.17.0 Agent Memory 向量存储 RFC(Embedding 职责外迁设计) + - [`docs/guides/mcp-integration-guide.md`](docs/guides/mcp-integration-guide.md) — MCP 集成指南 + - [`docs/README.md`](docs/README.md) — 完整文档导航 + +Skill Runtime 全生命周期已落地(含依赖管理 Schema v15),Schema v16 统一实体模型(entities/relations)已落地,Skill 自动封装(`discover`)已落地。 + +- **技术栈**:Rust 2024, SQLite, tokio, ratatui, git2, reqwest, tantivy +- **Registry DB**:`%LOCALAPPDATA%\devbase\registry.db`(轻量索引,用户本地,永不进入版本控制) +- **Workspace**:`%LOCALAPPDATA%\devbase\workspace/` —— 文件系统 = source of truth + - `vault/` —— PARA 结构:00-Inbox, 01-Projects, 02-Areas, 03-Resources, 04-Archives, 99-Meta + - `assets/` —— 二进制资源 +- **MCP Server**:stdio only,**68 个 tools**(含 7 个 vault tools + 8 个代码分析工具 + 5 个 embedding/搜索工具 + 4 个 Skill Runtime tools + 3 个 Workflow/评分 tools + 1 个报告工具 + 1 个 arXiv 工具 + 2 个 KnownLimit tools + 3 个 Relation tools + 11 个 Agent Context tools + 2 个 ClaudeCode 集成工具 + 1 个 streaming index 工具 + 1 个 oplog 工具 + 1 个 Index Health 工具 + 1 个 Search Quality 工具 + 1 个 Evaluate 工具);配置见 `mcp.json` +- **Kimi CLI 集成**:MCP server 已通过 `kimi mcp add` 注册,端到端验证通过(`kimi --print` 成功调用 `devkit_health`);项目级 skill 位于 `.kimi/skills/devbase-project/SKILL.md` +- **统一节点模型**:`core::node::{Node, NodeType, Edge}` —— GitRepo / VaultNote / Asset / ExternalLink +- **当前测试**:451+ lib passed / 0 failed / 5 ignored + 11/11 integration passed(`tests/cli.rs`) +- **编译状态**:0 warning / 0 vulnerabilities(`cargo audit` 干净,除上游 `tokei` 的 `RUSTSEC-2020-0163`) +- **Workspace 结构**:`crates/` 目录已启用,19 个零耦合模块已提取为独立 crate(`devbase-symbol-links`, `devbase-sync-protocol`, `devbase-core-types`, `devbase-syncthing-client`, `devbase-vault-frontmatter`, `devbase-vault-wikilink`, `devbase-workflow-interpolate`, `devbase-workflow-model`, `devbase-registry-health`, `devbase-registry-metrics`, `devbase-registry-workspace`, `devbase-embedding`, `devbase-skill-runtime-types`, `devbase-skill-runtime-parser`, `devbase-registry-entity`, `devbase-registry-relation`, `devbase-registry-call-graph`, `devbase-registry-dead-code`, `devbase-registry-code-symbols`) +- **Workflow Engine**:YAML 解析 + 拓扑调度 + batch 并行执行 + 5 种 step 类型(skill/subworkflow/parallel/condition/loop) +- **NLQ 自然语言查询**:TUI `[:]` 触发 embedding 语义搜索,fallback 降级文本搜索 +- **Mind Market 评分**:success_rate / usage_count / rating(0-5),`skill recalc-scores/top/recommend` + +## 关键约定 + +1. **文件操作**:读取用 `ReadFile`,搜索用 `Grep`/`Glob`,修改用 `StrReplaceFile`,整文件重写用 `WriteFile` +2. **Shell**:Windows PowerShell;用 `;` 分隔命令 +3. **Git**:提交前必须通过 `cargo test --all-targets` + `cargo clippy --all-targets -D warnings` + `cargo fmt --check` +4. **Schema 迁移**:`PRAGMA user_version` 安全升级;升级前自动调用 `backup::auto_backup_before_migration()` + +## 安全原则 + +### 本地优先(Local-First) + +- **Registry DB** 始终存储在用户的本地配置目录(`dirs::config_dir()/devbase/`),绝不向远程传输 +- **代码内容** 不会被上传到任何云端服务(除非用户显式配置 GitHub token 用于 stars 查询) +- **MCP Server** 仅通过 stdio 本地进程通信,不暴露网络端口 + +### 客户端无关(Client-Agnostic) + +> devbase 的核心能力(编排、注册、索引、搜索、同步)必须在不依赖任何特定 AI 客户端的前提下独立运行。 + +- ✅ **允许**:向通用目录输出数据,由用户自行分发给任意客户端(如 `skill sync --output-dir ./plans`) +- ✅ **允许**:实现标准协议(MCP)供任意客户端连接 +- ❌ **禁止**:核心能力硬编码特定客户端的路径、API、或配置格式(如 `C:\Users\xxx\.claude`) +- ❌ **禁止**:核心能力的可用性取决于某个客户端是否安装 +- 🟡 **适配层**:`scripts/claude/`、`docs/clients/` 等目录下的客户端适配脚本属于配套示例,不归入核心版本控制 + +### 凭证管理 + +- GitHub token、LLM API key 存储在本地 `config.toml` 中 +- `config.toml` 位于用户配置目录,**不在项目工作目录**,因此不会被意外 `git commit` +- 默认配置模板中的 token 字段使用占位符 ``,避免真实 token 格式泄露 +- `.gitignore` 已覆盖 `*.db`、`.devbase/`、`.env*`、`*.local.toml` + +### 审计与备份 + +- 所有 `scan`/`sync`/`health` 操作自动写入 OpLog(SQLite `oplog` 表) +- Schema 迁移前自动生成 `backup-YYYYMMDD-HHMMSS.db` 快照 +- Registry 支持 `export`/`import` 用于用户自主备份 + +## 许可证策略 + +- **主许可证**: AGPL-3.0-or-later (`LICENSE`) +- **商业授权**: 双许可模式,闭源/专有 SaaS 使用需联系作者 (`LICENSE-COMMERCIAL.md`) +- **Cargo.toml**: `license = "AGPL-3.0-or-later"` +- **SPDX 头**: 新增源文件应在顶部包含 AGPL-3.0 声明(见 `LICENSE` 末尾 "How to Apply" 部分) + +## 架构状态(Wave 15b 完成) + +| 维度 | 状态 | +|------|------| +| 代码质量 | `rustfmt.toml` + `cargo fmt` + `clippy -D warnings` 全绿 | +| 模块拆分 | `sync`→5 / `registry`→11 / `mcp` 测试分离 / `search`→hybrid / `oplog_analytics` / `symbol_links` / **workspace: 3 crates extracted** | +| 库/二进制 | `src/lib.rs` 导出全部 **30+** 个模块;`src/main.rs` 仅 CLI 入口 | +| TUI 架构 | `render/` 6 子模块 + `theme.rs` Design Token + `layout.rs` 响应式引擎 | +| 数据层 | Schema v23: `repos`/`vault_notes`/`papers`/`workflows`/`repo_modules_legacy` 表已删除;`entities` 为唯一数据源;`repo_tags/repo_remotes/repo_health/...` 为独立 JOIN 表(无 FK);仅 `skills` 保留独立表(embedding BLOB) | +| CI/CD | `.github/workflows/ci.yml`:check / test / fmt / clippy on Windows | +| 依赖安全 | `cargo audit` 0 漏洞(除上游 `tokei` 的 `RUSTSEC-2020-0163`) | + +## 架构红线(Architecture Guardrails) + +> 基于第一性原理的工程约束。违反任意一条 = HALT,转交人类裁决或回滚。 +> 规则编号 `RF-XX`(Red-line / Fitness function),带客观测量标准,非主观描述。 + +### RF-1: 依赖注入优于全局状态(Global State Anti-Pattern) + +**理论锚定**:全局可变状态使组件隐式耦合,破坏可测试性与可复用性(参考:Pure Function / DI 原则)。 + +**规则**: +- 禁止新增 `dirs::data_local_dir()` / `std::env::var_os` 硬编码路径。 +- 所有 IO 边界路径(DB、索引、备份、配置)必须通过参数、构造函数或 `trait` 注入。 +- **例外(Grandfathered)**:现有 3 处(`backup_dir`、`db_path`、`index_path`)在重构前不得新增第 4 处。 + +**Fitness Function**: +```bash +# 新增 PR 中不得出现新的全局路径硬编码 +grep -rn "dirs::data_local_dir\|std::env::var_os\|std::env::var(\"LOCALAPPDATA\"" src/ \ + | grep -v "backup.rs\|migrate.rs\|search.rs" +# 预期输出:空 +``` + +### RF-2: 测试密封性(Hermetic Testing) + +**理论锚定**:测试失败必须仅因被测代码缺陷,不因外部因素、测试顺序或并行调度(参考:Google Test Blog — Hermetic Servers)。 + +**规则**: +- 所有测试禁止修改全局进程状态(`std::env::set_var`、`static mut`、全局文件系统句柄)。 +- 文件系统测试必须使用 `tempfile` + 注入式路径,禁止直接操作 `%LOCALAPPDATA%` 或 `~/.config`。 +- Tantivy / SQLite 文件系统测试必须获取 `SEARCH_TEST_LOCK`(或同等级串行化机制)。 + +**子规则(来自 PR #4 教训)**: +- **R2.1 禁止 `DEVBASE_DATA_DIR` 全局注入**:并行测试中 `std::env::set_var("DEVBASE_DATA_DIR", ...)` 导致竞态;必须使用 `TempStorageBackend` 注入式替代。 +- **R2.2 Windows 路径双端规范化**:`TempDir` 可能返回短文件名(`TEMP~1`),而 `dunce::canonicalize` 返回长文件名;路径比较前必须对**双方**调用 `dunce::canonicalize`。 +- **R2.3 `git2` 测试显式身份 + 显式分支**: + - CI runner 无全局 `user.name`/`user.email` → `repo.signature()` 会 panic;必须改用 `git2::Signature::now("Test", "test@example.com")`。 + - `git2::Repository::init` 的默认分支在不同平台可能为 `master` 或 `main`;必须显式 `repo.set_head("refs/heads/main")` 并 commit 到 `"refs/heads/main"`。 + +**Fitness Function**: +```bash +# 高并发下 100% 通过,无 flaky +cargo test --test-threads=16 +``` + +### RF-3: Schema 单一事实来源(Single Source of Truth) + +**理论锚定**:重复信息必然 drift(参考:DRY 原则 + Evolutionary Architecture 的版本一致性约束)。 + +**规则**: +- `SCHEMA_DDL`(`registry/test_helpers.rs`)与 `migrate.rs` 必须原子同步。 +- 新增表、索引、列必须同时出现在两者中;禁止仅更新其一。 + +**Fitness Function**: +- CI 运行 `test_in_memory_schema_version` + schema 结构比对脚本(可手动运行 `cargo test registry::test_helpers::tests` 验证)。 + +### RF-4: 二进制入口限界(Bounded Context) + +**理论锚定**:CLI 入口应仅做命令分发,业务逻辑应在 lib 模块中(参考:Hexagonal Architecture / Ports & Adapters)。 + +**规则**: +- `main.rs` 行数不得超过 **1000 行**。 +- 新增 CLI 命令必须先拆分为 `commands/` 子模块或独立函数,禁止在 `main.rs` 中堆积业务逻辑。 + +**Fitness Function**: +```bash +# 当前 515 行(Phase 1/2/3 已削减 1003 行),远超目标 +[ $(wc -l < src/main.rs) -le 1000 ] || exit 1 +``` + +### RF-5: 无循环依赖(Acyclic Dependencies) + +**理论锚定**:循环依赖破坏模块化,使增量编译和独立复用不可能(参考:John Lakos — Large-Scale C++ Software Design)。 + +**规则**: +- 禁止模块间双向 `use crate::` 引用。 +- 新增模块必须通过脚本验证无循环(当前已满足,未来 PR 保持)。 + +**Fitness Function**: +```bash +# 文件级双向依赖检测(当前输出应为空) +for f in src/**/*.rs; do + name=$(basename "$f" .rs) + refs=$(grep -o 'use crate::[a-z_]*' "$f" | sed 's/use crate:://') + for r in $refs; do + if [ -f "src/$r.rs" ] && grep -q "use crate::$name\b" "src/$r.rs"; then + echo "CYCLE: $name <-> $r" + fi + done +done +``` + +### RF-7: Workspace 拆分约束(Module Distribution Readiness) + +**理论锚定**:模块能否独立发布是耦合健康度的金标准;不能拆分的模块 = 耦合不健康的模块。 + +**规则**: +- 新增模块若对 devbase 内部其他模块的 `crate::` 引用超过 **5 个**,禁止提取为 workspace crate。 +- 已提取 crate 的重新导出文件(`src/symbol_links.rs` 等)**禁止添加新代码**——顶部有 `RE-EXPORT ONLY` 注释作为守卫。 +- 子 crate 的依赖版本必须与 workspace 统一,禁止独立 bump。 + +**Fitness Function**: +```bash +# 扫描所有 src/*.rs,统计 crate:: 引用数 +for f in src/*.rs; do + count=$(grep -c 'crate::' "$f") + if [ "$count" -gt 15 ]; then + echo "HIGH COUPLING: $f ($count refs)" + fi +done +# 预期输出:空(或仅已标记的高耦合文件如 mcp/tools/repo.rs) +``` + +### RF-6: 生产代码无 panic(Crash-only Software) + +**理论锚定**:Rust 的 `Result` 类型将错误显式化;`unwrap` 是将运行时崩溃隐藏在类型系统背后(参考:Joe Armstrong — Let it crash,但 Rust 中崩溃 = 进程终止,不可接受)。 + +**规则**: +- 生产代码(`src/**/*.rs` 中不在 `#[cfg(test)]` 块内的代码)禁止 `unwrap()`、`expect()`、`panic!()`。 +- 测试代码不受此限,但鼓励使用 `?` 传播。 + +**Fitness Function**: +```bash +# 生产代码 unwrap 计数(排除 #[cfg(test)] 块及 tests.rs 文件) +for f in $(find src -name "*.rs"); do + test_line=$(grep -n "#\[cfg(test)\]" "$f" | head -1 | cut -d: -f1) + if echo "$f" | grep -qE "tests?\.rs$|_test\.rs$|/tests/"; then continue; fi + if [ -n "$test_line" ]; then + head -n "$((test_line - 1))" "$f" | grep -n "\.unwrap()" + else + grep -n "\.unwrap()" "$f" + fi +done +# 预期输出:空 +``` + +**状态**:🟢 **已完成**(v0.20.1 复核:生产代码 unwrap = 0;此前 1090 为测试模块误统计)。 + +### 架构治理框架(Architecture Governance) + +> 参考:外部架构治理方法论(Kimi 会话 `e9f2965f-b949-46a5-9d7c-afd6d4d9232c`) + +**已制度化实践**: + +| 实践 | devbase 落地形式 | 文档位置 | +|------|-----------------|---------| +| ADR(架构决策记录) | ADR-001(单 crate defer)、ADR-002(batch encoding 回滚) | [`docs/architecture/adr-template.md`](docs/architecture/adr-template.md) | +| 不变量清单(Invariants) | RF-1~RF-7 + 分层模块约束(T01–T12) | [`docs/architecture/invariants.md`](docs/architecture/invariants.md) | +| 模块提取演习 | RF-7 的 5 个 `crate::` 引用阈值 + 已提取 18 workspace crates | 本文件 §RF-7 | +| 三层摘要 | `crates/*/README.md` 要求:一句话 + 一页纸 + 深度链接 | 各 crate README | +| 定期架构回顾 | 每次 Wave 结束时的架构审计(见 `docs/_audit/`) | `docs/_audit/2026-04-26-*.md` | + +**待增强**: +- 三层摘要:部分已提取 crate 的 README 尚未达到"一页纸"标准 +- 定期架构回顾:当前按 Wave(功能迭代周期)触发,建议每 2–4 周增加一次纯架构 review(不看 feature 进度,只看不变量违反和隐式依赖) + +--- + +## 技术债登记簿(Technical Debt Ledger) + +> 已识别的架构债,按严重程度排序。清偿前不得新增同类债务。 + +| 债项 | 严重 | 当前值 | 目标阈值 | 清理路径 | 引入 Wave | +|---|---|---|---|---|---| +| `main.rs` 上帝文件 | 🟢 | 778 行 | ≤1000 行 | 拆分为 `commands/simple.rs` + `commands/skill.rs` + `commands/workflow.rs` + `commands/limit.rs`;全部 22 个命令/子命令树已迁移 | ≤15 | +| Workspace crate 版本号混乱 | 🟢 | **已完成**:全部 19 个 crate 统一为 `version.workspace = true`,workspace 版本 `0.20.0` | 全部统一为 `version.workspace = true` 或 `0.20.0` | 批量修正 `Cargo.toml` | v0.20.1 | +| RF-7 高耦合模块超标 | 🟢 | **已完成**:`scan.rs` 18→7、`digest.rs` 17→5、`registry.rs` 20→4;全部 `src/*.rs` ≤ 15 `crate::` 引用 | ≤15 | use 语句规范化消除 self-reference;`registry.rs` 保留 4 个(2 use + 2 dependency_graph)作为 facade | v0.20.1 | +| `init_db()` 全局路径 | 🟢 | `AppContext` 已集成到全部 commands/ 模块;`main()` 通过 `AppContext` 分发配置;`init_db()` 无外部调用 | 0 | 已完成:`StorageBackend` trait + `AppContext` 全面替代;`db_path`/`workspace_dir`/`index_path`/`backup_dir` 已统一 | ≤15 | +| Tantivy+SQLite 双写一致性 | 🟡 | 无事务协调;**已添加反向检测**(`repair_tantivy_consistency` 现在检测 SQLite→Tantivy 缺失) | 补偿机制 | 长期:事务协调或 SQLite FTS5 替代;短期:反向检测 + 日志已落地(`fe14c81`) | 7 | +| 主从表切换 | 🟢 | Phase 1 全部完成:`repos` 表已删除,entities 为唯一数据源 | `entities` 为第一公民 | Phase 2 类型系统开放(新增 entity_type 无需改表结构) | v0.12.0 | +| vault/paper/workflow entities 缺口 | 🟢 | Stage C+D+E 全部完成:`vault_notes`/`papers`/`workflows` 表已删除,`skills` 保留(embedding BLOB) | 0 缺口 | — | v0.12.0 | +| scan 路径排除 | 🟢 | `discover_repos` + `collect_tasks` 均支持 `scan.exclude_paths`;scan 和 sync 双阶段过滤 | 0 缺口 | 排除路径使用 `Path::starts_with` 组件级匹配,避免字符串前缀误杀;相对路径在 sync 场景(无 root)下被忽略 | v0.12.0 | +| tree-sitter 编译成本 | 🟢 | ~15-20s grammar C compilation | 可控 | 已完成 feature-gate:`lang-rust`/`lang-python`/`lang-js-ts`/`lang-go` 四个 feature,默认全启,可选关闭减少编译;`--no-default-features` 编译通过 | 8 | +| Feature flags 缺失 | 🟢 | 4 个可选 feature (tui, watch, mcp, embedding) | ≥3 | 已完成:`tui`/`watch`/`mcp`/`embedding` 均为 optional;`--no-default-features` 编译通过 | ≤15 | +| Vault 无版本历史 | 🟢 | `devkit_vault_history` + git2 revwalk + blob diff 行级统计 | 历史可回溯 | 用户侧将 vault 目录作为 Git 子模块管理 | v0.20.0 | +| `LOCALAPPDATA` 测试模式残留 | 🟢 | 0 处 | 0 | 全面废弃 `LOCALAPPDATA` 环境变量覆盖,统一为 `DEVBASE_DATA_DIR`;mcp/tests.rs 修复 cleanup 逻辑(remove_var 目标从 LOCALAPPDATA 修正为 DEVBASE_DATA_DIR) | 47 | +| 单体职责膨胀(代码智能+知识库+仓库管理+工作流+Skill+Syncthing) | 🟡 | 6 个核心领域耦合于单一二进制(31MB);`workflow`/`skill` 与 Claude Code Agent 能力重叠 | 按领域拆分为 `devbase-core`(代码+vault)+ `devbase-sync`(仓库管理)+ `devbase-bridge`(Syncthing);冻结 workflow/skill 新增 | 外部审查 2026-05-11 | + +**清偿原则**: +1. 禁止在清偿现有 🔴 债务前新增同类别债务。 +2. 每个债务必须关联至少一个 `TODO(#)` 或 `FIXME` 代码注释。 +3. 每季度(90 天)由 MODE-O 审查一次,更新当前值与优先级。 + +--- + +## 历史 Waves + +| Wave | 主题 | 关键产出 | Commit | +|------|------|---------|--------| +| 42-44 | 测试基础设施 | 22 个 smoke tests, CLI 集成测试层 (`tests/cli.rs`), Criterion 基准测试 (`benches/registry_bench.rs`) | — | +| 45-47 | Tier 1 测试收尾 | 28 个 smoke tests 覆盖 embeddings/semantic_search/cross-repo/search/workflow/backup/registry;`SCHEMA_DDL` 补录 4 表;`init_db()` 并发安全 (`BEGIN EXCLUSIVE`);测试数据隔离统一为 `DEVBASE_DATA_DIR` | — | +| 1 | 代码质量 | `rustfmt.toml`, clippy 清零 | `4efcd58` | +| 2 | 模块拆分 | `sync/`, `registry/`, `mcp/tests.rs` | `4efcd58` | +| 3 | 工程化 | `src/lib.rs`, CI workflow, `main.rs` 简化 | `4efcd58` | +| 4 | 依赖/审计 | `notify` 8.2.0, `tokei` 14.0.0 | `4efcd58` | +| 5 | TUI 美学与工程学 | 主题系统, Tabs, Help Overlay, Render 拆分 | `6b9be88` | +| 6 | 数据层深度能力 (MVP) | 语义索引、调用图、依赖图、死代码检测、Python 依赖解析 | `9fbf7c4` | +| 7 | 向量语义搜索 | `embedding.rs`, `code_embeddings` 表, `devkit_semantic_search` | `4d400b1` | +| 8 | 多语言符号提取 | tree-sitter-python/typescript/go, Rust/Python/JS/Go 符号 + Call Graph | `4f4911b` | +| 9 | scan panic 修复 + arXiv/CMake | `block_on_async` 安全封装, arXiv API 元数据, CMakeLists.txt 依赖解析 | `881cd32` | +| 10 | OpLog 结构化 | Schema v12, OplogEventType 枚举, JSON metadata, duration_ms | `7aa2a65` | +| 11 | 性能基准 | criterion benches: index_repo_full, cosine_similarity, extract_symbols, CMake | `8e0f236` | +| 12 | 混合检索核心 | `search::hybrid.rs`: RRF 归并, keyword_search, hybrid_search_symbols | `7fca714` | +| 13 | 外部 Embedding Provider | Python CLI `tools/embedding-provider/`, Ollama 批量生成, 字节兼容序列化 | `574fb96` | +| 14a | 跨 repo 语义聚合 | `cross_repo_search_symbols()` INTERSECT tag 过滤, `devkit_cross_repo_search` | `8e762c7` | +| 14b | 知识覆盖报告 | `oplog_analytics.rs`: 表存在性容错, 覆盖度/健康度/活动流, `devkit_knowledge_report` | `869bcbf` | +| 15a | 显式知识链接 | Schema v13 `code_symbol_links`, Jaccard 签名相似度, 同文件聚类, `devkit_related_symbols` | `d462209` | +| 15b | 混合检索 MCP Tool | `devkit_hybrid_search`: 向量+RRF+关键词自动降级, 推荐默认搜索入口 | `6df6106` | +| 16a | Skill Runtime Schema | `skills` + `skill_executions` 表, SKILL.md 解析器, Registry CRUD, 3 内置 skills | `e41eccb` | +| 16b | Skill 发现与搜索 | 文本搜索 + 语义搜索 (`--semantic`), skill embedding 生成脚本 | `48b96c6` | +| 17 | Skill 执行引擎 | Process-based executor, interpreter 自动解析, timeout, stdout/stderr 捕获, 执行审计 | `99d818e` | +| 18 | MCP Skill 集成 | `devkit_skill_list` / `devkit_skill_search` / `devkit_skill_run` 3 个 tools | `c80fdec` | +| 19a | Skill 生态(安装/发布) | `install_skill_from_git` (git2 clone), `publish` (validate + git tag + push remote) | `8120e4d` | +| 19b | Skill 生态(同步/TUI) | `sync --target clarity` (导出为 Clarity plan JSON), TUI Skill Panel (`k` keybinding) | `678c70c` | +| 20 | Skill 依赖管理 | Schema v15 `dependencies` 列,Kahn 拓扑排序,DFS 环检测,自动安装缺失依赖,`install`/`run`/`validate` 集成 | `75fed3c` | +| 21 | 统一实体模型 + 自动封装 | Schema v16 `entities/entity_types/relations`,渐进双写;`discover` 命令(Rust/Node/Python/Go/Docker/Generic 检测 + SKILL.md 自动生成 + entry_script 包装器);分类推断(ai/dev/data/infra/communication) | — | +| 22 | AppContext Pool 化 | `r2d2::Pool` 替代单 Connection;22 个 commands/TUI/MCP 全链路迁移;`init_db()` 89→5 处;MCP 测试临时目录隔离;search 多线程竞态自愈 | — | +| 23 | Registry God Object 拆解 | 提取 10 子模块(repo/vault/workspace/health/metrics/links/known_limits/knowledge_meta/knowledge)为 free-function 模块;`WorkspaceRegistry` 退化为向后兼容门面;~150 处调用点迁移;0 测试回归 | `dfc43d4` | + +## 敏感文件清单(禁止提交) + +| 文件/模式 | 原因 | .gitignore 覆盖 | +|-----------|------|----------------| +| `*.db` | SQLite 数据库含用户仓库元数据 | ✅ | +| `.devbase/` | 本地 sync 标记和工作区状态 | ✅ | +| `*.log` | 可能含路径或错误堆栈信息 | ✅ | +| `.env*` | 环境变量和 secrets | ✅ | +| `*.local.toml` | 本地覆盖配置 | ✅ | +| `target/` | 构建产物 | ✅ | + +## 跨项目接口 + +- **clarity-core**:已解除路径依赖。devbase 不再被 clarity-core 调用,LLM 能力内联为纯 reqwest +- **syncthing-rust**:`.syncdone` 标记格式已对齐 + +## 架构讨论摘要(来自 2026-04-24 会话) + +以下为本项目相关的粗粒度架构决策与待探索方向。 + +### 1. 自指知识库:五层知识模型 + +devbase 作为知识库存储层,需支持 L0-L4 五层索引: + +| 层级 | 内容 | 生长信号 | 遗忘机制 | +|------|------|---------|---------| +| L0 对象 | 外部知识块(代码、文档、日志) | 检索频率、引用次数 | 版本冻结 | +| L1 方法 | 操作知识的方法(检索/分块/向量化) | 检索成功率、延迟分布 | A/B 测试 | +| L2 哲学 | 设计原则(本地优先、奥卡姆剃刀) | 架构决策事后验证 | 外部论文扰动 | +| L3 风险 | 系统弱点图谱 | 故障事件、异常日志 | 红队攻击 | +| L4 元认知 | 关于 L1-L3 的元知识 | 人类纠正、跨会话一致性 | 形式化验证 | + +**决策**:粗粒度与细粒度知识保留独立索引;细粒度存 SQLite(快速查询),粗粒度存 Vector DB(语义检索)。 + +### 2. 审计日志(OpLog) + +- P3 不可靠交付的使用追踪写入 OpLog,实现事后追溯 +- 边界图谱版本历史、探索任务结果写入 OpLog +- 所有验证消息(请求+响应+共识)写入 OpLog + +### 3. 外部资源调度器 + +devbase 承载外部资源调度的抽象接口: + +- **形式化工具**:TLA+/Coq/Lean(本地路径或远程地址) +- **人类专家**:异步审批,不阻塞夜间批处理 +- **P2P 节点**:复用 syncthing-rust 的 Device ID 与传输层 +- **文献检索**:arXiv / Semantic Scholar API + +**决策**:定义资源请求的抽象接口与排队策略;具体调度算法不进当前 scope。 + +### 4. 边界图谱存储 + +- `BoundaryMap` 存储已知限制(KnownLimit)的版本历史 +- `ExplorationTask` 队列记录边界外待探索任务 +- 跨实例同步:通过 syncthing-rust P2P 网络同步边界快照 + +### 5. 安全计算(MPC/TEE) + +- 当前四个项目中无密码学层归属 +- **短期**:devbase MCP 接口可封装外部 TEE 服务(如 Azure Confidential Computing) +- **长期**:如需自建,新建 `clarity-tee` 或 `devbase-secure` 子项目 + +## 当前阶段待办(v0.15.0 推进中) + +v0.11.3 已交付(tagged)。v0.12.0-alpha 全部功能已完成,进入发布治理阶段。 + +| 方向 | 状态 | 说明 | +|------|------|------| +| `init_db()` → `AppContext` 迁移 | 🟢 | Pool 化完成,`init_db()` 从 89 处降至 5 处合法保留,全部 commands/TUI/MCP 已接入 | +| Tantivy+SQLite 双写一致性 | 🟡 | 无事务协调,需补偿机制或 FTS5 替代评估 | +| tree-sitter 编译成本 | 🟡 | ~15-20s,评估 ccache 或 grammar 预编译 | +| Feature flags 扩展 | 🟡 | 2/3(tui, watch),mcp 等模块待评估 | + +--- + +## 历史完成记录(v0.4.0 – v0.10.0) + +### 阶段二任务(v0.4.0 AI Skill 编排基础设施) + +| 波次 | 任务 | 状态 | 交付物 | +|------|------|------|--------| +| Wave 21 | Schema v16 + 自动封装 | ✅ 已完成 | `entity_types/entities/relations` + `devbase skill discover` | +| Wave 22 | discover 硬化 | ✅ 已完成 | `--install` 真正注册 + Git URL 直接克隆封装 | +| Wave 23 | Workflow 预留 | ✅ 规范已完成 | `docs/architecture/workflow-dsl.md` | +| Wave 24 | Workflow Engine v0.5.0 | ✅ 已完成 | YAML 解析 + 拓扑调度 + batch 并行执行 + 5 step 类型 | +| Wave 25 | TUI Workflow 可执行 | ✅ 已完成 | `[w]` 详情页 `r/Enter` 运行 + 结果弹窗 | +| Wave 26 | NLQ 自然语言查询 v0.7.0 | ✅ 已完成 | `[:]` 触发 embedding 语义搜索 + fallback 降级 | +| Wave 27 | Mind Market 评分 v0.6.0 | ✅ 已完成 | `success_rate`/`usage_count`/`rating` + `recalc-scores`/`top`/`recommend` | +| Wave 28 | 7 个风险点修复 v0.7.1 | ✅ 已完成 | EnvGuard、NLQ fallback、StepType 显式标签、跨平台解释器探测 | +| Wave 29 | Workflow 子类型执行 v0.8.0 | ✅ 已完成 | Subworkflow 递归 + Parallel 聚合 + Condition 表达式求值 | +| Wave 30 | 生产代码 unwrap 清零 | ✅ 已完成 | 29 个生产代码 unwrap → 0,`cargo clippy -D warnings` 全绿 | +| Wave 31 | NLQ 结果可执行 v0.8.1 | ✅ 已完成 | `[:]` 搜索结果按 Enter 直接运行 skill,event+state+render 三文件修改 | +| Wave 32 | NLQ smoke test | ✅ 已完成 | `run_nlp_selected_skill` 空列表/无技能/执行管道测试,267 tests passed | +| Wave 33 | TUI SkillPanel 拆分 | ✅ 已完成 | 7 个 skill 字段提取到 `SkillPanelState`,App 51→44 字段 | +| Wave 34 | Workflow Loop Step 硬化 | ✅ 已完成 | `StepType::Loop { body }` + `execute_loop_step` + `${loop.item}` / `${loop.index}` | +| Wave 35 | L3 Risk Layer MVP | ✅ 已完成 | Schema v18 `known_limits` + Registry CRUD + MCP tools + CLI `limit` + OpLog 集成 | +| Wave 36 | L4 元认知层 MVP | ✅ 已完成 | Schema v19 `knowledge_meta` + Registry CRUD + `--reason` resolve + L3-L4 联动 | +| Wave 37 | Hard Veto 运行时守卫 | ✅ 已完成 | `skill_runtime::executor` 执行前检查未解决 hard veto,警告注入 stderr + OpLog 审计 | + +### 明确不做(已排除) + +- SSE transport(stdio 已覆盖主流 Client) +- `.devbase` 目录规范(无外部采纳者) +- MCP 协议扩展提案(Star = 0,不会被采纳) +- 商业化 / 付费版 +- ~~拆分 crate(50+ tools 后再评估)~~ → **重新评估**:已触发外部架构审查(§九 耦合检查,6 领域耦合),`workflow`/`skill` 与 Claude Code Agent 重叠,v0.16.0 需输出拆分方案(`devbase-core` / `devbase-sync` / `devbase-bridge`) + +### Future / Icebox(无排期) + +1. ~~输出 L0-L4 五层知识的 TOML/JSON Schema 草案~~(保持开放,非阻塞) +2. ~~输出 OpLog 审计事件类型清单~~(已有基础枚举,保持增量) +3. ~~输出外部资源调度的请求格式草案~~(保持开放) +4. **不做**:调度算法、边界图谱引擎、哲学规则库内容、密码学协议 + +### Post-Wave 19 triage 结论(2026-04-25) + +| 优先级 | 事项 | 状态 | +|--------|------|------| +| P1 | SSE 传输状态与 README 一致性 | ✅ 已完成 — README 修正为 "stdio only; SSE in development",见 commit `935dd61` | +| P2 | 架构预拆分评估 | ✅ 已完成 — 评估报告位于 `docs/architecture/pre-split-evaluation.md`,结论:22.7 KLOC 单 crate 仍最优, defer 至 50+ tools 或编译 > 60s | +| P3 | 竞品定位标语 | ✅ 已完成 — README 顶部标语更新为 "AI 无法识别你的 GUI,devbase 是它的眼镜。" | +| P4 | 开发者 onboarding 文档 | ✅ 已完成 — `CONTRIBUTING.md` + README Contributing 章节(devbase + clarity) | + +- **Tag**: `v0.10.0` 已打标(最新);`v0.2.4` 及之前标签见 Git history +- **Roadmap**: `docs/ROADMAP.md` 为唯一活跃主路线图 + +## Embedding 策略长期规划(已决策) + +**方向**:混合方案 — 模型向量语义搜索 + tantivy BM25 降级 + +| 层级 | 触发条件 | 技术方案 | 状态 | +|------|----------|----------|------| +| L1 向量语义 | `code_embeddings` 表有数据 | Ollama/OpenAI-compatible 生成 768-dim embedding,余弦相似度 Top-K | 已实现,待激活(需 Ollama 运行) | +| L2 全文搜索 | `code_embeddings` 为空或服务不可用时 | tantivy 索引代码符号(function name + signature + doc comment),BM25 评分 | 基础设施就绪,待接入 `semantic_search_symbols` | +| L3 纯符号匹配 | 查询为精确标识符 | SQLite `LIKE '%name%'` 快速匹配 | 已有 | + +**关键决策**:不绑定 Ollama 为唯一 provider。未来可能替换 embedding 生成层为: +- 本地 C++ 推理引擎(如 llama.cpp / onnxruntime) +- 纯 Rust 推理引擎(如 rust-bert / candle) +- 外部 MCP / Skill 封装(embedding 作为独立服务) + +**Embedding 状态**: +- `code_embeddings`: **56,722** 行(37.0% 覆盖率),覆盖 10 个仓库 +- `skills.embedding`: 3 个 builtin skill 已有 384-dim 向量 +- 生成工具:`tools/embedding-provider/skills.py`(sentence-transformers `all-MiniLM-L6-v2`) +- 激活路径:启动 Ollama + `devbase index ` 生成 embedding,或配置远程 provider 于 `config.toml [embedding]` 段 + +### 2026-05-04 索引性能实验记录 + +**发现**:Candle CPU BERT `batch_size=32` forward 比 `rayon` 并行单条慢 **5.2×**(88s vs 16s)。 +- 根因:Candle CPU matmul 对大 padded batch 不友好;batch 内序列长度差异导致大量无效 padding token 计算。 +- **决策**:`generate_and_save_embeddings` 回滚到 `rayon::par_iter()` 单条编码;保留 `EmbeddingProvider::encode_batch` trait 方法供未来 GPU/ONNX provider。 +- **新增**:`devbase index --skip-embeddings` 跳过 embedding 生成,纯符号/调用图索引从 ~16s 降至 ~250ms。 + +**外部参考**:知识蒸馏 Pipeline 设计规格(六阶段:噪声过滤→语义分割→主题聚类→层级展开→可信度标注→结构化输出),来源见 `docs/_audit/2026-04-26-embedding-research.md` §2026-05-04 补充。该规格提出通过 devbase MCP 暴露 `devkit_knowledge_distill` 工具,与 Vault 系统形成输入-处理-输出闭环。状态:设计规格级,待验证后评估集成优先级。 + +## 上下文安全机制(Context Safety Mechanism) + +> 长期架构原则:在多 Agent / 子代理协作场景下,保证工作区状态的一致性与可恢复性。 + +### 1. 子代理执行隔离 + +**教训**(2026-04-25 实际发生):多个子代理在同一 Git 工作目录并行执行 `git checkout`/`git commit` 会导致严重的分支混乱。`agent-publish` 和 `agent-tui` 的修改互相覆盖,最终 commit 被错误地放置到对方分支, stash 中混入了不相关的代码。 + +**规则**: +- **串行优先**:多个子代理任务必须串行执行,每次 commit 后切回 main 再启动下一个 +- **目录隔离**:若必须并行,每个子代理在独立的 `git clone` 临时目录工作,完成后由主会话 cherry-pick +- **禁止共享工作目录**:多个 Agent 绝不能同时操作同一个 `.git` 目录 +- **编译检查**:任何子代理返回前必须通过 `cargo test --lib`,否则标记为脏状态 + +### 2. MCP 工具幂等性 + +**原则**:所有通过 MCP 暴露的状态变更操作必须是幂等的。 + +**实现**: +- `save_embeddings` — `ON CONFLICT(repo_id, symbol_name) DO UPDATE` +- `save_symbol_links` — `ON CONFLICT(source_repo, source_symbol, target_repo, target_symbol, link_type) DO NOTHING` +- `index_repo` — 先 `DELETE` 旧数据再 `INSERT`(而非追加) +- 所有批量操作包裹在 SQLite transaction 中 + +### 3. 状态变更审计追踪 + +**原则**:任何对 registry 的写入都必须留下不可变的审计痕迹。 + +**实现**: +- OpLog Schema v12+:`event_type` 枚举 + JSON metadata + `duration_ms` +- 所有 `scan`/`sync`/`health`/`index` 操作自动记录 +- Schema 迁移前自动生成 `backup-YYYYMMDD-HHMMSS.db` 快照 +- `registry export --format json` 支持用户自主备份 + +### 4. 知识库一致性契约 + +**原则**:存储层(devbase)与计算层(Clarity/Skill)之间的接口契约必须显式、可版本化。 + +**当前契约**: +| 方向 | 接口 | 版本 | +|------|------|------| +| 外部 → devbase | `devkit_embedding_store(repo_id, symbol_name, embedding[])` | v1 | +| devbase → 外部 | `devkit_hybrid_search(repo_id, query_text, query_embedding?, limit)` | v1 | +| devbase → 外部 | `devkit_knowledge_report(repo_id?, activity_limit)` | v1 | + +**变更规则**:MCP tool schema 的 breaking change 必须通过新增 tool(如 `devkit_hybrid_search_v2`)而非修改现有 tool。 + +### 5. Sync Fail-Safe Defaults(Managed-Gate) + +**原则**:危险操作(修改本地 Git 分支)必须默认拒绝,仅对显式授权仓库生效。 + +**实现**(v0.12.0-alpha+): +- `scan --register` 不再自动分配 `"discovered"` 标签;新注册仓库标签为空。 +- 默认 `devbase sync`(无 `--filter-tags`)仅操作带有**管理标签**的仓库: + `mirror`, `reference`, `third-party`, `collaborative`, `team`, + `own-project`, `tool`, `active`, `managed`。 +- 未管理仓库仍被追踪(`health`/`index`/`query` 不受影响),但 `sync` 会跳过它们并提示: + `ℹ️ N registered repositories are not managed. Use 'devbase tag managed' to enable sync.` +- `--filter-tags` 显式过滤时绕过管理门控,允许用户按需选择任意标签组合。 +- 现有 DB 中带有旧 `"discovered"` 标签的仓库自动变为未管理状态(无需迁移,即安全修复)。 + +**用户操作路径**: +1. `devbase scan --register` → 仓库入库,标签为空。 +2. `devbase tag managed`(或 `mirror` / `active` 等)→ 授权该仓库进入自动同步池。 +3. `devbase sync` → 仅对已授权仓库执行同步。 + +--- + +## 上下文压缩缓解(Kimi CLI / LLM Agent 会话恢复) + +> Kimi CLI 等 Agent 环境存在上下文窗口限制,长会话会被压缩/截断。以下措施确保压缩后新 Agent 能独立恢复工作上下文。 + +### 1. 文件自描述原则 + +所有 `src/` 根目录模块和 `crates/*/src/lib.rs` 必须在文件顶部包含: +- **一句话职责**:这个文件解决什么问题 +- **边界说明**:它依赖谁、谁依赖它(或明确声明"零内部依赖") +- **关键决策注释**:任何非直观的设计选择必须有 `// NOTE:` 或 `// DECISION:` 注释 + +### 2. 状态锚点文件(Context Anchor Files) + +以下文件必须保持最新,作为会话压缩后的恢复点: + +| 文件 | 用途 | 更新触发条件 | +|------|------|-------------| +| `docs/ai-protocol.md` | 架构快照、待办、耦合地图 | 每次架构变更后 | +| `AGENTS.md` | 环境指引、红线规则、历史决策 | 每次红线变更后 | +| `Cargo.toml` workspace 声明 | 模块结构、crate 列表 | 每次提取/新增 crate | +| 各 `crates/*/Cargo.toml` | 独立 crate 的依赖与版本 | 每次依赖变更 | + +### 3. 反模式:禁止在代码中埋藏隐式上下文 + +- ❌ `// 之前讨论过这个方案` — 压缩后无法追溯 +- ❌ `// 见上文的 TODO` — "上文"已丢失 +- ✅ `// DECISION(2026-05-01): 使用 rusqlite::Connection 而非 r2d2 Pool,因为单个写入线程不需要连接池` — 自包含 + +### 4. 会话交接模板 + +当检测到上下文可能被压缩时,Agent 应在恢复后执行以下自检: + +```bash +# 1. 确认当前架构状态 +cargo test --workspace 2>&1 | grep "test result" +# 2. 确认 workspace 成员 +cargo metadata --format-version 1 | jq '.workspace_members' +# 3. 确认耦合地图(快速扫描高耦合模块) +grep -rc 'crate::' src/*.rs | sort -t: -k2 -n | tail -5 +``` + +## 知识库生产级缺口与补齐路线(Knowledge Base Production Gap) + +> 该章节记录 devbase 作为知识基础设施与生产级要求之间的真实差距,以及消除"玩具感"的补齐路径。 +> **核心原则**:devbase 首先是一个可靠的本地知识基础设施,然后才是一个 World Model Compiler。AI 层是编译器的输出接口,但如果存储层不可靠,AI 就是沙上建塔。 + +### 缺口诊断(与生产级知识库对比) + +| 能力维度 | 当前现状 | 生产级要求 | 缺口等级 | +|:---|:---|:---|:---:| +| **存储可靠性** | SQLite 单文件;Schema 迁移前自动快照 | WAL 并发模式、增量备份、索引损坏自动检测重建、点对点恢复 | 🔴 **严重** | +| **检索质量** | BM25 + 768-dim `cosine_similarity` SQL UDF | Hybrid RRF 调优、Re-rank、多路召回、查询延迟可观测 | 🔴 **严重** | +| **知识图谱** | `relation_store/query` 简单三元组 | 双向链接图遍历、Transitive Closure、社区发现、本体约束 | 🟠 **显著** | +| **版本历史** | 代码有 Git;Vault 笔记无版本 | 笔记块级历史、分支、冲突合并策略 | 🟠 **显著** | +| **规模化** | 单机 Rayon;未验证 >100 仓库 / >10k 文档场景 | 索引分片、增量更新、查询缓存、内存上限保护 | 🟠 **显著** | +| **互操作性** | Vault 读写 Markdown | Obsidian 兼容(frontmatter/wikilink)、标准导入导出、避免 Vendor Lock-in | 🟡 **中等** | +| **多模态** | 文本为主 | PDF 解析、图片 OCR、音频转录 | 🟡 **可延期** | +| **协作** | 单用户 + Syncthing 文件级同步 | 冲突解决(CRDT/OT 或至少 last-write-win)、多设备状态一致性 | 🟠 **显著** | + +### 补齐路线图 + +#### 🔴 v0.19.0:存储可靠性加固(消除"玩具感"的最快路径) + +| 任务 | 优先级 | 验收标准 | +|:---|:---:|:---| +| SQLite WAL 模式默认启用 | P1 | 并发写入无锁定冲突;`PRAGMA journal_mode=WAL` 持久化 | +| Tantivy 索引健康检查 `devkit_index_health` | P1 | 检测索引损坏、版本不匹配、孤儿文档;返回健康评分 0-100 | +| 自动重建策略 | P1 | 索引损坏时自动 fallback 全量重建,而非静默失败;重建过程写入 OpLog | +| 查询性能基线测试 | P1 | CI 中测试 1k/10k/100k 文档量级的检索延迟;建立性能回归红线 | +| Vault 批量导出(Markdown + frontmatter) | P2 | `devkit_vault_export` 支持 PARA 结构完整导出;消除 Vendor Lock-in 焦虑 | +| Redis 缓存评估 | P2 | 完成 Session/向量缓存需求分析;决策:引入 / 自建 / 放弃 | + +#### 🟠 v0.20.0:知识完备性(从"能存"到"好用") + +| 任务 | 优先级 | 验收标准 | +|:---|:---:|:---| +| Vault 双向链接图遍历 | P1 | `vault_backlinks` 升级为图查询:最短路径、共同引用、引用频次 | +| 笔记变更追踪 | P1 | Vault 笔记历史基于 Git 追踪(vault 目录作为 Git 子模块)或 SQLite 增量历史表 | +| 混合检索质量监控 | P1 | RRF 参数可调(`k`、`weights`)、召回率/精确率指标、`devkit_search_quality` 工具 | +| 笔记块级引用 `[[note#block]]` | P2 | 从文档级粒度下沉到块级;支持标题块、列表块、代码块引用 | +| middleware.ts 错误修复 | P2 | 解决已知未解决错误,见技术债登记簿 | + +#### 🟡 v0.21.0+:外部能力嫁接(不重复造轮子) + +| 任务 | 来源 | 集成方式 | +|:---|:---|:---| +| 多说话人播客/测验生成管道 | Open Notebook | 提取生成模块作为外部 MCP Tool,devbase 提供文档输入 | +| Agent 协作与多 LLM 路由 | SurfSense | 参考 Agent 架构,融入 Clarity 三角色世界模型 | +| 时序观测基础设施 | GreptimeDB | Standalone 模式起步,替代 Prometheus,监控索引和查询健康度 | +| 向量索引统一(远期) | GreptimeDB v1.1 | 评估替代 Tantivy+SQLite 双写架构的可行性 | + +### 技术债关联更新 + +| 债项 | 严重 | 状态变更 | 清理路径 | +|:---|:---:|:---|:---| +| Tantivy+SQLite 双写一致性 | 🟡 | **从长期降级至 v0.19.0 P1** | WAL + 补偿机制 + `devkit_index_health` | +| SQLite 单文件并发 | 🔴 | **新增** | v0.19.0 WAL 模式启用 | +| 查询性能不可观测 | 🔴 | **新增** | v0.19.0 CI 性能基线 + OpLog 延迟指标 | +| Vault 无版本历史 | 🟠 | **新增** | v0.20.0 Git 追踪或增量表 | + +### 决策约束 + +1. **v0.19.0 禁止新增非可靠性相关的 MCP Tool**。所有新增 Tool 必须与存储健康、可观测性、或索引修复直接相关。 +2. **v0.19.0 禁止引入外部数据库依赖**(包括 GreptimeDB、Redis、PostgreSQL)。可靠性加固必须在现有 SQLite + Tantivy 技术栈内完成。 +3. **世界模型研究(Spark/Flink/时序图神经网络)保持独立仓库**,主仓库继续执行"不得引入 Spark/Flink 依赖"红线。 + +--- + +## 架构演进方向:世界模型战略(World Model Strategy) + +> 该章节记录 devbase 从"静态情境编译器"向"动态世界模型"演进的战略认知。 +> 完整推导见 `vault/research/world-model-spark-flink-strategy.md`,精简认知见 `vault/ideas/world-model-cognition-card.md`。 + +### 核心认知 + +devbase 的终极壁垒不是"管理仓库的工具",而是**把静态代码库编译成 AI 可推理的动态世界模型**。 + +当前 devbase 是**静态世界模型编译器**——能把代码库的"当前快照"编译成 AI 可读的符号表征(调用图、知识图谱、Agent Memory),但不具备**时间维度**和**因果维度**的建模能力。 + +### 三层缺口分析 + +| 层级 | 当前能力 | 缺口 | 研究价值 | +|:---|:---|:---|:---:| +| **感知层** | AST、Git 状态、Vault 索引 | 时序演化感知、群体协作行为 | 中 | +| **世界模型层** | 调用图、知识图谱、向量空间 | 动态转移预测、因果结构、反事实推演 | **高** | +| **策略应对层** | 预设 Workflow 规则 | 自动规划、风险预测、基于模型的决策 | **高** | + +### 关键决策原则 + +1. **产品核心**:坚持 Local-first、Rust-native、zero ML runtime。世界模型训练可在云端,**推理必须下沉到本地**。 +2. **技术选型**:Spark/Flink 是可替换的数据工程管道,不是竞争壁垒。 +3. **差异化**:静态→动态的世界模型升级,是学术+工程的双重壁垒。 + +### Spark/Flink 定位 + +从世界模型视角,Spark/Flink 仅处于**数据工程层**: +- **Spark**:批量构建全局代码演化图谱、分布式因果发现(变量 > 10k 时有用) +- **Flink**:实时事件处理、多开发者世界模型同步 + +在单机/小团队场景下,两者均可用 `rayon` + `tokio` + `SQLite WAL` 替代。真正的研究核心在于**时序图神经网络、因果发现、世界模型压缩**,而非分布式框架本身。 + +### 两条验证路径 + +| 路径 | 形式 | 产出 | 与主仓库关系 | +|:---|:---|:---|:---| +| **学术原型** | 独立仓库 `devbase-worldmodel-research` | ICSE/FSE/NeurIPS Workshop 论文 | 复用 devbase AST 逻辑做数据预处理,模型通过 MCP 接入 | +| **求职映射** | 简历话语 | "基于 Spark/Flink 构建代码知识图谱的动态演化分析系统" | 实际支撑:devbase 符号提取 + 独立研究仓库分布式训练 | + +### 待验证假设 + +- [ ] 时序图神经网络能否预测模块缺陷爆发时间窗口? +- [ ] 因果发现算法能否从 git history 提取可靠的干预建议? +- [ ] 世界模型压缩后,本地推理延迟能否控制在 < 100ms? + +### 关联笔记(双向联动) + +| 笔记 | 类型 | 用途 | +|:---|:---|:---| +| `vault/research/world-model-spark-flink-strategy.md` | 完整推导 | 世界模型三层架构、Spark/Flink 定位、研究方向建议 | +| `vault/ideas/world-model-cognition-card.md` | 精炼认知 | 快速查阅:一句话认知、决策原则、反常识洞察 | + +> **认知同步原则**:AGENTS.md 是项目级**约束文档**,Vault 笔记是**探索空间**。若 Vault 中的假设被验证,应反向同步到 AGENTS.md 的"待验证假设"中并打勾;若 AGENTS.md 的决策原则变更,应同步更新 Vault 认知卡片。 + +## 禁止事项 + +- 不得修改 `dev\third_party\*` 外部仓库 +- 不得在没有迁移逻辑的情况下修改 registry schema +- 不得引入已 deprecated 的协议 +- **不得在主仓库引入 Spark/Flink 依赖**(研究性质代码必须置于独立仓库,保持主仓库轻量) +- **不得在任何源码文件中硬编码真实 token、api_key 或密码**(包括注释和测试数据) diff --git a/docs/production-readiness.md b/docs/production-readiness.md new file mode 100644 index 0000000..92680cc --- /dev/null +++ b/docs/production-readiness.md @@ -0,0 +1,90 @@ +# 生产就绪检查清单 (Production Readiness Checklist) + +> 本清单定义 devbase 从"实验项目"升级为"Agent 可依赖基础设施"的门槛。 +> 所有条目必须客观可验证,禁止主观描述。 + +--- + +## 一、稳定性门槛 (Stability) + +| 编号 | 检查项 | 验收标准 | 当前状态 | 阻塞 Issue | +|------|--------|---------|---------|-----------| +| S-1 | 测试通过率 | `cargo test --all-targets` 连续 7 天 0 failed | ⬜ | | +| S-2 | 编译零警告 | `cargo clippy --all-targets -D warnings` 全绿 | ✅ | | +| S-3 | 无已知崩溃 | Issue 列表中无 `panic`/`unwrap` 导致的 crash | ⬜ | | +| S-4 | Schema 冻结 | stable tools 的输入/输出 schema 30 天无变更 | ⬜ | | +| S-5 | 回归测试 | 每次 PR 必须通过 integration tests (`tests/cli.rs`) | ✅ | | + +## 二、性能门槛 (Performance) + +| 编号 | 检查项 | 验收标准 | 当前状态 | 备注 | +|------|--------|---------|---------|------| +| P-1 | MCP Server 冷启动 | `devbase mcp` 从执行到 ready < 1s | ⬜ | 需测量 | +| P-2 | 内存占用 | MCP Server 常驻内存 < 128MB | ⬜ | 需测量 | +| P-3 | 查询延迟 | `devkit_project_context` 平均响应 < 500ms | ⬜ | 需基准测试 | +| P-4 | Binary 大小 | release binary < 50MB | ⬜ | 需编译后测量 | +| P-5 | 并发安全 | 同时运行 `devbase tui` + `devbase mcp` 无锁竞争 | ⬜ | 需压测 | + +## 三、MCP 门槛 (MCP Integration) + +| 编号 | 检查项 | 验收标准 | 当前状态 | +|------|--------|---------|---------| +| M-1 | Stable tools 固化 | 5 个 stable tools 签名冻结,文档完整 | ⬜ | +| M-2 | 错误处理 | 所有 tools 返回结构化错误(非 panic) | ⬜ | +| M-3 | Schema 版本化 | tools 声明 `schemaVersion`,支持向后兼容 | ⬜ | +| M-4 | Graceful 降级 | MCP Server 异常退出时,Agent 能继续工作 | ⬜ | +| M-5 | 健康检查 | `devkit_health` 能在 100ms 内返回状态 | ⬜ | + +## 四、Agent 集成门槛 (Agent Integration) + +| 编号 | 检查项 | 验收标准 | 当前状态 | +|------|--------|---------|---------| +| A-1 | Kimi CLI 验证 | 连续 1 周 daily use,tool 调用成功率 > 95% | ⬜ | +| A-2 | Claude Code 验证 | 连续 1 周 daily use,无 `api.anthropic.com` 干扰 | ⬜ | +| A-3 | Session 持久化 | `devkit_session_export` → 切换工具 → `devkit_session_import` 上下文不丢失 | ⬜ | +| A-4 | Project Brief 质量 | `devkit_project_brief` 输出能被 Agent 直接用于决策 | ⬜ | +| A-5 | Vault 检索质量 | NLQ 自然语言查询 Top-3 命中率 > 80% | ⬜ | + +## 五、文档门槛 (Documentation) + +| 编号 | 检查项 | 验收标准 | 当前状态 | +|------|--------|---------|---------| +| D-1 | AGENTS.md 精简 | < 300 行,Agent 读取成本可控 | ✅ | +| D-2 | 工具文档 | 每个 stable tool 有独立的 `.md` 文档 | ⬜ | +| D-3 | 架构决策记录 | ADR 覆盖所有重大设计选择 | ⬜ | +| D-4 | 故障排查指南 | 常见错误有 Runbook | ⬜ | + +## 六、发布流程 (Release Process) + +| 编号 | 检查项 | 验收标准 | 当前状态 | +|------|--------|---------|---------| +| R-1 | 版本号语义 | 遵循 SemVer,v1.0 为生产就绪标志 | ⬜ | +| R-2 | 变更日志 | CHANGELOG.md 记录所有 breaking changes | ✅ | +| R-3 | 二进制分发 | GitHub Release 提供 Windows/Linux/macOS binary | ⬜ | +| R-4 | 回滚方案 | 新版本导致 regression 时,5 分钟内回退到旧版本 | ⬜ | + +--- + +## Phase 推进计划 + +``` +Phase 0: 当前 (v0.20.x) + └─ 完成 S-2, S-5, D-1, R-2 + └─ Focus: 精简文档 + 编译优化 + 崩溃修复 + +Phase 1: 稳定化 (v0.30.x) + └─ 达成: S-1, S-3, S-4, P-1~P-5, M-1~M-5 + └─ Focus: 性能基准测试 + MCP schema 冻结 + +Phase 2: Agent 试点 (v0.40.x) + └─ 达成: A-1, A-2, A-4, A-5 + └─ Focus: 单 Agent 接入 + dogfooding + +Phase 3: 生产就绪 (v1.0.0) + └─ 达成: A-3, D-2~D-4, R-3, R-4 + └─ Focus: 多 Agent 共享 + 二进制分发 + 故障恢复 +``` + +--- + +*本清单随项目演进更新。每次修改需经作者审核。* From 3612e10c4c39e13d9be3607232a675e305b528ad Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 17:20:43 +0800 Subject: [PATCH 02/12] chore: code health maintenance across workspace - Add description fields to 12 crates missing them in Cargo.toml - Remove dead code: sync_skills_to_clarity (Client-Agnostic violation), update_repo_last_synced_at, list_workspaces_by_tier - Add SAFETY comments to 4 unsafe env var blocks in mcp/tests.rs - RepairResult caller now logs orphan/missing counts; remove allow(dead_code) - Remove unused FolderScheduler::new, add NOTE for retained dead_code Co-Authored-By: Claude Opus 4.7 --- crates/devbase-embedding/Cargo.toml | 1 + crates/devbase-registry-call-graph/Cargo.toml | 1 + .../devbase-registry-code-symbols/Cargo.toml | 1 + crates/devbase-registry-dead-code/Cargo.toml | 1 + crates/devbase-registry-entity/Cargo.toml | 1 + crates/devbase-registry-health/Cargo.toml | 1 + crates/devbase-registry-metrics/Cargo.toml | 1 + crates/devbase-registry-relation/Cargo.toml | 1 + crates/devbase-registry-workspace/Cargo.toml | 1 + .../devbase-skill-runtime-parser/Cargo.toml | 1 + crates/devbase-skill-runtime-types/Cargo.toml | 1 + .../devbase-workflow-interpolate/src/lib.rs | 4 +++ crates/devbase-workflow-model/Cargo.toml | 1 + src/mcp/tests.rs | 5 ++++ src/skill_runtime/clarity_sync.rs | 12 +++------ src/storage.rs | 27 +++++++++++++++---- src/watch.rs | 12 +++------ 17 files changed, 49 insertions(+), 23 deletions(-) diff --git a/crates/devbase-embedding/Cargo.toml b/crates/devbase-embedding/Cargo.toml index 379870c..6eb1b2b 100644 --- a/crates/devbase-embedding/Cargo.toml +++ b/crates/devbase-embedding/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-embedding" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Embedding generation and storage protocol with Candle and Ollama backends" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-registry-call-graph/Cargo.toml b/crates/devbase-registry-call-graph/Cargo.toml index eb5e8a7..b718bfb 100644 --- a/crates/devbase-registry-call-graph/Cargo.toml +++ b/crates/devbase-registry-call-graph/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-registry-call-graph" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Intra-repository call graph query helpers" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-registry-code-symbols/Cargo.toml b/crates/devbase-registry-code-symbols/Cargo.toml index 691f42f..482273f 100644 --- a/crates/devbase-registry-code-symbols/Cargo.toml +++ b/crates/devbase-registry-code-symbols/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-registry-code-symbols" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Code symbol query helpers for the devbase registry" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-registry-dead-code/Cargo.toml b/crates/devbase-registry-dead-code/Cargo.toml index 785c667..b81b743 100644 --- a/crates/devbase-registry-dead-code/Cargo.toml +++ b/crates/devbase-registry-dead-code/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-registry-dead-code" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Dead code detection queries for the devbase registry" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-registry-entity/Cargo.toml b/crates/devbase-registry-entity/Cargo.toml index 31feacf..ec03390 100644 --- a/crates/devbase-registry-entity/Cargo.toml +++ b/crates/devbase-registry-entity/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-registry-entity" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Unified entity upsert and query operations for the devbase registry" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-registry-health/Cargo.toml b/crates/devbase-registry-health/Cargo.toml index 1ce097b..1c21dd6 100644 --- a/crates/devbase-registry-health/Cargo.toml +++ b/crates/devbase-registry-health/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-registry-health" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Repository health entry storage and retrieval" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-registry-metrics/Cargo.toml b/crates/devbase-registry-metrics/Cargo.toml index 4b25dcb..05c9ab1 100644 --- a/crates/devbase-registry-metrics/Cargo.toml +++ b/crates/devbase-registry-metrics/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-registry-metrics" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Code metrics persistence and retrieval for devbase repositories" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-registry-relation/Cargo.toml b/crates/devbase-registry-relation/Cargo.toml index b457112..7cb4617 100644 --- a/crates/devbase-registry-relation/Cargo.toml +++ b/crates/devbase-registry-relation/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-registry-relation" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Directed entity relation storage and query for the devbase registry" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-registry-workspace/Cargo.toml b/crates/devbase-registry-workspace/Cargo.toml index 51d0b51..3f3eb0b 100644 --- a/crates/devbase-registry-workspace/Cargo.toml +++ b/crates/devbase-registry-workspace/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-registry-workspace" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Workspace snapshot and oplog event types for the devbase registry" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-skill-runtime-parser/Cargo.toml b/crates/devbase-skill-runtime-parser/Cargo.toml index 3060c28..6134d1c 100644 --- a/crates/devbase-skill-runtime-parser/Cargo.toml +++ b/crates/devbase-skill-runtime-parser/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-skill-runtime-parser" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "SKILL.md frontmatter parser for skill metadata extraction" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-skill-runtime-types/Cargo.toml b/crates/devbase-skill-runtime-types/Cargo.toml index 7e1f2e1..0f860b5 100644 --- a/crates/devbase-skill-runtime-types/Cargo.toml +++ b/crates/devbase-skill-runtime-types/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-skill-runtime-types" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Skill runtime type definitions and discriminant enums" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/crates/devbase-workflow-interpolate/src/lib.rs b/crates/devbase-workflow-interpolate/src/lib.rs index 92fd48a..f7127db 100644 --- a/crates/devbase-workflow-interpolate/src/lib.rs +++ b/crates/devbase-workflow-interpolate/src/lib.rs @@ -154,6 +154,8 @@ mod tests { impl Drop for EnvGuard { fn drop(&mut self) { + // SAFETY: EnvGuard is only used in single-threaded tests with + // DEVBASE_TEST_* keys. No other thread reads these vars concurrently. match &self.old { Some(v) => unsafe { std::env::set_var(self.key, v) }, None => unsafe { std::env::remove_var(self.key) }, @@ -166,6 +168,8 @@ mod tests { let key = "DEVBASE_TEST_VAR"; let old = std::env::var(key).ok(); let _guard = EnvGuard { key, old }; + // SAFETY: test-only env var mutation; DEVBASE_TEST_VAR is not read + // concurrently by any other thread in this test process. unsafe { std::env::set_var(key, "test_value"); } diff --git a/crates/devbase-workflow-model/Cargo.toml b/crates/devbase-workflow-model/Cargo.toml index 5d13ac5..69c18d7 100644 --- a/crates/devbase-workflow-model/Cargo.toml +++ b/crates/devbase-workflow-model/Cargo.toml @@ -3,6 +3,7 @@ name = "devbase-workflow-model" version.workspace = true edition = "2024" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] +description = "Workflow definition types for the YAML-based workflow engine" license = "MIT" repository = "https://github.com/juice094/devbase" diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index b7d69fd..1035590 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -302,6 +302,8 @@ async fn test_tools_call_devkit_skill_discover() { } } }); + // SAFETY: test-only env var mutation; test runner guarantees no concurrent + // reads of DEVBASE_MCP_ENABLE_DESTRUCTIVE in this process. unsafe { std::env::set_var("DEVBASE_MCP_ENABLE_DESTRUCTIVE", "1"); } @@ -321,6 +323,7 @@ async fn test_tools_call_devkit_skill_discover() { #[test] fn test_destructive_gate_disabled_by_default() { // Ensure the variable is unset + // SAFETY: test-only env var mutation; no concurrent reads of this var. unsafe { std::env::remove_var("DEVBASE_MCP_ENABLE_DESTRUCTIVE"); } @@ -332,12 +335,14 @@ fn test_destructive_gate_disabled_by_default() { #[test] fn test_destructive_gate_enabled() { + // SAFETY: test-only env var mutation; no concurrent reads of this var. unsafe { std::env::set_var("DEVBASE_MCP_ENABLE_DESTRUCTIVE", "1"); } let result = crate::mcp::check_destructive_enabled(); assert!(result.is_ok()); // Cleanup + // SAFETY: test-only env var mutation; no concurrent reads of this var. unsafe { std::env::remove_var("DEVBASE_MCP_ENABLE_DESTRUCTIVE"); } diff --git a/src/skill_runtime/clarity_sync.rs b/src/skill_runtime/clarity_sync.rs index 0b6499e..dd3bbd3 100644 --- a/src/skill_runtime/clarity_sync.rs +++ b/src/skill_runtime/clarity_sync.rs @@ -92,12 +92,6 @@ pub fn sync_skills_to_plans(conn: &Connection, plans_dir: &Path) -> Result Result { - sync_skills_to_plans(conn, &clarity_dir.join("plans")) -} - fn fetch_skills_with_inputs(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, name, description, tags, inputs_schema, updated_at FROM skills ORDER BY name", @@ -197,7 +191,7 @@ mod tests { use chrono::Utc; #[test] - fn test_sync_skills_to_clarity() { + fn test_sync_skills_to_plans() { let conn = WorkspaceRegistry::init_in_memory().unwrap(); let skill = SkillMeta { id: "test-skill".to_string(), @@ -293,7 +287,7 @@ mod tests { ) .unwrap(); - let count = sync_skills_to_clarity(&conn, clarity_dir).unwrap(); + let count = sync_skills_to_plans(&conn, &plans_dir).unwrap(); assert_eq!(count, 0); let content = std::fs::read_to_string(plans_dir.join("conflict-test.json")).unwrap(); @@ -346,7 +340,7 @@ mod tests { ) .unwrap(); - let count = sync_skills_to_clarity(&conn, clarity_dir).unwrap(); + let count = sync_skills_to_plans(&conn, &plans_dir).unwrap(); assert_eq!(count, 1); let content = std::fs::read_to_string(plans_dir.join("update-test.json")).unwrap(); diff --git a/src/storage.rs b/src/storage.rs index ac46b47..e614a7e 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -140,8 +140,17 @@ impl AppContext { let path = storage.db_path()?; // 先执行 init_db_with 确保数据库已初始化并迁移 let mut conn = WorkspaceRegistry::init_db_with(&*storage)?; - if let Err(e) = repair_tantivy_consistency(&mut conn) { - tracing::warn!("Startup Tantivy consistency check failed: {}", e); + match repair_tantivy_consistency(&mut conn) { + Ok(result) => { + if result.orphans > 0 || result.missing_from_index > 0 { + tracing::info!( + "Tantivy consistency repaired: {} orphans, {} missing from index", + result.orphans, + result.missing_from_index + ); + } + } + Err(e) => tracing::warn!("Startup Tantivy consistency check failed: {}", e), } if let Err(e) = sync_index_to_db(&conn) { tracing::warn!("Startup Tantivy/SQLite orphan sync failed: {}", e); @@ -163,8 +172,17 @@ impl AppContext { pub fn with_storage(storage: Arc) -> anyhow::Result { let path = storage.db_path()?; let mut conn = WorkspaceRegistry::init_db_with(&*storage)?; - if let Err(e) = repair_tantivy_consistency(&mut conn) { - tracing::warn!("Startup Tantivy consistency check failed: {}", e); + match repair_tantivy_consistency(&mut conn) { + Ok(result) => { + if result.orphans > 0 || result.missing_from_index > 0 { + tracing::info!( + "Tantivy consistency repaired: {} orphans, {} missing from index", + result.orphans, + result.missing_from_index + ); + } + } + Err(e) => tracing::warn!("Startup Tantivy consistency check failed: {}", e), } if let Err(e) = sync_index_to_db(&conn) { tracing::warn!("Startup Tantivy/SQLite orphan sync failed: {}", e); @@ -227,7 +245,6 @@ impl AppContext { } /// Result of a startup consistency scan. -#[allow(dead_code)] pub(crate) struct RepairResult { /// Tantivy documents whose repo no longer exists in SQLite. pub orphans: usize, diff --git a/src/watch.rs b/src/watch.rs index fb7b0c4..3a70044 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -9,6 +9,8 @@ use std::time::Duration; /// Bottom-layer filesystem watcher using `notify`. pub struct FsWatcher { + // NOTE: field is never read directly, but must be held to keep the + // notify watcher alive for the duration of FsWatcher's lifetime. #[allow(dead_code)] watcher: RecommendedWatcher, rx: crossbeam_channel::Receiver>, @@ -66,6 +68,7 @@ impl FsWatcher { /// Middle-layer aggregator: dedup and degrade to full-scan when too many files change. pub struct WatchAggregator { + // NOTE: reserved for future debounce delay configuration. #[allow(dead_code)] pub delay: Duration, pub max_files: usize, @@ -115,15 +118,6 @@ pub struct FolderScheduler { } impl FolderScheduler { - #[allow(dead_code)] - pub fn new(root: PathBuf) -> Self { - FolderScheduler { - root, - index: None, - max_files: crate::config::default_watch_max_files(), - } - } - pub fn with_max_files(root: PathBuf, max_files: usize) -> Self { FolderScheduler { root, index: None, max_files } } From 2a67fa42b3f36edc7afd46283ffc3c7362d7baea Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 17:21:21 +0800 Subject: [PATCH 03/12] test(registry): Wave 1 coverage for relation + health modules - relation: add test_list_relations, test_find_related_entities_bidirectional, test_save_relation_upsert (was only 1 smoke test) - health: add test_get_health_batch covering batch query, empty input, and partial miss scenarios - 5 tested crates now at 88-97% region coverage Co-Authored-By: Claude Opus 4.7 --- crates/devbase-registry-health/src/lib.rs | 34 ++++++++ crates/devbase-registry-relation/src/lib.rs | 86 +++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/crates/devbase-registry-health/src/lib.rs b/crates/devbase-registry-health/src/lib.rs index 62d6fe4..02168d5 100644 --- a/crates/devbase-registry-health/src/lib.rs +++ b/crates/devbase-registry-health/src/lib.rs @@ -231,4 +231,38 @@ mod tests { assert_eq!(history[0].0, 10); assert_eq!(history[1].0, 20); } + + #[test] + fn test_get_health_batch() { + let conn = init_in_memory(); + let h1 = HealthEntry { + status: "healthy".to_string(), + ahead: 1, + behind: 0, + checked_at: Utc::now(), + }; + let h2 = HealthEntry { + status: "dirty".to_string(), + ahead: 0, + behind: 2, + checked_at: Utc::now(), + }; + save_health(&conn, "repo-a", &h1).unwrap(); + save_health(&conn, "repo-b", &h2).unwrap(); + + // Batch query both repos + let batch = get_health_batch(&conn, &["repo-a", "repo-b"]).unwrap(); + assert_eq!(batch.len(), 2); + assert_eq!(batch["repo-a"].status, "healthy"); + assert_eq!(batch["repo-b"].status, "dirty"); + + // Empty input returns empty map + let empty = get_health_batch(&conn, &[]).unwrap(); + assert!(empty.is_empty()); + + // Partial miss skips missing repos without error + let partial = get_health_batch(&conn, &["repo-a", "repo-c"]).unwrap(); + assert_eq!(partial.len(), 1); + assert_eq!(partial["repo-a"].status, "healthy"); + } } diff --git a/crates/devbase-registry-relation/src/lib.rs b/crates/devbase-registry-relation/src/lib.rs index dbc7d26..29be574 100644 --- a/crates/devbase-registry-relation/src/lib.rs +++ b/crates/devbase-registry-relation/src/lib.rs @@ -155,4 +155,90 @@ mod tests { .unwrap(); assert_eq!(count, 1); } + + #[test] + fn test_list_relations() { + let conn = in_memory(); + conn.execute( + "INSERT INTO entities (id) VALUES ('a'), ('b'), ('c')", + [], + ) + .unwrap(); + super::save_relation(&conn, "a", "b", "depends_on", 0.9).unwrap(); + super::save_relation(&conn, "a", "c", "uses", 0.7).unwrap(); + + // List all outgoing relations from 'a' + let all = super::list_relations(&conn, "a", None).unwrap(); + assert_eq!(all.len(), 2); + + // Filter by relation_type + let depends = super::list_relations(&conn, "a", Some("depends_on")).unwrap(); + assert_eq!(depends.len(), 1); + assert_eq!(depends[0].0, "b"); // to_entity_id + assert_eq!(depends[0].1, "depends_on"); + assert!((depends[0].2 - 0.9).abs() < f64::EPSILON); + + // Empty filter string should behave like None + let empty_filter = super::list_relations(&conn, "a", Some("")).unwrap(); + assert_eq!(empty_filter.len(), 2); + + // Non-existent from_entity + let none = super::list_relations(&conn, "z", None).unwrap(); + assert!(none.is_empty()); + } + + #[test] + fn test_find_related_entities_bidirectional() { + let conn = in_memory(); + conn.execute( + "INSERT INTO entities (id) VALUES ('a'), ('b'), ('c')", + [], + ) + .unwrap(); + super::save_relation(&conn, "a", "b", "depends_on", 0.9).unwrap(); + super::save_relation(&conn, "c", "a", "uses", 0.8).unwrap(); + + // Bidirectional query for 'a' should find both outgoing and incoming + let related = super::find_related_entities(&conn, "a", None).unwrap(); + assert_eq!(related.len(), 2); + + // Filter by type + let depends_only = + super::find_related_entities(&conn, "a", Some("depends_on")).unwrap(); + assert_eq!(depends_only.len(), 1); + assert_eq!(depends_only[0].0, "a"); // from_entity_id + assert_eq!(depends_only[0].1, "b"); // to_entity_id + + // Non-existent entity + let none = super::find_related_entities(&conn, "z", None).unwrap(); + assert!(none.is_empty()); + } + + #[test] + fn test_save_relation_upsert() { + let conn = in_memory(); + conn.execute("INSERT INTO entities (id) VALUES ('a'), ('b')", []) + .unwrap(); + super::save_relation(&conn, "a", "b", "depends_on", 0.5).unwrap(); + super::save_relation(&conn, "a", "b", "depends_on", 0.9).unwrap(); + + // Should only have one row with updated confidence + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM relations WHERE from_entity_id = 'a'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + + let conf: f64 = conn + .query_row( + "SELECT confidence FROM relations WHERE from_entity_id = 'a'", + [], + |row| row.get(0), + ) + .unwrap(); + assert!((conf - 0.9).abs() < f64::EPSILON); + } } From 20842dd8682fb8dd6106ffef5a52926bdeccecb9 Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 17:22:18 +0800 Subject: [PATCH 04/12] feat(sync): managed flag transparency and repo status commands - Add MANAGED_TAGS constant and RepoEntry::is_managed() in registry.rs Document that managed lives in repo_tags (queryable) not metadata - Replace inline MANAGED_TAGS check in sync/tasks.rs with repo.is_managed() - Add devbase repo list: shows managed flag, type, tier, path - Add devbase repo status: batch git health (ahead/behind/dirty/managed) with health cache TTL reuse and --json support - Enhance sync output: categorize skipped repos by reason with counts Co-Authored-By: Claude Opus 4.7 --- src/commands/repo.rs | 198 +++++++++++++++++++++++++++++++++++++++++ src/commands/simple.rs | 4 +- src/main.rs | 24 +++++ src/registry.rs | 21 +++++ src/registry/repo.rs | 85 ++++-------------- src/sync.rs | 15 +++- src/sync/tasks.rs | 15 +--- 7 files changed, 276 insertions(+), 86 deletions(-) diff --git a/src/commands/repo.rs b/src/commands/repo.rs index c1203d7..5a1d290 100644 --- a/src/commands/repo.rs +++ b/src/commands/repo.rs @@ -462,6 +462,204 @@ pub fn run_knowledge_report( Ok(()) } +pub async fn run_repo( + ctx: &mut crate::storage::AppContext, + cmd: crate::RepoCommands, +) -> anyhow::Result<()> { + match cmd { + crate::RepoCommands::List { json } => run_repo_list(ctx, json).await, + crate::RepoCommands::Status { json } => run_repo_status(ctx, json).await, + } +} + +pub async fn run_repo_list(ctx: &mut crate::storage::AppContext, json: bool) -> anyhow::Result<()> { + let conn = ctx.conn()?; + let repos = crate::registry::repo::list_repos(&conn)?; + + #[derive(serde::Serialize)] + struct RepoListItem { + id: String, + path: String, + language: Option, + tags: Vec, + managed: bool, + workspace_type: String, + data_tier: String, + upstream_url: Option, + } + + let items: Vec = repos + .into_iter() + .map(|r| { + let managed = r.is_managed(); + RepoListItem { + upstream_url: r.primary_remote().and_then(|rm| rm.upstream_url.clone()), + id: r.id, + path: r.local_path.to_string_lossy().to_string(), + language: r.language, + tags: r.tags, + managed, + workspace_type: r.workspace_type, + data_tier: r.data_tier, + } + }) + .collect(); + + if json { + println!("{}", serde_json::to_string_pretty(&items)?); + } else { + println!("{:<20} {:<8} {:<10} {:<12} Path", "ID", "Managed", "Type", "Tier"); + println!("{}", "-".repeat(90)); + for item in &items { + println!( + "{:<20} {:<8} {:<10} {:<12} {}", + item.id, + if item.managed { "yes" } else { "no" }, + item.workspace_type, + item.data_tier, + item.path + ); + } + let managed_count = items.iter().filter(|i| i.managed).count(); + println!("\nTotal: {} | Managed: {} | Unmanaged: {}", items.len(), managed_count, items.len() - managed_count); + } + Ok(()) +} + +pub async fn run_repo_status(ctx: &mut crate::storage::AppContext, json: bool) -> anyhow::Result<()> { + use crate::registry::{HealthEntry, health as reg_health}; + use chrono::Utc; + + let conn = ctx.conn()?; + let repos = crate::registry::repo::list_repos(&conn)?; + + // Batch load health cache + let repo_ids: Vec<&str> = repos.iter().map(|r| r.id.as_str()).collect(); + let health_batch = reg_health::get_health_batch(&conn, &repo_ids).unwrap_or_default(); + + #[derive(serde::Serialize)] + struct RepoStatusItem { + id: String, + path: String, + managed: bool, + status: String, + ahead: usize, + behind: usize, + upstream_url: Option, + default_branch: Option, + tags: Vec, + checked_at: Option, + } + + let mut items = Vec::new(); + for repo in repos { + let primary = repo.primary_remote(); + let upstream_url = primary.and_then(|r| r.upstream_url.clone()); + let default_branch = primary.and_then(|r| r.default_branch.clone()); + + let (status, ahead, behind, checked_at) = if repo.workspace_type == "git" { + match health_batch.get(&repo.id).cloned() { + Some(health) => { + let elapsed = Utc::now().signed_duration_since(health.checked_at).num_seconds(); + // Use cached value if fresh (within TTL), else re-analyze + let ttl = ctx.config.cache.ttl_seconds; + if elapsed >= 0 && elapsed < ttl { + ( + health.status, + health.ahead, + health.behind, + Some(health.checked_at.to_rfc3339()), + ) + } else { + let (st, ah, bh) = crate::health::analyze_repo( + repo.local_path.to_string_lossy().as_ref(), + upstream_url.as_deref(), + default_branch.as_deref(), + ); + let new_health = HealthEntry { + status: st.clone(), + ahead: ah, + behind: bh, + checked_at: Utc::now(), + }; + if let Err(e) = reg_health::save_health(&conn, &repo.id, &new_health) { + tracing::warn!("Failed to save health for {}: {}", repo.id, e); + } + (st, ah, bh, Some(Utc::now().to_rfc3339())) + } + } + None => { + let (st, ah, bh) = crate::health::analyze_repo( + repo.local_path.to_string_lossy().as_ref(), + upstream_url.as_deref(), + default_branch.as_deref(), + ); + let new_health = HealthEntry { + status: st.clone(), + ahead: ah, + behind: bh, + checked_at: Utc::now(), + }; + if let Err(e) = reg_health::save_health(&conn, &repo.id, &new_health) { + tracing::warn!("Failed to save health for {}: {}", repo.id, e); + } + (st, ah, bh, Some(Utc::now().to_rfc3339())) + } + } + } else { + ("non-git".to_string(), 0, 0, None) + }; + + let managed = repo.is_managed(); + items.push(RepoStatusItem { + id: repo.id, + path: repo.local_path.to_string_lossy().to_string(), + managed, + status, + ahead, + behind, + upstream_url, + default_branch, + tags: repo.tags, + checked_at, + }); + } + + if json { + println!("{}", serde_json::to_string_pretty(&items)?); + } else { + println!( + "{:<20} {:<8} {:<12} {:>5} {:>6} Path", + "ID", "Managed", "Status", "Ahead", "Behind" + ); + println!("{}", "-".repeat(90)); + for item in &items { + println!( + "{:<20} {:<8} {:<12} {:>5} {:>6} {}", + item.id, + if item.managed { "yes" } else { "no" }, + item.status, + item.ahead, + item.behind, + item.path + ); + } + let managed_count = items.iter().filter(|i| i.managed).count(); + let dirty_count = items.iter().filter(|i| i.status == "dirty" || i.status == "changed").count(); + let behind_count = items.iter().filter(|i| i.behind > 0).count(); + let ahead_count = items.iter().filter(|i| i.ahead > 0).count(); + println!( + "\nTotal: {} | Managed: {} | Dirty: {} | Behind: {} | Ahead: {}", + items.len(), + managed_count, + dirty_count, + behind_count, + ahead_count + ); + } + Ok(()) +} + pub fn run_registry( ctx: &mut crate::storage::AppContext, cmd: crate::RegistryCommands, diff --git a/src/commands/simple.rs b/src/commands/simple.rs index ff466f4..b127d2b 100644 --- a/src/commands/simple.rs +++ b/src/commands/simple.rs @@ -13,8 +13,8 @@ pub use crate::commands::knowledge::{ #[cfg(feature = "tui")] pub use crate::commands::repo::run_discover; pub use crate::commands::repo::{ - run_health, run_index, run_knowledge_report, run_query, run_registry, run_scan, run_status, - run_sync, run_syncthing_push, + run_health, run_index, run_knowledge_report, run_query, run_registry, run_repo, run_scan, + run_status, run_sync, run_syncthing_push, }; #[cfg(feature = "mcp")] pub use crate::commands::system::run_mcp; diff --git a/src/main.rs b/src/main.rs index 52fc220..e28e4e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -312,10 +312,31 @@ pub(crate) enum Commands { #[command(subcommand)] cmd: LimitCommands, }, + /// Repository management — list repos and check git health + Repo { + #[command(subcommand)] + cmd: RepoCommands, + }, /// Show version information Version, } +#[derive(Subcommand)] +pub(crate) enum RepoCommands { + /// List all registered repositories + List { + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Show git health status (ahead/behind/dirty/managed) for all repos + Status { + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand)] pub(crate) enum SkillCommands { /// List installed skills @@ -624,6 +645,9 @@ async fn main() -> anyhow::Result<()> { Commands::Status { json } => { commands::simple::run_status(&mut ctx, json).await?; } + Commands::Repo { cmd } => { + commands::simple::run_repo(&mut ctx, cmd).await?; + } Commands::Sync { dry_run, filter_tags, diff --git a/src/registry.rs b/src/registry.rs index dd2349e..4f54562 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -35,6 +35,21 @@ pub struct RepoEntry { pub remotes: Vec, } +/// Tags that mark a repository as "managed" for sync purposes. +/// Stored in the `repo_tags` table (not metadata) because tags are the +/// queryable, filterable dimension — metadata is for opaque JSON. +pub const MANAGED_TAGS: &[&str] = &[ + "mirror", + "reference", + "third-party", + "collaborative", + "team", + "own-project", + "tool", + "active", + "managed", +]; + impl RepoEntry { /// Return the 'origin' remote if present, otherwise the first remote. pub fn primary_remote(&self) -> Option<&RemoteEntry> { @@ -43,6 +58,12 @@ impl RepoEntry { .find(|r| r.remote_name == "origin") .or_else(|| self.remotes.first()) } + + /// Whether this repo is considered "managed" for sync/health automation. + /// Managed status is determined by the presence of any tag in [`MANAGED_TAGS`]. + pub fn is_managed(&self) -> bool { + self.tags.iter().any(|t| MANAGED_TAGS.contains(&t.as_str())) + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/registry/repo.rs b/src/registry/repo.rs index bd19ea0..a79e9ea 100644 --- a/src/registry/repo.rs +++ b/src/registry/repo.rs @@ -218,40 +218,6 @@ pub fn update_repo_workspace_type( Ok(()) } -#[allow(dead_code)] -pub fn update_repo_last_synced_at( - conn: &rusqlite::Connection, - repo_id: &str, - timestamp: DateTime, -) -> anyhow::Result<()> { - let now = chrono::Utc::now().to_rfc3339(); - conn.execute( - "UPDATE entities SET last_synced_at = ?1, updated_at = ?2 WHERE id = ?3", - rusqlite::params![timestamp.to_rfc3339(), &now, repo_id], - )?; - Ok(()) -} - -#[allow(dead_code)] -pub fn list_workspaces_by_tier( - conn: &rusqlite::Connection, - tier: &str, -) -> anyhow::Result> { - let stmt = conn.prepare(&format!( - "SELECT e.id, e.local_path, (SELECT group_concat(tag, ',') FROM repo_tags WHERE repo_id = e.id) as tags, - e.language, e.discovered_at, - e.workspace_type, e.data_tier, - e.last_synced_at, e.stars, - rm.remote_name, rm.upstream_url, rm.default_branch, rm.last_sync - FROM entities e - LEFT JOIN repo_remotes rm ON e.id = rm.repo_id - WHERE e.entity_type = '{}' AND e.data_tier = ?1 - ORDER BY e.id, rm.remote_name", - super::ENTITY_TYPE_REPO - ))?; - collect_repos_from_stmt(stmt, &[&tier]) -} - /// Sync repo_tags sub-table back into entities.metadata.tags. pub fn sync_repo_tags_to_entity(conn: &rusqlite::Connection, repo_id: &str) -> anyhow::Result<()> { let tags: Option = conn @@ -334,6 +300,21 @@ mod tests { } } + #[test] + fn test_repo_is_managed() { + let mut repo = sample_repo("r", "/tmp/r"); + assert!(!repo.is_managed(), "no tags = not managed"); + + repo.tags = vec!["discovered".to_string()]; + assert!(!repo.is_managed(), "discovered is not a managed tag"); + + repo.tags = vec!["managed".to_string()]; + assert!(repo.is_managed(), "managed tag marks as managed"); + + repo.tags = vec!["mirror".to_string(), "discovered".to_string()]; + assert!(repo.is_managed(), "mirror is a managed tag"); + } + #[test] fn test_list_repos_empty() { let conn = WorkspaceRegistry::init_in_memory().unwrap(); @@ -410,30 +391,6 @@ mod tests { assert!(!repos[0].tags.contains(&"a".to_string())); } - #[test] - fn test_list_workspaces_by_tier() { - let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); - let mut private = sample_repo("private_repo", "/tmp/p"); - private.data_tier = "private".to_string(); - - let mut public = sample_repo("public_repo", "/tmp/pub"); - public.data_tier = "public".to_string(); - - save_repo(&mut conn, &private).unwrap(); - save_repo(&mut conn, &public).unwrap(); - - let private_repos = list_workspaces_by_tier(&conn, "private").unwrap(); - assert_eq!(private_repos.len(), 1); - assert_eq!(private_repos[0].id, "private_repo"); - - let public_repos = list_workspaces_by_tier(&conn, "public").unwrap(); - assert_eq!(public_repos.len(), 1); - assert_eq!(public_repos[0].id, "public_repo"); - - let none = list_workspaces_by_tier(&conn, "nonexistent").unwrap(); - assert!(none.is_empty()); - } - #[test] fn test_save_repo_with_stars() { let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); @@ -478,18 +435,6 @@ mod tests { assert_eq!(repos[0].workspace_type, "openclaw"); } - #[test] - fn test_update_repo_last_synced_at() { - let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); - let repo = sample_repo("repo1", "/tmp/repo1"); - save_repo(&mut conn, &repo).unwrap(); - - let now = chrono::Utc::now(); - update_repo_last_synced_at(&conn, "repo1", now).unwrap(); - let repos = list_repos(&conn).unwrap(); - assert!(repos[0].last_synced_at.is_some()); - } - #[test] fn test_list_repos_stale_health() { let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); diff --git a/src/sync.rs b/src/sync.rs index 6704cae..1ae71d0 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -189,12 +189,23 @@ pub async fn run( print_summary_table(&results_json, i18n); if !skipped_repos.is_empty() { + let excluded = skipped_repos.iter().filter(|s| s.reason == "excluded").count(); + let path_excluded = skipped_repos.iter().filter(|s| s.reason == "path_excluded").count(); println!("\n Skipped repositories ({}):", skipped_repos.len()); + if skipped_unmanaged > 0 { + println!(" · unmanaged: {}", skipped_unmanaged); + } + if excluded > 0 { + println!(" · excluded: {}", excluded); + } + if path_excluded > 0 { + println!(" · path_excluded: {}", path_excluded); + } for s in skipped_repos.iter().take(10) { - println!(" [{}] {} (reason: {})", s.id, s.path, s.reason); + println!(" [{}] {} ({})", s.id, s.path, s.reason); } if skipped_repos.len() > 10 { - println!(" ... and {} more", skipped_repos.len() - 10); + println!(" ... and {} more", skipped_repos.len() - 10); } if skipped_unmanaged > 0 { println!( diff --git a/src/sync/tasks.rs b/src/sync/tasks.rs index 7a17af7..50e83a2 100644 --- a/src/sync/tasks.rs +++ b/src/sync/tasks.rs @@ -184,17 +184,8 @@ pub(super) async fn execute_task( } } -const MANAGED_TAGS: &[&str] = &[ - "mirror", - "reference", - "third-party", - "collaborative", - "team", - "own-project", - "tool", - "active", - "managed", -]; +// Managed-tag list lives in registry::MANAGED_TAGS so it is defined once +// and documented as the single source of truth for the "managed" concept. #[derive(Debug, Clone)] pub(super) struct SkippedRepoInfo { @@ -225,7 +216,7 @@ pub(super) async fn collect_tasks( .into_iter() .filter(|repo| { let tag_match = if is_default_mode { - repo.tags.iter().any(|t| MANAGED_TAGS.contains(&t.as_str())) + repo.is_managed() } else { filter_list.iter().any(|f| repo.tags.contains(&f.to_string())) }; From 178aac64436d5187a07992e5ed2c0e3c2be304e7 Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 17:22:43 +0800 Subject: [PATCH 05/12] docs(mcp): freeze 5 Stable Tools schemas with dedicated reference docs - Promote devkit_hybrid_search from Beta to Stable - Add docs/reference/stable-tools/{health,project_brief,hybrid_search, vault_search,session_recall}.md with frozen input/output schemas, example requests/responses, and error catalogs - Add stable-tools/README.md with stability guarantee contract - Update mcp-tools.md cross-links and tier markings - Add docs/clients/claude/scenarios.md with 5 usage scenarios Co-Authored-By: Claude Opus 4.7 --- docs/clients/claude/scenarios.md | 189 ++++++++++++++++++ docs/reference/mcp-tools.md | 19 +- docs/reference/stable-tools/README.md | 24 +++ docs/reference/stable-tools/health.md | 114 +++++++++++ docs/reference/stable-tools/hybrid_search.md | 93 +++++++++ docs/reference/stable-tools/project_brief.md | 71 +++++++ docs/reference/stable-tools/session_recall.md | 91 +++++++++ docs/reference/stable-tools/vault_search.md | 84 ++++++++ 8 files changed, 679 insertions(+), 6 deletions(-) create mode 100644 docs/clients/claude/scenarios.md create mode 100644 docs/reference/stable-tools/README.md create mode 100644 docs/reference/stable-tools/health.md create mode 100644 docs/reference/stable-tools/hybrid_search.md create mode 100644 docs/reference/stable-tools/project_brief.md create mode 100644 docs/reference/stable-tools/session_recall.md create mode 100644 docs/reference/stable-tools/vault_search.md diff --git a/docs/clients/claude/scenarios.md b/docs/clients/claude/scenarios.md new file mode 100644 index 0000000..1e616f1 --- /dev/null +++ b/docs/clients/claude/scenarios.md @@ -0,0 +1,189 @@ +# Claude Code x devbase 高频使用场景 + +> 本文档定义 5 个 Claude Code 与 devbase 集成的核心使用场景。 +> 每个场景包含:目标、推荐的 tool 序列、Prompt 模板、预期输出、验收标准。 +> +> **版本**: v0.20.0 +> **Stable Tools** (5个): `devkit_health`, `devkit_query_repos`, `devkit_vault_search`, `devkit_vault_read`, `devkit_project_context` +> **Beta Tools** (2个): `devkit_project_brief`, `devkit_impact_analysis` + +--- + +## 场景一:项目初始化(Project Onboarding) + +**目标**:Claude 首次进入项目目录时,秒级建立全景认知,替代暴力文件扫描。 + +**Tool 序列**: +1. `devkit_health` — 确认 devbase 服务就绪 +2. `devkit_project_brief` — 获取 Markdown 项目简报 +3. `devkit_query_repos` — 列出已注册的仓库(如项目含子模块) + +**Prompt 模板**: +``` +我刚进入这个项目目录。请通过 devbase 获取项目全景: +1. 先检查 devbase 健康状态 +2. 生成项目简报(format=markdown) +3. 列出已注册的所有仓库 + +基于这些信息,告诉我: +- 项目的技术栈和核心模块 +- 当前活跃的 Agent Context(如果有) +- 有哪些已知限制(Known Limits)我应该注意 +``` + +**预期收益**: +- Claude 启动扫描时间从数十秒降至 < 2 秒 +- 文件读取次数减少 30%+ + +**验收标准**: +- `devkit_project_brief` 输出包含 ≥ 5 个关键模块 +- 无重复读取同一文件 > 1 次 + +--- + +## 场景二:代码语义探索(Semantic Code Exploration) + +**目标**:用自然语言搜索代码,替代关键词 grep,减少无关文件读取。 + +**Tool 序列**: +1. `devkit_hybrid_search` — 语义 + 关键词混合搜索 +2. `devkit_project_context` — 获取特定文件/符号的上下文 +3. `devkit_vault_search` — 搜索 Vault 笔记中的相关设计决策 + +**Prompt 模板**: +``` +我需要理解这个项目的认证流程。请: +1. 用 hybrid_search 搜索 "authentication flow"(limit=10) +2. 对搜索结果中最重要的 3 个文件,用 project_context 获取详细上下文 +3. 在 Vault 中搜索是否有相关的设计文档或决策记录 + +告诉我: +- 认证相关的核心文件有哪些 +- 它们之间的调用关系 +- Vault 中是否有相关的设计决策或已知限制 +``` + +**预期收益**: +- 搜索结果 Top-3 命中率 ≥ 80% +- 减少 50% 的无关文件读取 + +**验收标准**: +- `hybrid_search` 返回结果中包含至少 1 个真正的认证相关文件 +- `project_context` 输出能支撑代码理解(≥ 3 个相关符号) + +--- + +## 场景三:修改前影响分析(Pre-Edit Impact Analysis) + +**目标**:在 Claude 修改代码前,自动分析影响范围,降低回归风险。 + +**Tool 序列**: +1. `devkit_impact_analysis` — 分析指定修改的影响范围 +2. `devkit_query_repos` — 确认目标仓库状态 +3. `devkit_vault_read` — 读取相关设计文档(如有) + +**Prompt 模板**: +``` +我打算修改 src/registry/repo.rs 中的 save_repo 函数,给它增加一个 +`force_update` 参数。请: +1. 用 impact_analysis 分析这个修改的影响范围 +2. 列出所有调用 save_repo 的位置 +3. 检查 Vault 中是否有关于 repo 保存逻辑的决策记录 + +告诉我: +- 需要同步修改哪些文件 +- 哪些测试会受影响 +- 是否有已知限制与此相关 +``` + +**预期收益**: +- 修改前预知 ≥ 80% 的受影响文件 +- 误报率(false positive)< 20% + +**验收标准**: +- `impact_analysis` 返回的 affected_files 包含实际受影响的文件 +- 无遗漏关键调用链(false negative = 0) + +--- + +## 场景四:Vault 知识检索(Knowledge Retrieval) + +**目标**:在编码过程中快速检索项目笔记、决策记录和已知限制。 + +**Tool 序列**: +1. `devkit_vault_search` — 搜索 Vault 笔记 +2. `devkit_vault_read` — 读取特定笔记全文 +3. `devkit_vault_graph` — 查看笔记的双向链接图谱 + +**Prompt 模板**: +``` +我在处理一个关于 Tantivy 索引一致性的 bug。请: +1. 在 Vault 中搜索 "Tantivy consistency" +2. 读取最相关的笔记全文 +3. 查看该笔记的反向链接(backlinks)和双向链接图谱 + +告诉我: +- 之前关于这个问题的决策或分析 +- 相关的其他笔记有哪些 +- 是否有已知的缓解措施 +``` + +**预期收益**: +- 项目知识查询响应 < 500ms +- 跨笔记关联发现率提升 + +**验收标准**: +- `vault_search` 返回相关笔记的 Top-3 包含目标笔记 +- `vault_graph` 能正确展示双向链接关系 + +--- + +## 场景五:会话恢复与上下文延续(Session Recovery) + +**目标**:Claude 会话结束后,下次启动时能恢复关键上下文和决策。 + +**Tool 序列**: +1. `devkit_session_list` — 列出历史会话 +2. `devkit_session_recall` — 恢复指定会话的记忆 +3. `devkit_session_export` / `devkit_session_import` — 跨工具迁移 + +**Prompt 模板**: +``` +昨天我和 devbase 一起分析了这个项目的架构问题,今天继续。 +请: +1. 列出最近 5 个会话 +2. 找到昨天关于 "架构拆分" 的会话并恢复其记忆 +3. 如果我要切换到 Kimi CLI,请导出当前会话上下文 + +告诉我: +- 昨天做了哪些分析 +- 关键决策或发现是什么 +- 导出的文件路径 +``` + +**预期收益**: +- 跨会话上下文不丢失 +- 支持 Claude ↔ Kimi 的会话迁移 + +**验收标准**: +- `session_recall` 能正确注入历史决策到当前上下文 +- `session_export` → `session_import` 链路数据完整 + +--- + +## 测量与反馈 + +每个场景执行后,Claude 应记录以下指标到 `mcp-oplog.ndjson`: + +```json +{ + "scenario": "project_onboarding", + "tools_called": ["devkit_health", "devkit_project_brief", "devkit_query_repos"], + "total_duration_ms": 1250, + "success": true, + "file_reads_avoided": 12, + "user_satisfaction": "high" +} +``` + +**Dogfooding 周期**:连续 7 天,每天至少执行 3 个场景,记录 tool 调用成功率和响应时间。 diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 0853c18..46143ac 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -1,8 +1,8 @@ # MCP Tools 参考 -devbase MCP Server 提供 **38 个 tools**,通过 stdio 传输与 AI Agent 通信。工具按稳定性分为三级: +devbase MCP Server 提供 **40 个 tools**,通过 stdio 传输与 AI Agent 通信。工具按稳定性分为三级: -- **Stable** — 经过充分测试,schema 冻结 +- **Stable** — 经过充分测试,schema 冻结。详见 [`stable-tools/`](stable-tools/README.md) 独立文档。 - **Beta** — 功能验证通过,schema 可能微调 - **Experimental** — 新功能,行为可能变化 @@ -13,7 +13,7 @@ devbase MCP Server 提供 **38 个 tools**,通过 stdio 传输与 AI Agent 通 | 工具名 | Tier | 一句话描述 | 关键参数 | |--------|------|-----------|----------| | `devkit_scan` | Beta | 扫描目录发现 Git 仓库并注册 | `path`, `register` | -| `devkit_health` | Stable | 检查注册仓库的健康状态(dirty/behind/ahead) | `detail`, `limit`, `page` | +| [`devkit_health`](stable-tools/health.md) | Stable | 检查注册仓库的健康状态(dirty/behind/ahead) | `detail`, `limit`, `page` | | `devkit_sync` | Beta | 安全同步仓库与上游(destructive gate) | `repo_id`, `dry_run` | | `devkit_query_repos` | Stable | 查询已注册仓库列表,支持 tag/language 过滤 | `query`, `limit`, `page` | | `devkit_index` | Beta | 索引仓库摘要、模块结构、代码符号 | `path` | @@ -34,7 +34,7 @@ devbase MCP Server 提供 **38 个 tools**,通过 stdio 传输与 AI Agent 通 | 工具名 | Tier | 一句话描述 | 关键参数 | |--------|------|-----------|----------| | `devkit_semantic_search` | Beta | 基于 embedding 的语义代码搜索 | `repo_id`, `query`, `limit` | -| `devkit_hybrid_search` | Beta | 向量语义 + 关键词 RRF 混合搜索 | `repo_id`, `query`, `limit` | +| [`devkit_hybrid_search`](stable-tools/hybrid_search.md) | Stable | 向量语义 + 关键词 RRF 混合搜索 | `repo_id`, `query`, `limit` | | `devkit_cross_repo_search` | Beta | 跨仓库符号搜索(按 tag 过滤) | `tags`, `query`, `limit` | | `devkit_related_symbols` | Experimental | 查找与指定符号相关的符号 | `repo_id`, `symbol_name` | | `devkit_embedding_store` | Beta | 存储代码符号的 embedding 向量 | `repo_id`, `symbol_name`, `embedding` | @@ -46,7 +46,7 @@ devbase MCP Server 提供 **38 个 tools**,通过 stdio 传输与 AI Agent 通 | 工具名 | Tier | 一句话描述 | 关键参数 | |--------|------|-----------|----------| -| `devkit_vault_search` | Stable | 关键词搜索 Vault 笔记 | `query` | +| [`devkit_vault_search`](stable-tools/vault_search.md) | Stable | 关键词搜索 Vault 笔记 | `query` | | `devkit_vault_read` | Stable | 读取指定 Vault 笔记的完整内容 | `path` | | `devkit_vault_write` | Beta | 写入或更新 Vault 笔记(destructive gate) | `path`, `content`, `frontmatter` | | `devkit_vault_backlinks` | Beta | 查找指向指定笔记的反向链接 | `note_id` | @@ -60,11 +60,18 @@ devbase MCP Server 提供 **38 个 tools**,通过 stdio 传输与 AI Agent 通 | `devkit_skill_run` | Beta | 执行指定 Skill(destructive gate) | `skill_id`, `args` | | `devkit_skill_discover` | Beta | 将当前项目封装为 Skill(destructive gate,dry_run 默认 true) | `path` | -## 项目上下文(1) +## 项目上下文(2) | 工具名 | Tier | 一句话描述 | 关键参数 | |--------|------|-----------|----------| | `devkit_project_context` | Stable | 获取项目统一上下文(repo + vault + assets + modules + symbols + calls) | `project` | +| [`devkit_project_brief`](stable-tools/project_brief.md) | Stable | 生成 Markdown 项目摘要(架构 + 活动 + 限制),供 LLM 注入 | `repo_id`, `max_tokens` | + +## Session 管理(1) + +| 工具名 | Tier | 一句话描述 | 关键参数 | +|--------|------|-----------|----------| +| [`devkit_session_recall`](stable-tools/session_recall.md) | Stable | 基于 embedding 的语义记忆召回 | `context_id`, `query_embedding`, `limit` | ## 其他(10) diff --git a/docs/reference/stable-tools/README.md b/docs/reference/stable-tools/README.md new file mode 100644 index 0000000..66e3b36 --- /dev/null +++ b/docs/reference/stable-tools/README.md @@ -0,0 +1,24 @@ +# Stable Tools Reference + +Tools in this directory have **frozen schemas** as of devbase v0.21.0. +Breaking changes require a major version bump and a deprecation cycle. + +| Tool | Purpose | File | +|------|---------|------| +| [`devkit_health`](health.md) | Check Git health (dirty/ahead/behind) of all registered repos | `repo.rs` | +| [`devkit_project_brief`](project_brief.md) | Generate a Markdown project brief for LLM context injection | `brief.rs` | +| [`devkit_hybrid_search`](hybrid_search.md) | Vector + keyword RRF search for code symbols | `search.rs` | +| [`devkit_vault_search`](vault_search.md) | Keyword search across Vault notes (titles, tags, content) | `vault.rs` | +| [`devkit_session_recall`](session_recall.md) | Semantic memory recall by embedding similarity | `session.rs` | + +## Schema stability guarantee + +- Input JSON Schema: frozen — no required fields added/removed without deprecation +- Output JSON structure: frozen — fields may be added but never removed or retyped +- Semantic behavior: frozen — matching logic, fallback behavior, and error modes are stable + +## Changelog + +| Version | Change | +|---------|------------------------------------------| +| v0.21.0 | 5 tools promoted to Stable; schemas frozen | diff --git a/docs/reference/stable-tools/health.md b/docs/reference/stable-tools/health.md new file mode 100644 index 0000000..da1e1d8 --- /dev/null +++ b/docs/reference/stable-tools/health.md @@ -0,0 +1,114 @@ +# devkit_health + +> **Tier**: Stable (frozen at v0.21.0) +> **Source**: `src/mcp/tools/repo.rs` — `DevkitHealthTool` + +Check the health status of all registered repositories. Read-only diagnostic tool. + +## Purpose + +- Get an overview of all tracked repos and their Git status +- Identify repos that are dirty, ahead, behind, or diverged +- Check environment prerequisites (Rust, Go, Node.js, CMake versions) +- Find repos that need attention before a sync + +## When NOT to use + +- Pulling or pushing changes → use `devkit_sync` +- Searching repos by language or tag → use `devkit_query_repos` +- Scanning new directories → use `devkit_scan` + +## Input Schema + +```json +{ + "type": "object", + "properties": { + "detail": { + "type": "boolean", + "description": "Show detailed per-repo status", + "default": false + } + } +} +``` + +| Parameter | Type | Required | Default | Description | +|-----------|---------|----------|---------|--------------------------------------| +| `detail` | boolean | No | `false` | If true, returns per-repo Git status | + +## Output Schema + +### Summary mode (`detail: false`) + +```json +{ + "success": true, + "summary": { + "total_repos": 12, + "dirty_repos": 2, + "behind_upstream": 3, + "no_upstream": 1 + }, + "environment": { + "rustc": "1.85.0", + "cargo": "1.85.0", + "node": "22.14.0", + "go": "go1.24.2", + "cmake": "3.31.6", + "python": "3.13.3", + "bun": "1.2.10", + "zig": "0.14.0", + "java": "21.0.6" + } +} +``` + +### Detail mode (`detail: true`) + +```json +{ + "success": true, + "summary": { "total_repos": 12, "dirty_repos": 2, "behind_upstream": 3, "no_upstream": 1 }, + "environment": { "rustc": "1.85.0", ... }, + "repos": [ + { + "id": "devbase", + "local_path": "C:\\Users\\dev\\devbase", + "upstream_url": "https://github.com/user/devbase", + "default_branch": "main", + "status": "dirty", + "ahead": 0, + "behind": 0, + "workspace_type": "git", + "data_tier": "private" + } + ] +} +``` + +### Repo status values + +| Status | Meaning | +|--------------|-------------------------------------------| +| `ok` | Clean, up to date with upstream | +| `dirty` | Uncommitted changes in working tree | +| `ahead` | Local commits not pushed | +| `behind` | Remote commits not pulled | +| `diverged` | Both ahead and behind | +| `no_upstream`| No remote configured | +| `error` | Git repository could not be opened | +| `detached` | HEAD is detached | + +## Errors + +| Error | Cause | +|------------------------------|------------------------------------------| +| Database connection failed | SQLite locked or corrupted | +| Git repository unreadable | Path no longer exists or permissions | + +## Changelog + +| Version | Change | +|---------|------------------------------------------| +| v0.21.0 | Schema frozen as Stable | diff --git a/docs/reference/stable-tools/hybrid_search.md b/docs/reference/stable-tools/hybrid_search.md new file mode 100644 index 0000000..917f25b --- /dev/null +++ b/docs/reference/stable-tools/hybrid_search.md @@ -0,0 +1,93 @@ +# devkit_hybrid_search + +> **Tier**: Stable (frozen at v0.21.0) +> **Source**: `src/mcp/tools/search.rs` — `DevkitHybridSearchTool` + +Hybrid code symbol search combining vector embeddings and keyword matching via Reciprocal Rank Fusion (RRF). + +## Purpose + +- Find code related to a concept ("authentication", "error handling") +- Search with either natural language or an embedding vector +- Get robust results even when the embedding provider is offline + +## When NOT to use + +- Exact keyword searches → use `devkit_natural_language_query` +- Finding symbol definitions by exact name → use `devkit_code_symbols` +- When no embeddings exist and no keyword query is available + +## Input Schema + +```json +{ + "type": "object", + "properties": { + "repo_id": { "type": "string" }, + "query_text": { "type": "string", "description": "Keyword or natural language query" }, + "query_embedding": { + "type": "array", + "items": { "type": "number" }, + "description": "Optional query embedding vector" + }, + "limit": { "type": "integer", "default": 10 } + }, + "required": ["repo_id", "query_text"] +} +``` + +| Parameter | Type | Required | Default | Description | +|-----------------|------------|----------|---------|--------------------------------------------| +| `repo_id` | string | Yes | — | Registered repository ID | +| `query_text` | string | Yes | — | Keyword or natural language query | +| `query_embedding`| number[] | No | — | Optional f32 vector for semantic search | +| `limit` | integer | No | 10 | Max results (capped at 50) | + +## Behavior + +| Scenario | Behavior | +|---------------------------------------|---------------------------------------------------| +| `query_embedding` provided | RRF fusion: vector similarity (70%) + keyword (30%) | +| `query_embedding` omitted | Falls back to pure keyword search on symbol names/signatures | +| No embeddings exist for repo | Gracefully degrades to keyword search | +| Embedding generation fails | Warns in logs, falls back to keyword search | + +## Output Schema + +```json +{ + "success": true, + "repo_id": "devbase", + "query_text": "error handling", + "count": 3, + "symbols": [ + { + "name": "handle_error", + "file_path": "src/errors.rs", + "line_start": 42, + "similarity_score": 0.87 + } + ] +} +``` + +| Field | Type | Description | +|------------------|---------|------------------------------------------| +| `name` | string | Symbol name | +| `file_path` | string | Relative file path in the repo | +| `line_start` | integer | Line number where symbol begins | +| `similarity_score`| number | RRF score (0.0–1.0, higher is better) | + +## Errors + +| Error | Cause | +|--------------------|------------------------------------------| +| `repo_id required` | Missing `repo_id` | +| `query_text required`| Missing `query_text` | +| Database error | SQLite query failure | + +## Changelog + +| Version | Change | +|---------|------------------------------------------| +| v0.21.0 | Schema frozen as Stable | diff --git a/docs/reference/stable-tools/project_brief.md b/docs/reference/stable-tools/project_brief.md new file mode 100644 index 0000000..f668eaf --- /dev/null +++ b/docs/reference/stable-tools/project_brief.md @@ -0,0 +1,71 @@ +# devkit_project_brief + +> **Tier**: Stable (frozen at v0.21.0) +> **Source**: `src/mcp/tools/brief.rs` — `DevkitProjectBriefTool` + +Generate a Markdown project brief optimized for LLM context injection. + +## Purpose + +- Summarize a repository's architecture, symbols, and recent activity +- Produce a concise context document for LLM prompts +- Surface known limits, active contexts, and hot files + +## When NOT to use + +- Searching for specific symbols → use `devkit_code_symbols` +- Reading full source files → use filesystem tools +- Getting Git health status → use `devkit_health` + +## Input Schema + +```json +{ + "type": "object", + "properties": { + "repo_id": { "type": "string" }, + "max_tokens": { "type": "integer", "default": 2000 } + }, + "required": ["repo_id"] +} +``` + +| Parameter | Type | Required | Default | Description | +|--------------|---------|----------|---------|---------------------------------------------| +| `repo_id` | string | Yes | — | Registered repository ID | +| `max_tokens` | integer | No | 2000 | Approximate token budget (1 token ~ 4 chars)| + +## Output Schema + +```json +{ + "success": true, + "repo_id": "devbase", + "brief": "# Project Brief: devbase\n\n## Overview\n- **Language**: rust\n- **Tags**: cli, rust, active\n- **Path**: `C:\\Users\\dev\\devbase`\n\n## Architecture\n- `main` (function)\n- `scan` (function)\n..." +} +``` + +### Brief sections (in order) + +1. **Overview** — language, tags, local path +2. **Architecture** — modules (up to 20) and key symbols (up to 15) +3. **Recent Activity** — last 7 commits, hot files (14d change count) +4. **Known Limits & Tech Debt** — open known_limits entries (up to 10) +5. **Active Contexts** — linked agent contexts with memories + +### Truncation behavior + +If the generated brief exceeds `max_tokens * 4` characters, it is truncated at the nearest section boundary (`\n## `) with an ellipsis note. + +## Errors + +| Error | Cause | +|--------------------|-------------------------------------------------| +| `repo_id required` | Missing or empty `repo_id` argument | +| `repo not found` | `repo_id` does not exist in the registry | + +## Changelog + +| Version | Change | +|---------|------------------------------------------| +| v0.21.0 | Schema frozen as Stable | diff --git a/docs/reference/stable-tools/session_recall.md b/docs/reference/stable-tools/session_recall.md new file mode 100644 index 0000000..5d1a7dd --- /dev/null +++ b/docs/reference/stable-tools/session_recall.md @@ -0,0 +1,91 @@ +# devkit_session_recall + +> **Tier**: Stable (frozen at v0.21.0) +> **Source**: `src/mcp/tools/session.rs` — `DevkitSessionRecallTool` + +Semantic memory recall for an active agent session. Finds relevant past memories by meaning rather than exact keyword. + +## Purpose + +- Surface decisions, constraints, or discoveries related to the current task +- Inject top-k relevant memories into prompt context +- Recall what was discussed in a previous project session + +## When NOT to use + +- Keyword-based memory search → use `devkit_session_search` +- Listing all sessions → use `devkit_session_list` +- Saving a new memory → use `devkit_session_capture` +- When embeddings have not been stored for memories → use `devkit_session_index` first + +## Input Schema + +```json +{ + "type": "object", + "properties": { + "context_id": { "type": "string", "description": "Session ID (optional)" }, + "query_embedding": { + "type": "array", + "items": { "type": "number" }, + "description": "Query vector as f32 array (externally generated)" + }, + "limit": { "type": "integer", "default": 5 } + }, + "required": ["query_embedding"] +} +``` + +| Parameter | Type | Required | Default | Description | +|-----------------|------------|----------|---------|--------------------------------------------| +| `context_id` | string | No | — | Session ID. Falls back to `DEVBASE_ACTIVE_CONTEXT` env var or `.active_context` state file | +| `query_embedding`| number[] | Yes | — | Externally-generated f32 embedding vector | +| `limit` | integer | No | 5 | Max results (capped at 20) | + +## Important: Embedding source + +devbase does **NOT** generate embeddings. The caller must provide a pre-computed vector from an external provider (Ollama, OpenAI, etc.). Use the same model that was used to index the memories via `devkit_session_index`. + +## Output Schema + +```json +{ + "success": true, + "context_id": "project-alpha", + "count": 3, + "memories": [ + { + "id": 42, + "type": "decision", + "content": "Use SQLite WAL mode for concurrent reads", + "created_at": "2026-05-10T14:32:00Z", + "embedding_model": "nomic-embed-text", + "score": 0.91 + } + ] +} +``` + +| Field | Type | Description | +|-------------------|---------|------------------------------------------| +| `id` | integer | Memory row ID | +| `type` | string | Memory classification: decision, constraint, note, discovery, error, action | +| `content` | string | Full memory text | +| `created_at` | string | ISO 8601 timestamp | +| `embedding_model` | string | Model used when memory was indexed | +| `score` | number | Cosine similarity (0.0–1.0) | + +## Errors + +| Error | Cause | +|------------------------------|-----------------------------------------------------| +| `query_embedding required` | Missing or empty embedding array | +| `query_embedding must not be empty` | Array contains no valid f32 values | +| No active session | `context_id` omitted and no active session set | +| Memory not found | `memory_id` in `devkit_session_index` does not exist| + +## Changelog + +| Version | Change | +|---------|------------------------------------------| +| v0.21.0 | Schema frozen as Stable | diff --git a/docs/reference/stable-tools/vault_search.md b/docs/reference/stable-tools/vault_search.md new file mode 100644 index 0000000..eece042 --- /dev/null +++ b/docs/reference/stable-tools/vault_search.md @@ -0,0 +1,84 @@ +# devkit_vault_search + +> **Tier**: Stable (frozen at v0.21.0) +> **Source**: `src/mcp/tools/vault.rs` — `DevkitVaultSearchTool` + +Search the devbase Vault (Markdown notes) by keywords across note titles, tags, and full content. + +## Purpose + +- Find notes related to a topic, architecture decision, or project +- Discover linked concepts via tags or wikilinks +- Locate a note when you only remember fragments of its content +- Check if a topic has been documented before writing a new note + +## When NOT to use + +- Reading the full content of a known note → use `devkit_vault_read` +- Writing or updating notes → use `devkit_vault_write` +- Finding backlinks to a specific note → use `devkit_vault_backlinks` +- Searching across code repositories → use `devkit_query_repos` + +## Input Schema + +```json +{ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search keywords" } + }, + "required": ["query"] +} +``` + +| Parameter | Type | Required | Default | Description | +|-----------|--------|----------|---------|--------------------------------| +| `query` | string | Yes | — | Space-separated keywords (AND) | + +## Matching behavior + +- All keywords must match (AND logic) +- Case-insensitive matching across: + - Note ID + - Note title + - Tags (comma-joined) + - Full Markdown body content +- No stemming or fuzzy matching — exact substring only + +## Output Schema + +```json +{ + "success": true, + "count": 2, + "query": "mcp integration", + "notes": [ + { + "id": "mcp-integration-guide", + "title": "MCP Integration Guide", + "path": "references/mcp-integration.md", + "tags": ["mcp", "integration", "architecture"] + } + ] +} +``` + +| Field | Type | Description | +|---------|----------|------------------------------------------| +| `id` | string | Note identifier (usually filename stem) | +| `title` | string | Parsed from YAML frontmatter | +| `path` | string | Vault-relative file path | +| `tags` | string[] | Parsed from YAML frontmatter | + +## Errors + +| Error | Cause | +|--------------------|------------------------------------------| +| `query required` | Missing or empty `query` argument | +| Vault unreadable | Vault directory missing or permission denied | + +## Changelog + +| Version | Change | +|---------|------------------------------------------| +| v0.21.0 | Schema frozen as Stable | From 3b711dc6a97fc2e7f4c5f7f6de84ba4f2e19845e Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 17:27:46 +0800 Subject: [PATCH 06/12] test(registry): Wave 2 coverage for code-symbols, call-graph, dead-code - code-symbols: 9 tests covering query_all, type filter, name filter, file_path filter, combined filters, limit, cross-repo isolation, optional field preservation - call-graph: 8 tests covering all_edges, callee/caller/file filters, combined filters, limit, cross-repo isolation - dead-code: 6 tests covering include_pub/exclude_pub, caller exclusion, tests.rs exclusion, limit, empty repo - All 3 crates raised from 0% to functional coverage Co-Authored-By: Claude Opus 4.7 --- crates/devbase-registry-call-graph/src/lib.rs | 131 +++++++++++++++++ .../devbase-registry-code-symbols/src/lib.rs | 137 ++++++++++++++++++ crates/devbase-registry-dead-code/src/lib.rs | 133 +++++++++++++++++ 3 files changed, 401 insertions(+) diff --git a/crates/devbase-registry-call-graph/src/lib.rs b/crates/devbase-registry-call-graph/src/lib.rs index febff72..987793c 100644 --- a/crates/devbase-registry-call-graph/src/lib.rs +++ b/crates/devbase-registry-call-graph/src/lib.rs @@ -60,3 +60,134 @@ pub fn query_call_edges( rows.collect::, _>>().map_err(Into::into) } + +#[cfg(test)] +mod tests { + use super::*; + + fn init_in_memory() -> rusqlite::Connection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE code_call_graph ( + id INTEGER PRIMARY KEY, + repo_id TEXT NOT NULL, + caller_file TEXT NOT NULL, + caller_symbol TEXT NOT NULL, + caller_line INTEGER, + callee_name TEXT NOT NULL + )", + [], + ) + .unwrap(); + conn + } + + fn seed_edges(conn: &rusqlite::Connection) { + let edges = [ + ("repo-a", "src/main.rs", "main", 10, "helper"), + ("repo-a", "src/main.rs", "main", 15, "process"), + ("repo-a", "src/lib.rs", "helper", 5, "util"), + ("repo-a", "src/lib.rs", "process", 20, "util"), + ("repo-b", "src/main.rs", "entry", 1, "init"), + ]; + for (repo, file, caller, line, callee) in edges { + conn.execute( + "INSERT INTO code_call_graph (repo_id, caller_file, caller_symbol, caller_line, callee_name) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![repo, file, caller, line, callee], + ) + .unwrap(); + } + } + + #[test] + fn test_query_all_edges() { + let conn = init_in_memory(); + seed_edges(&conn); + + let results = query_call_edges(&conn, "repo-a", None, None, None, 100).unwrap(); + assert_eq!(results.len(), 4); + } + + #[test] + fn test_query_by_callee() { + let conn = init_in_memory(); + seed_edges(&conn); + + let results = query_call_edges(&conn, "repo-a", Some("util"), None, None, 100).unwrap(); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|e| e.callee_name == "util")); + } + + #[test] + fn test_query_by_caller() { + let conn = init_in_memory(); + seed_edges(&conn); + + let results = query_call_edges( + &conn, "repo-a", None, Some("main"), None, 100, + ) + .unwrap(); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|e| e.caller_symbol == "main")); + } + + #[test] + fn test_query_by_file_path() { + let conn = init_in_memory(); + seed_edges(&conn); + + let results = query_call_edges( + &conn, "repo-a", None, None, Some("lib"), 100, + ) + .unwrap(); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|e| e.caller_file.contains("lib"))); + } + + #[test] + fn test_query_combined_filters() { + let conn = init_in_memory(); + seed_edges(&conn); + + let results = query_call_edges( + &conn, "repo-a", Some("util"), Some("helper"), None, 100, + ) + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].caller_symbol, "helper"); + assert_eq!(results[0].callee_name, "util"); + } + + #[test] + fn test_query_empty_result() { + let conn = init_in_memory(); + seed_edges(&conn); + + let results = query_call_edges( + &conn, "repo-a", Some("nonexistent"), None, None, 100, + ) + .unwrap(); + assert!(results.is_empty()); + } + + #[test] + fn test_query_cross_repo_isolation() { + let conn = init_in_memory(); + seed_edges(&conn); + + let repo_a = query_call_edges(&conn, "repo-a", None, None, None, 100).unwrap(); + let repo_b = query_call_edges(&conn, "repo-b", None, None, None, 100).unwrap(); + assert_eq!(repo_a.len(), 4); + assert_eq!(repo_b.len(), 1); + } + + #[test] + fn test_query_limit() { + let conn = init_in_memory(); + seed_edges(&conn); + + let results = query_call_edges(&conn, "repo-a", None, None, None, 2).unwrap(); + assert_eq!(results.len(), 2); + } +} diff --git a/crates/devbase-registry-code-symbols/src/lib.rs b/crates/devbase-registry-code-symbols/src/lib.rs index 3e5b672..8cec767 100644 --- a/crates/devbase-registry-code-symbols/src/lib.rs +++ b/crates/devbase-registry-code-symbols/src/lib.rs @@ -62,3 +62,140 @@ pub fn query_code_symbols( rows.collect::, _>>().map_err(Into::into) } + +#[cfg(test)] +mod tests { + use super::*; + + fn init_in_memory() -> rusqlite::Connection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE code_symbols ( + id INTEGER PRIMARY KEY, + repo_id TEXT NOT NULL, + file_path TEXT NOT NULL, + symbol_type TEXT NOT NULL, + name TEXT NOT NULL, + line_start INTEGER, + line_end INTEGER, + signature TEXT, + attributes TEXT + )", + [], + ) + .unwrap(); + conn + } + + fn seed_symbols(conn: &rusqlite::Connection) { + let symbols = [ + ("repo-a", "src/main.rs", "function", "main", 1, 10, None, None), + ("repo-a", "src/lib.rs", "function", "helper", 5, 15, Some("fn helper()"), None), + ("repo-a", "src/lib.rs", "struct", "Config", 20, 30, None, Some("derive(Debug)")), + ("repo-a", "src/models.rs", "struct", "User", 1, 20, None, None), + ("repo-a", "src/models.rs", "function", "new_user", 25, 35, Some("fn new_user() -> User"), None), + ("repo-b", "src/main.rs", "function", "entry", 1, 5, None, None), + ]; + for (repo, path, ty, name, start, end, sig, attrs) in symbols { + conn.execute( + "INSERT INTO code_symbols (repo_id, file_path, symbol_type, name, line_start, line_end, signature, attributes) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![repo, path, ty, name, start, end, sig, attrs], + ) + .unwrap(); + } + } + + #[test] + fn test_query_all_symbols() { + let conn = init_in_memory(); + seed_symbols(&conn); + + let results = query_code_symbols(&conn, "repo-a", None, None, None, 100).unwrap(); + assert_eq!(results.len(), 5); + } + + #[test] + fn test_query_by_symbol_type() { + let conn = init_in_memory(); + seed_symbols(&conn); + + let structs = query_code_symbols(&conn, "repo-a", None, Some("struct"), None, 100).unwrap(); + assert_eq!(structs.len(), 2); + assert!(structs.iter().all(|s| s.symbol_type == "struct")); + } + + #[test] + fn test_query_by_name_filter() { + let conn = init_in_memory(); + seed_symbols(&conn); + + let results = query_code_symbols(&conn, "repo-a", Some("user"), None, None, 100).unwrap(); + assert_eq!(results.len(), 2); // User and new_user + assert!(results.iter().any(|s| s.name == "User")); + assert!(results.iter().any(|s| s.name == "new_user")); + } + + #[test] + fn test_query_by_file_path() { + let conn = init_in_memory(); + seed_symbols(&conn); + + let results = query_code_symbols(&conn, "repo-a", None, None, Some("models"), 100).unwrap(); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|s| s.file_path.contains("models"))); + } + + #[test] + fn test_query_combined_filters() { + let conn = init_in_memory(); + seed_symbols(&conn); + + let results = query_code_symbols(&conn, "repo-a", None, Some("struct"), Some("lib"), 100).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "Config"); + } + + #[test] + fn test_query_empty_result() { + let conn = init_in_memory(); + seed_symbols(&conn); + + let results = query_code_symbols(&conn, "repo-a", Some("nonexistent"), None, None, 100).unwrap(); + assert!(results.is_empty()); + } + + #[test] + fn test_query_limit() { + let conn = init_in_memory(); + seed_symbols(&conn); + + let results = query_code_symbols(&conn, "repo-a", None, None, None, 3).unwrap(); + assert_eq!(results.len(), 3); + } + + #[test] + fn test_query_cross_repo_isolation() { + let conn = init_in_memory(); + seed_symbols(&conn); + + let repo_a = query_code_symbols(&conn, "repo-a", None, None, None, 100).unwrap(); + let repo_b = query_code_symbols(&conn, "repo-b", None, None, None, 100).unwrap(); + assert_eq!(repo_a.len(), 5); + assert_eq!(repo_b.len(), 1); + } + + #[test] + fn test_query_preserves_optional_fields() { + let conn = init_in_memory(); + seed_symbols(&conn); + + let results = query_code_symbols(&conn, "repo-a", Some("helper"), None, None, 100).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].signature, Some("fn helper()".to_string())); + assert_eq!(results[0].attributes, None); + + let results = query_code_symbols(&conn, "repo-a", Some("Config"), None, None, 100).unwrap(); + assert_eq!(results[0].attributes, Some("derive(Debug)".to_string())); + } +} diff --git a/crates/devbase-registry-dead-code/src/lib.rs b/crates/devbase-registry-dead-code/src/lib.rs index b0436ff..f193c6c 100644 --- a/crates/devbase-registry-dead-code/src/lib.rs +++ b/crates/devbase-registry-dead-code/src/lib.rs @@ -60,3 +60,136 @@ pub fn query_dead_code( rows.collect::, _>>().map_err(Into::into) } + +#[cfg(test)] +mod tests { + use super::*; + + fn init_in_memory() -> rusqlite::Connection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + r#" + CREATE TABLE code_symbols ( + id INTEGER PRIMARY KEY, + repo_id TEXT NOT NULL, + file_path TEXT NOT NULL, + symbol_type TEXT NOT NULL, + name TEXT NOT NULL, + line_start INTEGER, + line_end INTEGER, + signature TEXT, + attributes TEXT + ); + CREATE TABLE code_call_graph ( + id INTEGER PRIMARY KEY, + repo_id TEXT NOT NULL, + caller_file TEXT NOT NULL, + caller_symbol TEXT NOT NULL, + caller_line INTEGER, + callee_name TEXT NOT NULL + ); + "#, + ) + .unwrap(); + conn + } + + fn seed_data(conn: &rusqlite::Connection) { + // Functions: some have callers, some don't + let symbols = [ + ("repo-a", "src/main.rs", "function", "main", 1, 5, Some("pub fn main()"), None::<&str>), + ("repo-a", "src/lib.rs", "function", "helper", 10, 20, Some("fn helper()"), None::<&str>), + ("repo-a", "src/lib.rs", "function", "unused_fn", 30, 40, Some("fn unused_fn()"), None::<&str>), + ("repo-a", "src/lib.rs", "function", "pub_api", 50, 60, Some("pub fn pub_api()"), None::<&str>), + ("repo-a", "src/tests.rs", "function", "test_something", 1, 10, Some("fn test_something()"), None::<&str>), + ("repo-a", "src/utils.rs", "function", "test_helper", 1, 5, Some("fn test_helper()"), None::<&str>), + ("repo-a", "src/utils.rs", "struct", "Config", 10, 15, None::<&str>, None::<&str>), + ]; + for (repo, path, ty, name, start, end, sig, attrs) in symbols { + conn.execute( + "INSERT INTO code_symbols (repo_id, file_path, symbol_type, name, line_start, line_end, signature, attributes) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![repo, path, ty, name, start, end, sig, attrs], + ) + .unwrap(); + } + + // Call edges: main -> helper, main -> unused_fn (so unused_fn HAS a caller) + let edges = [ + ("repo-a", "src/main.rs", "main", 3, "helper"), + ("repo-a", "src/main.rs", "main", 4, "unused_fn"), + ]; + for (repo, file, caller, line, callee) in edges { + conn.execute( + "INSERT INTO code_call_graph (repo_id, caller_file, caller_symbol, caller_line, callee_name) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![repo, file, caller, line, callee], + ) + .unwrap(); + } + } + + #[test] + fn test_query_dead_code_include_pub() { + let conn = init_in_memory(); + seed_data(&conn); + + // include_pub=true: should find pub_api (no callers) but not main (excluded by name rule) + let results = query_dead_code(&conn, "repo-a", true, 100).unwrap(); + assert!(results.iter().any(|f| f.name == "pub_api")); + assert!(!results.iter().any(|f| f.name == "main")); + assert!(!results.iter().any(|f| f.name == "test_something")); + assert!(!results.iter().any(|f| f.name == "test_helper")); + // Structs are not functions, so Config should not appear + assert!(!results.iter().any(|f| f.name == "Config")); + } + + #[test] + fn test_query_dead_code_exclude_pub() { + let conn = init_in_memory(); + seed_data(&conn); + + // include_pub=false: pub_api excluded by signature heuristic + let results = query_dead_code(&conn, "repo-a", false, 100).unwrap(); + assert!(!results.iter().any(|f| f.name == "pub_api")); + } + + #[test] + fn test_query_dead_code_excludes_called_functions() { + let conn = init_in_memory(); + seed_data(&conn); + + // helper and unused_fn both have callers, so they should NOT appear + let results = query_dead_code(&conn, "repo-a", true, 100).unwrap(); + assert!(!results.iter().any(|f| f.name == "helper")); + assert!(!results.iter().any(|f| f.name == "unused_fn")); + } + + #[test] + fn test_query_dead_code_excludes_tests_rs() { + let conn = init_in_memory(); + seed_data(&conn); + + let results = query_dead_code(&conn, "repo-a", true, 100).unwrap(); + // test_something is in tests.rs, excluded by file path rule + assert!(!results.iter().any(|f| f.name == "test_something")); + } + + #[test] + fn test_query_dead_code_limit() { + let conn = init_in_memory(); + seed_data(&conn); + + let results = query_dead_code(&conn, "repo-a", true, 1).unwrap(); + assert_eq!(results.len(), 1); + } + + #[test] + fn test_query_dead_code_empty_repo() { + let conn = init_in_memory(); + // No data seeded + + let results = query_dead_code(&conn, "repo-x", true, 100).unwrap(); + assert!(results.is_empty()); + } +} From 96fbfd5d39f4a4a3dd23202f8c7aa5cbfb3e2cd4 Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 18:14:23 +0800 Subject: [PATCH 07/12] feat(mcp): scenario E2E tests, oplog tracing, and vault_search fix Scenario validation tests for Claude onboarding and semantic code exploration revealed a production-grade silent-failure bug in devkit_vault_search: VaultNote deserialization from partial JSON failed and was masked by unwrap_or_default(), causing all queries to return empty results. - Add append_mcp_oplog() NDJSON tracing for tool call latency and error classification - Add seed_scenario_data() + two scenario integration tests - Fix vault_search to operate on serde_json::Value directly, eliminating the deserialization trap Co-Authored-By: Claude Opus 4.7 --- src/mcp/mod.rs | 109 ++++++++++++++++++--------- src/mcp/tests.rs | 163 +++++++++++++++++++++++++++++++++++++++++ src/mcp/tools/vault.rs | 44 ++++++----- 3 files changed, 259 insertions(+), 57 deletions(-) diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index c85765c..d47a93a 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -449,6 +449,39 @@ impl McpTool for McpToolEnum { } } +/// Append a single MCP tool invocation record to the oplog file. +/// +/// Path: `%LOCALAPPDATA%/devbase/mcp-oplog.ndjson` +/// Format: newline-delimited JSON (NDJSON) +fn append_mcp_oplog( + tool_name: &str, + duration_ms: u128, + success: bool, + error_type: Option<&str>, +) { + let entry = serde_json::json!({ + "timestamp": chrono::Utc::now().to_rfc3339(), + "tool": tool_name, + "duration_ms": duration_ms, + "success": success, + "error_type": error_type, + }); + + if let Some(data_dir) = dirs::data_local_dir() { + let log_path = data_dir.join("devbase").join("mcp-oplog.ndjson"); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + use std::io::Write; + if let Err(e) = writeln!(file, "{}", entry) { + tracing::warn!("Failed to write MCP oplog: {}", e); + } + } + } +} + pub struct McpServer { tools: HashMap, } @@ -523,8 +556,10 @@ impl McpServer { match self.tools.get(name) { Some(_tool) if stream => { + let start = std::time::Instant::now(); match self.handle_streaming_call(name, args, ctx).await { Ok(events) => { + append_mcp_oplog(name, start.elapsed().as_millis(), true, None); let events_json = serde_json::to_string(&events)?; let content = serde_json::json!({ "type": "text", @@ -540,6 +575,7 @@ impl McpServer { })) } Err(e) => { + append_mcp_oplog(name, start.elapsed().as_millis(), false, Some("invoke_error")); let payload = serde_json::json!({ "success": false, "error": e.to_string() }); let text = serde_json::to_string(&payload)?; @@ -555,41 +591,46 @@ impl McpServer { } } } - Some(tool) => match tool.invoke(args, ctx).await { - Ok(result) => { - let text = result.to_string(); - let is_error = !result - .get("success") - .and_then(|v: &serde_json::Value| v.as_bool()) - .unwrap_or(true); - let content = serde_json::json!({ - "type": "text", - "text": text - }); - Ok(serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "content": [content], - "isError": is_error - } - })) - } - Err(e) => { - let payload = - serde_json::json!({ "success": false, "error": e.to_string() }); - let text = serde_json::to_string(&payload)?; - let content = serde_json::json!({ "type": "text", "text": text }); - Ok(serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "content": [content], - "isError": true - } - })) + Some(tool) => { + let start = std::time::Instant::now(); + match tool.invoke(args, ctx).await { + Ok(result) => { + let text = result.to_string(); + let is_error = !result + .get("success") + .and_then(|v: &serde_json::Value| v.as_bool()) + .unwrap_or(true); + append_mcp_oplog(name, start.elapsed().as_millis(), !is_error, None); + let content = serde_json::json!({ + "type": "text", + "text": text + }); + Ok(serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [content], + "isError": is_error + } + })) + } + Err(e) => { + append_mcp_oplog(name, start.elapsed().as_millis(), false, Some("invoke_error")); + let payload = + serde_json::json!({ "success": false, "error": e.to_string() }); + let text = serde_json::to_string(&payload)?; + let content = serde_json::json!({ "type": "text", "text": text }); + Ok(serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [content], + "isError": true + } + })) + } } - }, + } None => Ok(serde_json::json!({ "jsonrpc": "2.0", "id": id, diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index 1035590..007f19f 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -535,3 +535,166 @@ fn test_parse_tool_tiers_empty() { let tiers = parse_tool_tiers(""); assert!(tiers.is_empty()); } + +// --- Claude Scenario Validation Tests --- + +fn seed_scenario_data(ctx: &crate::storage::AppContext) { + let mut conn = ctx.conn().unwrap(); + let now = chrono::Utc::now().to_rfc3339(); + + // 1. Register a repo in the entities table (single source of truth) + conn.execute( + "INSERT INTO entities (id, entity_type, name, local_path, metadata, created_at, updated_at, language, discovered_at, workspace_type, data_tier, stars) + VALUES (?1, 'repo', ?2, ?3, ?4, ?5, ?5, ?6, ?5, ?7, ?8, ?9)", + rusqlite::params!["scenario-repo", "scenario-repo", "/tmp/scenario-repo", "{}", &now, "rust", "git", "private", 42i64], + ).unwrap(); + + // 2. Tags (including "managed" for is_managed coverage) + for tag in &["rust", "cli", "managed"] { + conn.execute( + "INSERT INTO repo_tags (repo_id, tag) VALUES (?1, ?2)", + rusqlite::params!["scenario-repo", tag], + ).unwrap(); + } + + // 3. Code symbols: 10 entries, mix of functions and structs. + // Include auth-related signatures so "authentication flow" keyword search hits. + let symbols: [(&str, &str, &str, i64, Option<&str>); 10] = [ + ("src/auth.rs", "function", "authenticate_user", 10, Some("pub fn authenticate_user(token: &str) // authentication flow handler")), + ("src/auth.rs", "function", "validate_token", 20, Some("fn validate_token(t: &str) -> bool")), + ("src/lib.rs", "function", "handle_error", 30, Some("pub fn handle_error(e: Error)")), + ("src/lib.rs", "function", "parse_config", 40, Some("fn parse_config() -> Config")), + ("src/main.rs", "function", "main", 1, Some("fn main()")), + ("src/lib.rs", "struct", "Config", 5, None), + ("src/models.rs", "struct", "User", 10, None), + ("src/models.rs", "function", "new_user", 15, Some("fn new_user() -> User")), + ("src/db.rs", "function", "connect_pool", 5, Some("fn connect_pool() -> Pool")), + ("src/api.rs", "function", "serve", 1, Some("pub async fn serve(addr: &str)")), + ]; + for (path, ty, name, line, sig) in &symbols { + conn.execute( + "INSERT INTO code_symbols (repo_id, file_path, symbol_type, name, line_start, signature) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["scenario-repo", path, ty, name, line, *sig], + ).unwrap(); + } + + // 4. Vault notes: create filesystem files then scan into registry + let ws = ctx.storage.workspace_dir().unwrap(); + let vault_dir = ws.join("vault"); + std::fs::create_dir_all(&vault_dir).unwrap(); + std::fs::write( + vault_dir.join("auth-design.md"), + "---\ntitle: Authentication Flow Design\nrepo: scenario-repo\ntags: [auth, design]\n---\n\nThis document describes the authentication flow for the scenario repo.\nThe authenticate_user function handles token validation.\n", + ).unwrap(); + crate::vault::scanner::scan_vault(&mut conn, Some(&vault_dir)).unwrap(); +} + +#[tokio::test] +async fn test_scenario_one_project_onboarding() { + let backend = std::sync::Arc::new(crate::storage::TempStorageBackend::new()); + let mut ctx = crate::storage::AppContext::with_storage(backend).unwrap(); + seed_scenario_data(&ctx); + + // Tool 1: devkit_health + let health_tool = DevkitHealthTool; + let health_result = health_tool + .invoke(serde_json::json!({ "detail": true }), &mut ctx) + .await + .unwrap(); + assert_eq!(health_result.get("success").unwrap(), true); + let summary = health_result.get("summary").unwrap(); + assert!(summary.get("total_repos").unwrap().as_i64().unwrap() >= 1); + + // Tool 2: devkit_project_brief + let brief_tool = DevkitProjectBriefTool; + let brief_result = brief_tool + .invoke(serde_json::json!({ "repo_id": "scenario-repo" }), &mut ctx) + .await + .unwrap(); + assert_eq!(brief_result.get("success").unwrap(), true); + let brief = brief_result.get("brief").unwrap().as_str().unwrap(); + // Acceptance: brief contains >= 5 key modules/symbols + let symbol_count = brief.matches("- `").count(); + assert!( + symbol_count >= 5, + "Expected >= 5 symbols in brief, found {}. Brief:\n{}", + symbol_count, + brief + ); + assert!(brief.contains("## Architecture")); + assert!(brief.contains("Key Symbols:")); + + // Tool 3: devkit_query_repos + let query_tool = DevkitQueryReposTool; + let query_result = query_tool + .invoke(serde_json::json!({}), &mut ctx) + .await + .unwrap(); + assert_eq!(query_result.get("success").unwrap(), true); + let repos = query_result.get("repos").unwrap().as_array().unwrap(); + assert!( + repos.iter().any(|r| r.get("id").and_then(|v| v.as_str()) == Some("scenario-repo")), + "scenario-repo should be listed in query_repos" + ); +} + +#[tokio::test] +async fn test_scenario_two_semantic_exploration() { + let backend = std::sync::Arc::new(crate::storage::TempStorageBackend::new()); + let mut ctx = crate::storage::AppContext::with_storage(backend).unwrap(); + seed_scenario_data(&ctx); + + // Tool 1: devkit_hybrid_search — keyword fallback path (no embeddings seeded) + let search_tool = DevkitHybridSearchTool; + let search_result = search_tool + .invoke( + serde_json::json!({ "repo_id": "scenario-repo", "query_text": "authentication flow", "limit": 10 }), + &mut ctx, + ) + .await + .unwrap(); + assert_eq!(search_result.get("success").unwrap(), true); + let symbols = search_result.get("symbols").unwrap().as_array().unwrap(); + assert!( + !symbols.is_empty(), + "hybrid_search should return at least 1 auth-related symbol via keyword fallback" + ); + let names: Vec<&str> = symbols.iter().filter_map(|s| s.get("name").and_then(|v| v.as_str())).collect(); + assert!( + names.contains(&"authenticate_user"), + "authenticate_user should appear in hybrid_search results for 'authentication flow'. Got: {:?}", + names + ); + + // Tool 2: devkit_project_context + let context_tool = DevkitProjectContextTool; + let ctx_result = context_tool + .invoke(serde_json::json!({ "project": "scenario-repo" }), &mut ctx) + .await + .unwrap(); + assert_eq!(ctx_result.get("success").unwrap(), true); + let ctx_symbols = ctx_result.get("symbols").unwrap().as_array().unwrap(); + assert!( + ctx_symbols.len() >= 3, + "project_context should return >= 3 symbols for understanding. Got: {}", + ctx_symbols.len() + ); + + // Tool 3: devkit_vault_search + let vault_tool = DevkitVaultSearchTool; + let vault_result = vault_tool + .invoke(serde_json::json!({ "query": "authentication" }), &mut ctx) + .await + .unwrap(); + assert_eq!(vault_result.get("success").unwrap(), true); + let notes = vault_result.get("notes").unwrap().as_array().unwrap(); + assert!( + !notes.is_empty(), + "vault_search should find the auth-design note" + ); + assert!( + notes.iter().any(|n| n.get("title").and_then(|v| v.as_str()) == Some("Authentication Flow Design")), + "vault_search should return auth-design note" + ); +} diff --git a/src/mcp/tools/vault.rs b/src/mcp/tools/vault.rs index 7682d14..e0ea306 100644 --- a/src/mcp/tools/vault.rs +++ b/src/mcp/tools/vault.rs @@ -2,7 +2,6 @@ // Copyright (c) 2026 juice094 use crate::clients::{DigestClient, VaultClient}; use crate::mcp::McpTool; -use crate::registry::VaultNote; use anyhow::Context; #[derive(Clone)] @@ -57,38 +56,37 @@ Returns: JSON array of matching notes. Each includes: id, title, path, and tags. let query_owned = query.to_string(); let results = tokio::task::spawn_blocking(move || { let value = ctx.list_vault_notes()?; - let notes: Vec = serde_json::from_value( - value.get("notes").cloned().unwrap_or(serde_json::json!([])), - ) - .unwrap_or_default(); + let notes_arr = value + .get("notes") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); let keywords: Vec<&str> = query_owned.split_whitespace().collect(); - let filtered: Vec<_> = notes + let filtered: Vec<_> = notes_arr .into_iter() .filter(|n| { + let id = n.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let path = n.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let title = n.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let tags = n + .get("tags") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.as_str()) + .collect::>() + .join(",") + }) + .unwrap_or_default(); let content = ctx - .read_vault_note(&n.path) + .read_vault_note(path) .ok() .and_then(|v| v.get("content").and_then(|c| c.as_str()).map(String::from)) .unwrap_or_default(); - let hay = format!( - "{} {} {} {}", - n.id, - n.title.as_deref().unwrap_or(""), - n.tags.join(","), - content - ) - .to_lowercase(); + let hay = format!("{} {} {} {}", id, title, tags, content).to_lowercase(); keywords.iter().all(|kw| hay.contains(&kw.to_lowercase())) }) - .map(|n| { - serde_json::json!({ - "id": n.id, - "title": n.title, - "path": n.path, - "tags": n.tags, - }) - }) .collect(); anyhow::Ok(filtered) From 68837ec78b4d6b34ea690a4854f228f7cf80963d Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 21:16:32 +0800 Subject: [PATCH 08/12] feat(mcp): add MCP oplog analytics to devkit_oplog_query Extend devkit_oplog_query with an `analytics` flag that reads mcp-oplog.ndjson and returns statistical reports: - tool call frequency and success rate per tool - latency percentiles (P50, P95, P99) - error classification breakdown - time range coverage Also fixes clippy lint issues in sort_by closures. Co-Authored-By: Claude Opus 4.7 --- src/mcp/tools/oplog.rs | 329 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 321 insertions(+), 8 deletions(-) diff --git a/src/mcp/tools/oplog.rs b/src/mcp/tools/oplog.rs index 0ebe2b7..f695da9 100644 --- a/src/mcp/tools/oplog.rs +++ b/src/mcp/tools/oplog.rs @@ -1,6 +1,179 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 use crate::mcp::McpTool; +use anyhow::Context; +use std::collections::HashMap; + +/// A single entry from the MCP oplog NDJSON file. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct McpOplogEntry { + pub timestamp: String, + pub tool: String, + pub duration_ms: u64, + pub success: bool, + pub error_type: Option, +} + +/// Per-tool statistics. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ToolStats { + pub tool: String, + pub calls: usize, + pub success: usize, + pub errors: usize, + pub avg_latency_ms: f64, + pub p50_latency_ms: u64, + pub p95_latency_ms: u64, + pub p99_latency_ms: u64, +} + +/// Overall latency distribution. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct LatencyStats { + pub p50_ms: u64, + pub p95_ms: u64, + pub p99_ms: u64, + pub min_ms: u64, + pub max_ms: u64, +} + +/// Error classification breakdown. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ErrorStats { + pub error_type: String, + pub count: usize, + pub tools: Vec, +} + +/// Complete analytics report for an MCP oplog file. +#[derive(Debug, Clone, serde::Serialize)] +pub struct OplogAnalyticsReport { + pub total_calls: usize, + pub success_count: usize, + pub error_count: usize, + pub success_rate: f64, + pub unique_tools: usize, + pub tool_breakdown: Vec, + pub latency_ms: LatencyStats, + pub error_breakdown: Vec, + pub time_range_start: Option, + pub time_range_end: Option, +} + +/// Parse an NDJSON file of `McpOplogEntry` records. +fn parse_mcp_oplog(path: &std::path::Path) -> anyhow::Result> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read MCP oplog: {}", path.display()))?; + let mut entries = Vec::new(); + for line in content.lines().filter(|l| !l.trim().is_empty()) { + match serde_json::from_str::(line) { + Ok(entry) => entries.push(entry), + Err(e) => { + tracing::warn!("Skipping malformed oplog line: {} — {}", e, line); + } + } + } + Ok(entries) +} + +/// Compute latency percentiles from a sorted slice. +fn latency_percentiles(sorted: &[u64]) -> LatencyStats { + if sorted.is_empty() { + return LatencyStats::default(); + } + let n = sorted.len(); + let idx = |p: f64| -> usize { ((n as f64 * p / 100.0).ceil() as usize).saturating_sub(1).min(n - 1) }; + LatencyStats { + p50_ms: sorted[idx(50.0)], + p95_ms: sorted[idx(95.0)], + p99_ms: sorted[idx(99.0)], + min_ms: sorted[0], + max_ms: sorted[n - 1], + } +} + +/// Analyze a collection of MCP oplog entries and produce a report. +pub fn analyze_mcp_oplog(entries: &[McpOplogEntry]) -> OplogAnalyticsReport { + let total_calls = entries.len(); + let success_count = entries.iter().filter(|e| e.success).count(); + let error_count = total_calls.saturating_sub(success_count); + let success_rate = if total_calls > 0 { + (success_count as f64 / total_calls as f64) * 100.0 + } else { + 0.0 + }; + + // Tool-level grouping + let mut tool_map: HashMap> = HashMap::new(); + for e in entries { + tool_map.entry(e.tool.clone()).or_default().push(e); + } + + let mut tool_breakdown: Vec = tool_map + .into_iter() + .map(|(tool, recs)| { + let calls = recs.len(); + let success = recs.iter().filter(|e| e.success).count(); + let errors = calls.saturating_sub(success); + let mut latencies: Vec = recs.iter().map(|e| e.duration_ms).collect(); + latencies.sort_unstable(); + let lat = latency_percentiles(&latencies); + let avg = if !latencies.is_empty() { + latencies.iter().sum::() as f64 / latencies.len() as f64 + } else { + 0.0 + }; + ToolStats { + tool, + calls, + success, + errors, + avg_latency_ms: avg, + p50_latency_ms: lat.p50_ms, + p95_latency_ms: lat.p95_ms, + p99_latency_ms: lat.p99_ms, + } + }) + .collect(); + tool_breakdown.sort_by_key(|b| std::cmp::Reverse(b.calls)); + + // Overall latency + let mut all_latencies: Vec = entries.iter().map(|e| e.duration_ms).collect(); + all_latencies.sort_unstable(); + let latency_ms = latency_percentiles(&all_latencies); + + // Error classification + let mut error_map: HashMap)> = HashMap::new(); + for e in entries.iter().filter(|e| !e.success) { + let key = e.error_type.clone().unwrap_or_else(|| "unknown".to_string()); + let entry = error_map.entry(key).or_insert_with(|| (0, Vec::new())); + entry.0 += 1; + if !entry.1.contains(&e.tool) { + entry.1.push(e.tool.clone()); + } + } + let mut error_breakdown: Vec = error_map + .into_iter() + .map(|(error_type, (count, tools))| ErrorStats { error_type, count, tools }) + .collect(); + error_breakdown.sort_by_key(|b| std::cmp::Reverse(b.count)); + + let time_range_start = entries.iter().map(|e| e.timestamp.clone()).min(); + let time_range_end = entries.iter().map(|e| e.timestamp.clone()).max(); + + OplogAnalyticsReport { + total_calls, + success_count, + error_count, + success_rate, + unique_tools: tool_breakdown.len(), + tool_breakdown, + latency_ms, + error_breakdown, + time_range_start, + time_range_end, + } +} #[derive(Clone)] pub struct DevkitOplogQueryTool; @@ -19,19 +192,16 @@ Use this when the user wants to: - Debug why something did or did not happen - Audit the history of workspace operations - Check the status of recent background tasks +- Analyze MCP tool call patterns, error rates, and latency distributions Parameters: - limit: Maximum number of events to return (default: 20, max: 100) - repo_id: Optional filter by repository ID. If omitted, returns workspace-wide activity. +- analytics: If true, returns a statistical summary of MCP oplog data instead of raw events. -Returns: JSON array of OpLog entries. Each entry includes: - - id: event id - - event_type: operation category (e.g. "index", "sync", "scan") - - repo_id: affected repository, if any - - status: "success" | "error" | "pending" - - timestamp: ISO 8601 timestamp - - duration_ms: execution time in milliseconds, if recorded - - details: JSON object with operation-specific metadata"#, +Returns: + - Normal mode: JSON array of OpLog entries + - Analytics mode: JSON object with total_calls, success_rate, latency percentiles, tool breakdown, and error classification"#, "inputSchema": { "type": "object", "properties": { @@ -42,6 +212,11 @@ Returns: JSON array of OpLog entries. Each entry includes: "repo_id": { "type": "string", "description": "Optional repository ID to filter by" + }, + "analytics": { + "type": "boolean", + "description": "Return MCP oplog analytics summary instead of raw events", + "default": false } } } @@ -53,6 +228,40 @@ Returns: JSON array of OpLog entries. Each entry includes: args: serde_json::Value, ctx: &mut crate::storage::AppContext, ) -> anyhow::Result { + let analytics = args.get("analytics").and_then(|v| v.as_bool()).unwrap_or(false); + + if analytics { + // MCP oplog analytics path + let data_dir = dirs::data_local_dir() + .context("Failed to determine local data directory")?; + let log_path = data_dir.join("devbase").join("mcp-oplog.ndjson"); + + if !log_path.exists() { + return Ok(serde_json::json!({ + "success": true, + "message": "No MCP oplog data found", + "total_calls": 0, + })); + } + + let entries = parse_mcp_oplog(&log_path)?; + let report = analyze_mcp_oplog(&entries); + return Ok(serde_json::json!({ + "success": true, + "total_calls": report.total_calls, + "success_count": report.success_count, + "error_count": report.error_count, + "success_rate": report.success_rate, + "unique_tools": report.unique_tools, + "latency_ms": report.latency_ms, + "tool_breakdown": report.tool_breakdown, + "error_breakdown": report.error_breakdown, + "time_range_start": report.time_range_start, + "time_range_end": report.time_range_end, + })); + } + + // Original DB oplog query path let limit = args .get("limit") .and_then(|v| v.as_i64()) @@ -111,4 +320,108 @@ mod tests { let s = t.schema(); assert!(s.is_object()); } + + #[test] + fn test_latency_percentiles_basic() { + let data = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + let lat = latency_percentiles(&data); + assert_eq!(lat.min_ms, 10); + assert_eq!(lat.max_ms, 100); + assert_eq!(lat.p50_ms, 50); + assert_eq!(lat.p95_ms, 100); + assert_eq!(lat.p99_ms, 100); + } + + #[test] + fn test_latency_percentiles_empty() { + let lat = latency_percentiles(&[]); + assert_eq!(lat.p50_ms, 0); + assert_eq!(lat.p95_ms, 0); + } + + #[test] + fn test_analyze_mcp_oplog_smoke() { + let entries = vec![ + McpOplogEntry { + timestamp: "2026-01-01T00:00:00Z".to_string(), + tool: "devkit_health".to_string(), + duration_ms: 100, + success: true, + error_type: None, + }, + McpOplogEntry { + timestamp: "2026-01-01T00:01:00Z".to_string(), + tool: "devkit_health".to_string(), + duration_ms: 200, + success: true, + error_type: None, + }, + McpOplogEntry { + timestamp: "2026-01-01T00:02:00Z".to_string(), + tool: "devkit_sync".to_string(), + duration_ms: 500, + success: false, + error_type: Some("timeout".to_string()), + }, + ]; + let report = analyze_mcp_oplog(&entries); + assert_eq!(report.total_calls, 3); + assert_eq!(report.success_count, 2); + assert_eq!(report.error_count, 1); + assert!((report.success_rate - 66.6667).abs() < 0.01, "success_rate = {}", report.success_rate); + assert_eq!(report.unique_tools, 2); + + let health = report + .tool_breakdown + .iter() + .find(|t| t.tool == "devkit_health") + .unwrap(); + assert_eq!(health.calls, 2); + assert_eq!(health.success, 2); + // Nearest-rank ceil-based: n=2, p=50 -> idx=0 -> sorted[0]=100 + assert_eq!(health.p50_latency_ms, 100); + + let sync = report + .tool_breakdown + .iter() + .find(|t| t.tool == "devkit_sync") + .unwrap(); + assert_eq!(sync.calls, 1); + assert_eq!(sync.errors, 1); + assert_eq!(sync.p95_latency_ms, 500); + + assert_eq!(report.error_breakdown.len(), 1); + assert_eq!(report.error_breakdown[0].error_type, "timeout"); + assert_eq!(report.error_breakdown[0].count, 1); + assert!(report.error_breakdown[0].tools.contains(&"devkit_sync".to_string())); + } + + #[test] + fn test_analyze_mcp_oplog_empty() { + let report = analyze_mcp_oplog(&[]); + assert_eq!(report.total_calls, 0); + assert_eq!(report.success_rate, 0.0); + assert!(report.tool_breakdown.is_empty()); + assert!(report.error_breakdown.is_empty()); + } + + #[test] + fn test_parse_mcp_oplog_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("test-oplog.ndjson"); + let lines = [ + r#"{"timestamp":"2026-01-01T00:00:00Z","tool":"t1","duration_ms":10,"success":true,"error_type":null}"#, + r#"{"timestamp":"2026-01-01T00:00:01Z","tool":"t2","duration_ms":20,"success":false,"error_type":"err"}"#, + "not-json-line", + r#"{"timestamp":"2026-01-01T00:00:02Z","tool":"t3","duration_ms":30,"success":true}"#, + ]; + std::fs::write(&path, lines.join("\n")).unwrap(); + + let entries = parse_mcp_oplog(&path).unwrap(); + assert_eq!(entries.len(), 3); // malformed line skipped + assert_eq!(entries[0].tool, "t1"); + assert_eq!(entries[1].tool, "t2"); + assert_eq!(entries[2].tool, "t3"); + assert!(entries[2].error_type.is_none()); + } } From 24620a6945c1203a1847fd2f0f23f9d289349775 Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 22:16:08 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feat(tests,security,perf):=20Phase=201=20?= =?UTF-8?q?hardening=20=E2=80=94=20workflow=20E2E,=20RF-7,=20perf=20baseli?= =?UTF-8?q?nes,=20Tantivy=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 端到端 workflow 测试 (Task #16): - Add MCP-tool-level DAG workflow integration tests: test_workflow_run_dag_success: 3-step Condition chain via DevkitWorkflowRunTool -> DevkitWorkflowStatusTool round-trip test_workflow_run_failure_propagation: verifies ErrorPolicy::Fail status propagation to execution record 性能回归基线校准 (Task #17): - Fix schema bug in perf tests (missing `signature` column caused panic) - Add profile-aware thresholds via cfg!(debug_assertions): release 1k<200ms/10k<500ms; debug 1k<800ms/10k<2000ms - Add latency eprintln reporting for CI observability RF-7 路径隐私修复 (Task #18): - Add sanitize_path() helper: replaces home dir prefix with ~, normalizes \ to / - Apply across project_context output: repo.path, modules.path, symbols.file, calls.caller_file, assets.path - Add 5 unit tests for path desensitization logic Tantivy 一致性强化 (Task #19): - Fix AppContext to use actual storage backend's index_path for repair_tantivy_consistency_at and sync_index_to_db_at (was hardcoded to DefaultStorageBackend, breaking TempStorageBackend) - Fix repair_tantivy_consistency_at early-return bug: now loads SQLite IDs first; on Tantivy read failure reports missing_from_index = sqlite_ids.len() instead of silently 0 - Add tests: fresh_workspace consistency, empty-index+DB-repos detection, AppContext correct index path verification Formatting: - Run cargo fmt across workspace to satisfy CI fmt --check Co-Authored-By: Claude Opus 4.7 --- crates/devbase-registry-call-graph/src/lib.rs | 22 +-- .../devbase-registry-code-symbols/src/lib.rs | 17 ++- crates/devbase-registry-dead-code/src/lib.rs | 66 ++++++++- crates/devbase-registry-relation/src/lib.rs | 36 ++--- src/commands/repo.rs | 15 +- src/mcp/mod.rs | 33 +++-- src/mcp/tests.rs | 56 +++++-- src/mcp/tools/context.rs | 67 +++++++-- src/mcp/tools/oplog.rs | 25 ++-- src/mcp/tools/vault.rs | 12 +- src/mcp/tools/workflow.rs | 140 ++++++++++++++++++ src/search/hybrid.rs | 53 ++++++- src/storage.rs | 106 +++++++++---- 13 files changed, 497 insertions(+), 151 deletions(-) diff --git a/crates/devbase-registry-call-graph/src/lib.rs b/crates/devbase-registry-call-graph/src/lib.rs index 987793c..19b7468 100644 --- a/crates/devbase-registry-call-graph/src/lib.rs +++ b/crates/devbase-registry-call-graph/src/lib.rs @@ -124,10 +124,7 @@ mod tests { let conn = init_in_memory(); seed_edges(&conn); - let results = query_call_edges( - &conn, "repo-a", None, Some("main"), None, 100, - ) - .unwrap(); + let results = query_call_edges(&conn, "repo-a", None, Some("main"), None, 100).unwrap(); assert_eq!(results.len(), 2); assert!(results.iter().all(|e| e.caller_symbol == "main")); } @@ -137,10 +134,7 @@ mod tests { let conn = init_in_memory(); seed_edges(&conn); - let results = query_call_edges( - &conn, "repo-a", None, None, Some("lib"), 100, - ) - .unwrap(); + let results = query_call_edges(&conn, "repo-a", None, None, Some("lib"), 100).unwrap(); assert_eq!(results.len(), 2); assert!(results.iter().all(|e| e.caller_file.contains("lib"))); } @@ -150,10 +144,8 @@ mod tests { let conn = init_in_memory(); seed_edges(&conn); - let results = query_call_edges( - &conn, "repo-a", Some("util"), Some("helper"), None, 100, - ) - .unwrap(); + let results = + query_call_edges(&conn, "repo-a", Some("util"), Some("helper"), None, 100).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].caller_symbol, "helper"); assert_eq!(results[0].callee_name, "util"); @@ -164,10 +156,8 @@ mod tests { let conn = init_in_memory(); seed_edges(&conn); - let results = query_call_edges( - &conn, "repo-a", Some("nonexistent"), None, None, 100, - ) - .unwrap(); + let results = + query_call_edges(&conn, "repo-a", Some("nonexistent"), None, None, 100).unwrap(); assert!(results.is_empty()); } diff --git a/crates/devbase-registry-code-symbols/src/lib.rs b/crates/devbase-registry-code-symbols/src/lib.rs index 8cec767..020f4e2 100644 --- a/crates/devbase-registry-code-symbols/src/lib.rs +++ b/crates/devbase-registry-code-symbols/src/lib.rs @@ -93,7 +93,16 @@ mod tests { ("repo-a", "src/lib.rs", "function", "helper", 5, 15, Some("fn helper()"), None), ("repo-a", "src/lib.rs", "struct", "Config", 20, 30, None, Some("derive(Debug)")), ("repo-a", "src/models.rs", "struct", "User", 1, 20, None, None), - ("repo-a", "src/models.rs", "function", "new_user", 25, 35, Some("fn new_user() -> User"), None), + ( + "repo-a", + "src/models.rs", + "function", + "new_user", + 25, + 35, + Some("fn new_user() -> User"), + None, + ), ("repo-b", "src/main.rs", "function", "entry", 1, 5, None, None), ]; for (repo, path, ty, name, start, end, sig, attrs) in symbols { @@ -151,7 +160,8 @@ mod tests { let conn = init_in_memory(); seed_symbols(&conn); - let results = query_code_symbols(&conn, "repo-a", None, Some("struct"), Some("lib"), 100).unwrap(); + let results = + query_code_symbols(&conn, "repo-a", None, Some("struct"), Some("lib"), 100).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].name, "Config"); } @@ -161,7 +171,8 @@ mod tests { let conn = init_in_memory(); seed_symbols(&conn); - let results = query_code_symbols(&conn, "repo-a", Some("nonexistent"), None, None, 100).unwrap(); + let results = + query_code_symbols(&conn, "repo-a", Some("nonexistent"), None, None, 100).unwrap(); assert!(results.is_empty()); } diff --git a/crates/devbase-registry-dead-code/src/lib.rs b/crates/devbase-registry-dead-code/src/lib.rs index f193c6c..27c89d4 100644 --- a/crates/devbase-registry-dead-code/src/lib.rs +++ b/crates/devbase-registry-dead-code/src/lib.rs @@ -97,12 +97,66 @@ mod tests { fn seed_data(conn: &rusqlite::Connection) { // Functions: some have callers, some don't let symbols = [ - ("repo-a", "src/main.rs", "function", "main", 1, 5, Some("pub fn main()"), None::<&str>), - ("repo-a", "src/lib.rs", "function", "helper", 10, 20, Some("fn helper()"), None::<&str>), - ("repo-a", "src/lib.rs", "function", "unused_fn", 30, 40, Some("fn unused_fn()"), None::<&str>), - ("repo-a", "src/lib.rs", "function", "pub_api", 50, 60, Some("pub fn pub_api()"), None::<&str>), - ("repo-a", "src/tests.rs", "function", "test_something", 1, 10, Some("fn test_something()"), None::<&str>), - ("repo-a", "src/utils.rs", "function", "test_helper", 1, 5, Some("fn test_helper()"), None::<&str>), + ( + "repo-a", + "src/main.rs", + "function", + "main", + 1, + 5, + Some("pub fn main()"), + None::<&str>, + ), + ( + "repo-a", + "src/lib.rs", + "function", + "helper", + 10, + 20, + Some("fn helper()"), + None::<&str>, + ), + ( + "repo-a", + "src/lib.rs", + "function", + "unused_fn", + 30, + 40, + Some("fn unused_fn()"), + None::<&str>, + ), + ( + "repo-a", + "src/lib.rs", + "function", + "pub_api", + 50, + 60, + Some("pub fn pub_api()"), + None::<&str>, + ), + ( + "repo-a", + "src/tests.rs", + "function", + "test_something", + 1, + 10, + Some("fn test_something()"), + None::<&str>, + ), + ( + "repo-a", + "src/utils.rs", + "function", + "test_helper", + 1, + 5, + Some("fn test_helper()"), + None::<&str>, + ), ("repo-a", "src/utils.rs", "struct", "Config", 10, 15, None::<&str>, None::<&str>), ]; for (repo, path, ty, name, start, end, sig, attrs) in symbols { diff --git a/crates/devbase-registry-relation/src/lib.rs b/crates/devbase-registry-relation/src/lib.rs index 29be574..b28965e 100644 --- a/crates/devbase-registry-relation/src/lib.rs +++ b/crates/devbase-registry-relation/src/lib.rs @@ -159,11 +159,8 @@ mod tests { #[test] fn test_list_relations() { let conn = in_memory(); - conn.execute( - "INSERT INTO entities (id) VALUES ('a'), ('b'), ('c')", - [], - ) - .unwrap(); + conn.execute("INSERT INTO entities (id) VALUES ('a'), ('b'), ('c')", []) + .unwrap(); super::save_relation(&conn, "a", "b", "depends_on", 0.9).unwrap(); super::save_relation(&conn, "a", "c", "uses", 0.7).unwrap(); @@ -190,11 +187,8 @@ mod tests { #[test] fn test_find_related_entities_bidirectional() { let conn = in_memory(); - conn.execute( - "INSERT INTO entities (id) VALUES ('a'), ('b'), ('c')", - [], - ) - .unwrap(); + conn.execute("INSERT INTO entities (id) VALUES ('a'), ('b'), ('c')", []) + .unwrap(); super::save_relation(&conn, "a", "b", "depends_on", 0.9).unwrap(); super::save_relation(&conn, "c", "a", "uses", 0.8).unwrap(); @@ -203,8 +197,7 @@ mod tests { assert_eq!(related.len(), 2); // Filter by type - let depends_only = - super::find_related_entities(&conn, "a", Some("depends_on")).unwrap(); + let depends_only = super::find_related_entities(&conn, "a", Some("depends_on")).unwrap(); assert_eq!(depends_only.len(), 1); assert_eq!(depends_only[0].0, "a"); // from_entity_id assert_eq!(depends_only[0].1, "b"); // to_entity_id @@ -217,27 +210,22 @@ mod tests { #[test] fn test_save_relation_upsert() { let conn = in_memory(); - conn.execute("INSERT INTO entities (id) VALUES ('a'), ('b')", []) - .unwrap(); + conn.execute("INSERT INTO entities (id) VALUES ('a'), ('b')", []).unwrap(); super::save_relation(&conn, "a", "b", "depends_on", 0.5).unwrap(); super::save_relation(&conn, "a", "b", "depends_on", 0.9).unwrap(); // Should only have one row with updated confidence let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM relations WHERE from_entity_id = 'a'", - [], - |row| row.get(0), - ) + .query_row("SELECT COUNT(*) FROM relations WHERE from_entity_id = 'a'", [], |row| { + row.get(0) + }) .unwrap(); assert_eq!(count, 1); let conf: f64 = conn - .query_row( - "SELECT confidence FROM relations WHERE from_entity_id = 'a'", - [], - |row| row.get(0), - ) + .query_row("SELECT confidence FROM relations WHERE from_entity_id = 'a'", [], |row| { + row.get(0) + }) .unwrap(); assert!((conf - 0.9).abs() < f64::EPSILON); } diff --git a/src/commands/repo.rs b/src/commands/repo.rs index 5a1d290..09a258f 100644 --- a/src/commands/repo.rs +++ b/src/commands/repo.rs @@ -521,12 +521,20 @@ pub async fn run_repo_list(ctx: &mut crate::storage::AppContext, json: bool) -> ); } let managed_count = items.iter().filter(|i| i.managed).count(); - println!("\nTotal: {} | Managed: {} | Unmanaged: {}", items.len(), managed_count, items.len() - managed_count); + println!( + "\nTotal: {} | Managed: {} | Unmanaged: {}", + items.len(), + managed_count, + items.len() - managed_count + ); } Ok(()) } -pub async fn run_repo_status(ctx: &mut crate::storage::AppContext, json: bool) -> anyhow::Result<()> { +pub async fn run_repo_status( + ctx: &mut crate::storage::AppContext, + json: bool, +) -> anyhow::Result<()> { use crate::registry::{HealthEntry, health as reg_health}; use chrono::Utc; @@ -645,7 +653,8 @@ pub async fn run_repo_status(ctx: &mut crate::storage::AppContext, json: bool) - ); } let managed_count = items.iter().filter(|i| i.managed).count(); - let dirty_count = items.iter().filter(|i| i.status == "dirty" || i.status == "changed").count(); + let dirty_count = + items.iter().filter(|i| i.status == "dirty" || i.status == "changed").count(); let behind_count = items.iter().filter(|i| i.behind > 0).count(); let ahead_count = items.iter().filter(|i| i.ahead > 0).count(); println!( diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index d47a93a..8f91f8b 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -453,12 +453,7 @@ impl McpTool for McpToolEnum { /// /// Path: `%LOCALAPPDATA%/devbase/mcp-oplog.ndjson` /// Format: newline-delimited JSON (NDJSON) -fn append_mcp_oplog( - tool_name: &str, - duration_ms: u128, - success: bool, - error_type: Option<&str>, -) { +fn append_mcp_oplog(tool_name: &str, duration_ms: u128, success: bool, error_type: Option<&str>) { let entry = serde_json::json!({ "timestamp": chrono::Utc::now().to_rfc3339(), "tool": tool_name, @@ -469,10 +464,7 @@ fn append_mcp_oplog( if let Some(data_dir) = dirs::data_local_dir() { let log_path = data_dir.join("devbase").join("mcp-oplog.ndjson"); - if let Ok(mut file) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) + if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) { use std::io::Write; if let Err(e) = writeln!(file, "{}", entry) { @@ -575,7 +567,12 @@ impl McpServer { })) } Err(e) => { - append_mcp_oplog(name, start.elapsed().as_millis(), false, Some("invoke_error")); + append_mcp_oplog( + name, + start.elapsed().as_millis(), + false, + Some("invoke_error"), + ); let payload = serde_json::json!({ "success": false, "error": e.to_string() }); let text = serde_json::to_string(&payload)?; @@ -600,7 +597,12 @@ impl McpServer { .get("success") .and_then(|v: &serde_json::Value| v.as_bool()) .unwrap_or(true); - append_mcp_oplog(name, start.elapsed().as_millis(), !is_error, None); + append_mcp_oplog( + name, + start.elapsed().as_millis(), + !is_error, + None, + ); let content = serde_json::json!({ "type": "text", "text": text @@ -615,7 +617,12 @@ impl McpServer { })) } Err(e) => { - append_mcp_oplog(name, start.elapsed().as_millis(), false, Some("invoke_error")); + append_mcp_oplog( + name, + start.elapsed().as_millis(), + false, + Some("invoke_error"), + ); let payload = serde_json::json!({ "success": false, "error": e.to_string() }); let text = serde_json::to_string(&payload)?; diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index 007f19f..882d6ab 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -554,16 +554,41 @@ fn seed_scenario_data(ctx: &crate::storage::AppContext) { conn.execute( "INSERT INTO repo_tags (repo_id, tag) VALUES (?1, ?2)", rusqlite::params!["scenario-repo", tag], - ).unwrap(); + ) + .unwrap(); } // 3. Code symbols: 10 entries, mix of functions and structs. // Include auth-related signatures so "authentication flow" keyword search hits. let symbols: [(&str, &str, &str, i64, Option<&str>); 10] = [ - ("src/auth.rs", "function", "authenticate_user", 10, Some("pub fn authenticate_user(token: &str) // authentication flow handler")), - ("src/auth.rs", "function", "validate_token", 20, Some("fn validate_token(t: &str) -> bool")), - ("src/lib.rs", "function", "handle_error", 30, Some("pub fn handle_error(e: Error)")), - ("src/lib.rs", "function", "parse_config", 40, Some("fn parse_config() -> Config")), + ( + "src/auth.rs", + "function", + "authenticate_user", + 10, + Some("pub fn authenticate_user(token: &str) // authentication flow handler"), + ), + ( + "src/auth.rs", + "function", + "validate_token", + 20, + Some("fn validate_token(t: &str) -> bool"), + ), + ( + "src/lib.rs", + "function", + "handle_error", + 30, + Some("pub fn handle_error(e: Error)"), + ), + ( + "src/lib.rs", + "function", + "parse_config", + 40, + Some("fn parse_config() -> Config"), + ), ("src/main.rs", "function", "main", 1, Some("fn main()")), ("src/lib.rs", "struct", "Config", 5, None), ("src/models.rs", "struct", "User", 10, None), @@ -627,14 +652,13 @@ async fn test_scenario_one_project_onboarding() { // Tool 3: devkit_query_repos let query_tool = DevkitQueryReposTool; - let query_result = query_tool - .invoke(serde_json::json!({}), &mut ctx) - .await - .unwrap(); + let query_result = query_tool.invoke(serde_json::json!({}), &mut ctx).await.unwrap(); assert_eq!(query_result.get("success").unwrap(), true); let repos = query_result.get("repos").unwrap().as_array().unwrap(); assert!( - repos.iter().any(|r| r.get("id").and_then(|v| v.as_str()) == Some("scenario-repo")), + repos + .iter() + .any(|r| r.get("id").and_then(|v| v.as_str()) == Some("scenario-repo")), "scenario-repo should be listed in query_repos" ); } @@ -660,7 +684,8 @@ async fn test_scenario_two_semantic_exploration() { !symbols.is_empty(), "hybrid_search should return at least 1 auth-related symbol via keyword fallback" ); - let names: Vec<&str> = symbols.iter().filter_map(|s| s.get("name").and_then(|v| v.as_str())).collect(); + let names: Vec<&str> = + symbols.iter().filter_map(|s| s.get("name").and_then(|v| v.as_str())).collect(); assert!( names.contains(&"authenticate_user"), "authenticate_user should appear in hybrid_search results for 'authentication flow'. Got: {:?}", @@ -689,12 +714,11 @@ async fn test_scenario_two_semantic_exploration() { .unwrap(); assert_eq!(vault_result.get("success").unwrap(), true); let notes = vault_result.get("notes").unwrap().as_array().unwrap(); + assert!(!notes.is_empty(), "vault_search should find the auth-design note"); assert!( - !notes.is_empty(), - "vault_search should find the auth-design note" - ); - assert!( - notes.iter().any(|n| n.get("title").and_then(|v| v.as_str()) == Some("Authentication Flow Design")), + notes + .iter() + .any(|n| n.get("title").and_then(|v| v.as_str()) == Some("Authentication Flow Design")), "vault_search should return auth-design note" ); } diff --git a/src/mcp/tools/context.rs b/src/mcp/tools/context.rs index 18cf206..73ece4d 100644 --- a/src/mcp/tools/context.rs +++ b/src/mcp/tools/context.rs @@ -85,7 +85,7 @@ Returns: JSON object with: let repo_json = matched_repo.as_ref().map(|r| { serde_json::json!({ "id": r.id, - "path": r.local_path, + "path": sanitize_path(&r.local_path.to_string_lossy()), "language": r.language, "tags": r.tags, "stars": r.stars, @@ -144,9 +144,7 @@ Returns: JSON object with: modules.push(serde_json::json!({ "name": name, "kind": kind, - // TODO(veto-audit-2026-04-26): RF-7 路径隐私 — path 可能为绝对路径,泄露用户目录结构。 - // 修复: 将 dirs::home_dir() 前缀替换为 ~,或返回相对路径。 - "path": path, + "path": sanitize_path(&path), })); } } @@ -162,7 +160,7 @@ Returns: JSON object with: symbol_names.insert(name.clone()); symbols.push(serde_json::json!({ "name": name, - "file": path, + "file": sanitize_path(&path), "line": line, "relevance_score": score, })); @@ -180,7 +178,7 @@ Returns: JSON object with: let rows = stmt.query_map([rid], |row| { Ok(serde_json::json!({ "name": row.get::<_, String>(0)?, - "file": row.get::<_, String>(1)?, + "file": sanitize_path(&row.get::<_, String>(1)?), "type": row.get::<_, String>(2)?, "line": row.get::<_, Option>(3)?, "signature": row.get::<_, Option>(4)?, @@ -238,7 +236,7 @@ Returns: JSON object with: )?; let rows = stmt.query_map([rid], |row| { Ok(serde_json::json!({ - "caller_file": row.get::<_, String>(0)?, + "caller_file": sanitize_path(&row.get::<_, String>(0)?), "caller": row.get::<_, String>(1)?, "callee": row.get::<_, String>(2)?, })) @@ -383,12 +381,12 @@ Returns: JSON object with: if meta.is_file() { assets.push(serde_json::json!({ "name": name, - "path": entry.path(), + "path": sanitize_path(&entry.path().to_string_lossy()), })); } else if meta.is_dir() { assets.push(serde_json::json!({ "name": name, - "path": entry.path(), + "path": sanitize_path(&entry.path().to_string_lossy()), "type": "folder", })); } @@ -439,6 +437,21 @@ Returns: JSON object with: } } +/// Replace the user's home directory prefix with `~` to avoid leaking +/// absolute paths in MCP tool output (RF-7). Normalizes separators to `/`. +pub(crate) fn sanitize_path(path: &str) -> String { + if let Some(home) = dirs::home_dir() { + let home_str = home.to_string_lossy(); + // Normalize home to use forward slashes for matching + let home_normalized = home_str.replace('\\', "/"); + let path_normalized = path.replace('\\', "/"); + if let Some(rest) = path_normalized.strip_prefix(&home_normalized) { + return format!("~{}", rest); + } + } + path.replace('\\', "/") +} + pub(crate) fn collect_recent_commits(repo_path: &std::path::Path, limit: usize) -> Vec { let repo = match git2::Repository::open(repo_path) { Ok(r) => r, @@ -662,4 +675,40 @@ mod tests { let commits = collect_recent_commits(tmp.path(), 10); assert!(commits.is_empty()); } + + // ------------------------------------------------------------------ + // RF-7 path privacy tests + // ------------------------------------------------------------------ + + #[test] + fn test_sanitize_path_replaces_home_prefix() { + let home = dirs::home_dir().unwrap(); + let home_str = home.to_string_lossy().to_string(); + let input = format!("{}\\dev\\project", home_str); + let result = sanitize_path(&input); + assert_eq!(result, "~/dev/project"); + } + + #[test] + fn test_sanitize_path_normalizes_separators() { + let result = sanitize_path("src\\main.rs"); + assert_eq!(result, "src/main.rs"); + } + + #[test] + fn test_sanitize_path_leaves_relative_unchanged() { + let result = sanitize_path("src/main.rs"); + assert_eq!(result, "src/main.rs"); + } + + #[test] + fn test_sanitize_path_non_home_absolute() { + let result = sanitize_path("/tmp/foo"); + assert_eq!(result, "/tmp/foo"); + } + + #[test] + fn test_sanitize_path_empty() { + assert_eq!(sanitize_path(""), ""); + } } diff --git a/src/mcp/tools/oplog.rs b/src/mcp/tools/oplog.rs index f695da9..dc9f8ed 100644 --- a/src/mcp/tools/oplog.rs +++ b/src/mcp/tools/oplog.rs @@ -82,7 +82,8 @@ fn latency_percentiles(sorted: &[u64]) -> LatencyStats { return LatencyStats::default(); } let n = sorted.len(); - let idx = |p: f64| -> usize { ((n as f64 * p / 100.0).ceil() as usize).saturating_sub(1).min(n - 1) }; + let idx = + |p: f64| -> usize { ((n as f64 * p / 100.0).ceil() as usize).saturating_sub(1).min(n - 1) }; LatencyStats { p50_ms: sorted[idx(50.0)], p95_ms: sorted[idx(95.0)], @@ -232,8 +233,8 @@ Returns: if analytics { // MCP oplog analytics path - let data_dir = dirs::data_local_dir() - .context("Failed to determine local data directory")?; + let data_dir = + dirs::data_local_dir().context("Failed to determine local data directory")?; let log_path = data_dir.join("devbase").join("mcp-oplog.ndjson"); if !log_path.exists() { @@ -368,24 +369,20 @@ mod tests { assert_eq!(report.total_calls, 3); assert_eq!(report.success_count, 2); assert_eq!(report.error_count, 1); - assert!((report.success_rate - 66.6667).abs() < 0.01, "success_rate = {}", report.success_rate); + assert!( + (report.success_rate - 66.6667).abs() < 0.01, + "success_rate = {}", + report.success_rate + ); assert_eq!(report.unique_tools, 2); - let health = report - .tool_breakdown - .iter() - .find(|t| t.tool == "devkit_health") - .unwrap(); + let health = report.tool_breakdown.iter().find(|t| t.tool == "devkit_health").unwrap(); assert_eq!(health.calls, 2); assert_eq!(health.success, 2); // Nearest-rank ceil-based: n=2, p=50 -> idx=0 -> sorted[0]=100 assert_eq!(health.p50_latency_ms, 100); - let sync = report - .tool_breakdown - .iter() - .find(|t| t.tool == "devkit_sync") - .unwrap(); + let sync = report.tool_breakdown.iter().find(|t| t.tool == "devkit_sync").unwrap(); assert_eq!(sync.calls, 1); assert_eq!(sync.errors, 1); assert_eq!(sync.p95_latency_ms, 500); diff --git a/src/mcp/tools/vault.rs b/src/mcp/tools/vault.rs index e0ea306..e9689ce 100644 --- a/src/mcp/tools/vault.rs +++ b/src/mcp/tools/vault.rs @@ -56,11 +56,8 @@ Returns: JSON array of matching notes. Each includes: id, title, path, and tags. let query_owned = query.to_string(); let results = tokio::task::spawn_blocking(move || { let value = ctx.list_vault_notes()?; - let notes_arr = value - .get("notes") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); + let notes_arr = + value.get("notes").and_then(|v| v.as_array()).cloned().unwrap_or_default(); let keywords: Vec<&str> = query_owned.split_whitespace().collect(); let filtered: Vec<_> = notes_arr @@ -73,10 +70,7 @@ Returns: JSON array of matching notes. Each includes: id, title, path, and tags. .get("tags") .and_then(|v| v.as_array()) .map(|arr| { - arr.iter() - .filter_map(|t| t.as_str()) - .collect::>() - .join(",") + arr.iter().filter_map(|t| t.as_str()).collect::>().join(",") }) .unwrap_or_default(); let content = ctx diff --git a/src/mcp/tools/workflow.rs b/src/mcp/tools/workflow.rs index 0dfd7bb..a3a4c1d 100644 --- a/src/mcp/tools/workflow.rs +++ b/src/mcp/tools/workflow.rs @@ -144,6 +144,21 @@ Returns: execution record with status, current_step, timestamps, and duration."# #[cfg(test)] mod tests { use super::*; + use crate::workflow::model::{ErrorPolicy, StepDefinition, StepType, WorkflowDefinition}; + use std::collections::HashMap; + + fn dag_workflow(id: &str, steps: Vec) -> WorkflowDefinition { + WorkflowDefinition { + id: id.to_string(), + name: id.to_string(), + version: "0.1.0".to_string(), + description: None, + inputs: vec![], + outputs: vec![], + steps, + output_mapping: HashMap::new(), + } + } #[tokio::test] async fn test_workflow_list_empty_registry() { @@ -178,4 +193,129 @@ mod tests { let result = tool.invoke(serde_json::json!({"execution_id": -1}), &mut ctx).await.unwrap(); assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(false)); } + + /// End-to-end: 3-step DAG workflow (a -> b -> c) executed via MCP tool. + /// Verifies registration -> run -> status query full chain. + #[tokio::test] + async fn test_workflow_run_dag_success() { + let backend = std::sync::Arc::new(crate::storage::TempStorageBackend::new()); + let mut ctx = crate::storage::AppContext::with_storage(backend).unwrap(); + + let wf = dag_workflow( + "dag-success", + vec![ + StepDefinition { + id: "a".to_string(), + step_type: StepType::Condition { r#if: "true".to_string() }, + inputs: HashMap::new(), + depends_on: vec![], + on_error: ErrorPolicy::Fail, + timeout_seconds: None, + }, + StepDefinition { + id: "b".to_string(), + step_type: StepType::Condition { r#if: "true".to_string() }, + inputs: HashMap::new(), + depends_on: vec!["a".to_string()], + on_error: ErrorPolicy::Fail, + timeout_seconds: None, + }, + StepDefinition { + id: "c".to_string(), + step_type: StepType::Condition { r#if: "true".to_string() }, + inputs: HashMap::new(), + depends_on: vec!["b".to_string()], + on_error: ErrorPolicy::Fail, + timeout_seconds: None, + }, + ], + ); + { + let conn = ctx.conn().unwrap(); + crate::workflow::save_workflow(&conn, &wf).unwrap(); + } + + let run_tool = DevkitWorkflowRunTool; + let result = run_tool + .invoke(serde_json::json!({"workflow_id": "dag-success"}), &mut ctx) + .await + .unwrap(); + + assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(result.get("status").and_then(|v| v.as_str()), Some("Completed")); + + let exec_id = result.get("execution_id").and_then(|v| v.as_i64()).unwrap(); + assert!(exec_id > 0); + + let step_results = result.get("step_results").and_then(|v| v.as_object()).unwrap(); + assert!(step_results.contains_key("a")); + assert!(step_results.contains_key("b")); + assert!(step_results.contains_key("c")); + + // Round-trip status query + let status_tool = DevkitWorkflowStatusTool; + let status = status_tool + .invoke(serde_json::json!({"execution_id": exec_id}), &mut ctx) + .await + .unwrap(); + assert_eq!(status.get("success").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(status.get("status").and_then(|v| v.as_str()), Some("Completed")); + assert_eq!(status.get("workflow_id").and_then(|v| v.as_str()), Some("dag-success")); + } + + /// Failure propagation: step a succeeds, step b fails (missing skill) with ErrorPolicy::Fail. + /// Verifies workflow returns Failed status and execution record reflects failure. + #[tokio::test] + async fn test_workflow_run_failure_propagation() { + let backend = std::sync::Arc::new(crate::storage::TempStorageBackend::new()); + let mut ctx = crate::storage::AppContext::with_storage(backend).unwrap(); + + let wf = dag_workflow( + "dag-fail", + vec![ + StepDefinition { + id: "a".to_string(), + step_type: StepType::Condition { r#if: "true".to_string() }, + inputs: HashMap::new(), + depends_on: vec![], + on_error: ErrorPolicy::Fail, + timeout_seconds: None, + }, + StepDefinition { + id: "b".to_string(), + step_type: StepType::Skill { + skill: "nonexistent-skill".to_string(), + }, + inputs: HashMap::new(), + depends_on: vec!["a".to_string()], + on_error: ErrorPolicy::Fail, + timeout_seconds: None, + }, + ], + ); + { + let conn = ctx.conn().unwrap(); + crate::workflow::save_workflow(&conn, &wf).unwrap(); + } + + let run_tool = DevkitWorkflowRunTool; + let result = run_tool + .invoke(serde_json::json!({"workflow_id": "dag-fail"}), &mut ctx) + .await + .unwrap(); + + assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(result.get("status").and_then(|v| v.as_str()), Some("Failed")); + + let exec_id = result.get("execution_id").and_then(|v| v.as_i64()).unwrap(); + assert!(exec_id > 0); + + let status_tool = DevkitWorkflowStatusTool; + let status = status_tool + .invoke(serde_json::json!({"execution_id": exec_id}), &mut ctx) + .await + .unwrap(); + assert_eq!(status.get("success").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(status.get("status").and_then(|v| v.as_str()), Some("Failed")); + } } diff --git a/src/search/hybrid.rs b/src/search/hybrid.rs index f374890..d8ce849 100644 --- a/src/search/hybrid.rs +++ b/src/search/hybrid.rs @@ -419,6 +419,8 @@ mod tests { symbol_type TEXT NOT NULL, name TEXT NOT NULL, line_start INTEGER, + line_end INTEGER, + signature TEXT, PRIMARY KEY (repo_id, file_path, name) )", [], @@ -445,10 +447,29 @@ mod tests { let (_results, metrics) = hybrid_search_symbols_with_metrics(&conn, "repo1", "func_500", None, 20).unwrap(); + + eprintln!( + "[perf] 1k docs keyword search latency: {}ms (profile: {})", + metrics.latency_ms, + if cfg!(debug_assertions) { + "debug" + } else { + "release" + } + ); + + // Thresholds differ by build profile to avoid debug-mode false positives. + let threshold_ms = if cfg!(debug_assertions) { 800 } else { 200 }; assert!( - metrics.latency_ms < 200, - "keyword search latency {}ms exceeds 200ms threshold @ 1k docs", - metrics.latency_ms + metrics.latency_ms < threshold_ms, + "keyword search latency {}ms exceeds {}ms threshold @ 1k docs (profile: {})", + metrics.latency_ms, + threshold_ms, + if cfg!(debug_assertions) { + "debug" + } else { + "release" + } ); } @@ -463,6 +484,8 @@ mod tests { symbol_type TEXT NOT NULL, name TEXT NOT NULL, line_start INTEGER, + line_end INTEGER, + signature TEXT, PRIMARY KEY (repo_id, file_path, name) )", [], @@ -489,10 +512,28 @@ mod tests { let (_results, metrics) = hybrid_search_symbols_with_metrics(&conn, "repo1", "func_5000", None, 20).unwrap(); + + eprintln!( + "[perf] 10k docs keyword search latency: {}ms (profile: {})", + metrics.latency_ms, + if cfg!(debug_assertions) { + "debug" + } else { + "release" + } + ); + + let threshold_ms = if cfg!(debug_assertions) { 2000 } else { 500 }; assert!( - metrics.latency_ms < 500, - "keyword search latency {}ms exceeds 500ms threshold @ 10k docs", - metrics.latency_ms + metrics.latency_ms < threshold_ms, + "keyword search latency {}ms exceeds {}ms threshold @ 10k docs (profile: {})", + metrics.latency_ms, + threshold_ms, + if cfg!(debug_assertions) { + "debug" + } else { + "release" + } ); } } diff --git a/src/storage.rs b/src/storage.rs index e614a7e..ec7d028 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -11,7 +11,7 @@ use crate::config::Config; use crate::i18n::{I18n, from_language}; use crate::registry::{ENTITY_TYPE_REPO, WorkspaceRegistry}; -use crate::search::{list_indexed_repo_ids_at, sync_index_to_db}; +use crate::search::list_indexed_repo_ids_at; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use std::path::PathBuf; @@ -140,7 +140,8 @@ impl AppContext { let path = storage.db_path()?; // 先执行 init_db_with 确保数据库已初始化并迁移 let mut conn = WorkspaceRegistry::init_db_with(&*storage)?; - match repair_tantivy_consistency(&mut conn) { + let index_path = storage.index_path()?; + match repair_tantivy_consistency_at(&index_path, &mut conn) { Ok(result) => { if result.orphans > 0 || result.missing_from_index > 0 { tracing::info!( @@ -152,7 +153,7 @@ impl AppContext { } Err(e) => tracing::warn!("Startup Tantivy consistency check failed: {}", e), } - if let Err(e) = sync_index_to_db(&conn) { + if let Err(e) = crate::search::sync_index_to_db_at(&index_path, &conn) { tracing::warn!("Startup Tantivy/SQLite orphan sync failed: {}", e); } drop(conn); @@ -172,7 +173,8 @@ impl AppContext { pub fn with_storage(storage: Arc) -> anyhow::Result { let path = storage.db_path()?; let mut conn = WorkspaceRegistry::init_db_with(&*storage)?; - match repair_tantivy_consistency(&mut conn) { + let index_path = storage.index_path()?; + match repair_tantivy_consistency_at(&index_path, &mut conn) { Ok(result) => { if result.orphans > 0 || result.missing_from_index > 0 { tracing::info!( @@ -184,7 +186,7 @@ impl AppContext { } Err(e) => tracing::warn!("Startup Tantivy consistency check failed: {}", e), } - if let Err(e) = sync_index_to_db(&conn) { + if let Err(e) = crate::search::sync_index_to_db_at(&index_path, &conn) { tracing::warn!("Startup Tantivy/SQLite orphan sync failed: {}", e); } drop(conn); @@ -252,49 +254,31 @@ pub(crate) struct RepairResult { pub missing_from_index: usize, } -/// Startup consistency scan: detect Tantivy documents whose repo no longer exists in SQLite. -/// Also detects SQLite repos that are missing from the Tantivy index. -/// Inserts orphan records into `orphan_tantivy_docs` for lazy cleanup during next index. -pub(crate) fn repair_tantivy_consistency( - conn: &mut rusqlite::Connection, -) -> anyhow::Result { - let backend = DefaultStorageBackend {}; - let index_path = match backend.index_path() { - Ok(p) => p, - Err(e) => { - tracing::warn!("Failed to resolve index path: {}", e); - return Ok(RepairResult { - orphans: 0, - missing_from_index: 0, - }); - } - }; - repair_tantivy_consistency_at(&index_path, conn) -} - /// Repair Tantivy consistency at an explicit index path, bypassing global storage backend. pub(crate) fn repair_tantivy_consistency_at( index_path: &std::path::Path, conn: &mut rusqlite::Connection, ) -> anyhow::Result { + // Load SQLite IDs first — this side never fails in normal operation. + let sqlite_ids: std::collections::HashSet = { + let mut stmt = conn.prepare("SELECT id FROM entities WHERE entity_type = ?1")?; + let rows = stmt.query_map([ENTITY_TYPE_REPO], |row| row.get::<_, String>(0))?; + rows.filter_map(Result::ok).collect() + }; + let tantivy_ids: std::collections::HashSet = match list_indexed_repo_ids_at(index_path) { Ok(ids) => ids.into_iter().collect(), Err(e) => { tracing::warn!("Failed to list Tantivy repo IDs: {}", e); + // When Tantivy is unreadable, every SQLite repo is potentially missing. return Ok(RepairResult { orphans: 0, - missing_from_index: 0, + missing_from_index: sqlite_ids.len(), }); } }; - let sqlite_ids: std::collections::HashSet = { - let mut stmt = conn.prepare("SELECT id FROM entities WHERE entity_type = ?1")?; - let rows = stmt.query_map([ENTITY_TYPE_REPO], |row| row.get::<_, String>(0))?; - rows.filter_map(Result::ok).collect() - }; - // Clear stale orphans: repos that are now present in SQLite but still marked orphan { let mut stmt = conn.prepare("SELECT repo_id FROM orphan_tantivy_docs")?; @@ -450,4 +434,62 @@ mod tests { .unwrap_or(false); assert!(!orphan_still_exists); } + + /// Fresh workspace first startup: empty index + empty DB should be consistent. + #[test] + fn test_repair_tantivy_consistency_fresh_workspace() { + let backend = TempStorageBackend::new(); + let index_path = backend.index_path().unwrap(); + let db_path = backend.db_path().unwrap(); + + let mut conn = WorkspaceRegistry::init_db_at(&db_path).unwrap(); + + let result = repair_tantivy_consistency_at(&index_path, &mut conn).unwrap(); + assert_eq!(result.orphans, 0); + assert_eq!(result.missing_from_index, 0); + } + + /// First startup with pre-populated DB but empty index: should detect missing repos. + #[test] + fn test_repair_tantivy_consistency_empty_index_with_db_repos() { + let backend = TempStorageBackend::new(); + let index_path = backend.index_path().unwrap(); + let db_path = backend.db_path().unwrap(); + + let mut conn = WorkspaceRegistry::init_db_at(&db_path).unwrap(); + + // Pre-populate DB with 2 repos before index exists + conn.execute( + "INSERT INTO entities (id, entity_type, name, local_path, metadata, created_at, updated_at) + VALUES ('repo-a', 'repo', 'Repo A', '/tmp/a', '{}', datetime('now'), datetime('now')), + ('repo-b', 'repo', 'Repo B', '/tmp/b', '{}', datetime('now'), datetime('now'))", + [], + ).unwrap(); + + let result = repair_tantivy_consistency_at(&index_path, &mut conn).unwrap(); + assert_eq!(result.orphans, 0); + assert_eq!(result.missing_from_index, 2); + } + + /// AppContext::with_storage must use TempStorageBackend's index path, not the global default. + #[test] + fn test_app_context_uses_correct_index_path() { + let storage = Arc::new(TempStorageBackend::new()); + let expected_index = storage.index_path().unwrap(); + + // AppContext creation triggers consistency check + sync against the temp index path + let ctx = AppContext::with_storage(storage.clone()).unwrap(); + + // The index directory should have been created at the temp path, not the default path + assert!(expected_index.exists(), "index dir should exist at temp path"); + + // Verify we can open the index that was created + let (_index, _reader) = crate::search::init_index_at(&expected_index).unwrap(); + + // Verify DB is accessible + let conn = ctx.conn().unwrap(); + let count: i64 = + conn.query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0)).unwrap(); + assert_eq!(count, 0); // fresh workspace, no repos yet + } } From 0398d21a436ecef6c4110f4fc1fabf7d3c6a4b84 Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 22:20:37 +0800 Subject: [PATCH 10/12] chore(release): bump version to 0.20.1 Co-Authored-By: Claude Opus 4.7 --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fee782d..35b2cfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devbase" -version = "0.20.0" +version = "0.20.1" edition = "2024" description = "Developer workspace database and knowledge-base manager" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] @@ -105,7 +105,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.20.0" +version = "0.20.1" authors = ["juice094 <160722440+juice094@users.noreply.github.com>"] edition = "2024" license = "AGPL-3.0-or-later" From cec420c2eaa8e45dc3efa8b083981ae737f99fae Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 22:27:05 +0800 Subject: [PATCH 11/12] fix(ci): invariant checker should skip tests.rs files The G5 RF-6 rule was flagging in as production code because the skip regex did not match (no trailing slash). in test modules is idiomatic Rust and should not be flagged. Co-Authored-By: Claude Opus 4.7 --- tools/invariant-checks/run-checks.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/invariant-checks/run-checks.ps1 b/tools/invariant-checks/run-checks.ps1 index 5d83470..ecfc135 100644 --- a/tools/invariant-checks/run-checks.ps1 +++ b/tools/invariant-checks/run-checks.ps1 @@ -77,7 +77,7 @@ if (-not $diffFiles) { $newViolations = @() foreach ($file in $diffFiles -split "`n") { if ($file -notmatch '\.rs$') { continue } - if ($file -match 'tests?/|_test\.rs$|benches/|examples/') { continue } + if ($file -match 'tests?/|tests\.rs$|_test\.rs$|benches/|examples/') { continue } # Get test line ranges for the file $testRanges = Get-TestLineRanges $file From a794fdf63d064dec1d1fa3faa70719cac1349b3d Mon Sep 17 00:00:00 2001 From: juice094 Date: Sun, 17 May 2026 22:29:51 +0800 Subject: [PATCH 12/12] chore(release): update Cargo.lock for v0.20.1 The release workflow uses --locked which requires Cargo.lock to be in sync with Cargo.toml version bumps. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c4bfc2c..21bddc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1436,7 +1436,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "devbase" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "assert_cmd", @@ -1499,14 +1499,14 @@ dependencies = [ [[package]] name = "devbase-core-types" -version = "0.20.0" +version = "0.20.1" dependencies = [ "chrono", ] [[package]] name = "devbase-embedding" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "candle-core", @@ -1520,7 +1520,7 @@ dependencies = [ [[package]] name = "devbase-registry-call-graph" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "rusqlite", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "devbase-registry-code-symbols" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "rusqlite", @@ -1536,7 +1536,7 @@ dependencies = [ [[package]] name = "devbase-registry-dead-code" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "rusqlite", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "devbase-registry-entity" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "chrono", @@ -1554,7 +1554,7 @@ dependencies = [ [[package]] name = "devbase-registry-health" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "chrono", @@ -1564,7 +1564,7 @@ dependencies = [ [[package]] name = "devbase-registry-metrics" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "chrono", @@ -1575,7 +1575,7 @@ dependencies = [ [[package]] name = "devbase-registry-relation" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "chrono", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "devbase-registry-workspace" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "chrono", @@ -1594,7 +1594,7 @@ dependencies = [ [[package]] name = "devbase-skill-runtime-parser" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "chrono", @@ -1603,7 +1603,7 @@ dependencies = [ [[package]] name = "devbase-skill-runtime-types" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "chrono", @@ -1613,7 +1613,7 @@ dependencies = [ [[package]] name = "devbase-symbol-links" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "chrono", @@ -1622,7 +1622,7 @@ dependencies = [ [[package]] name = "devbase-sync-protocol" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "chrono", @@ -1632,7 +1632,7 @@ dependencies = [ [[package]] name = "devbase-syncthing-client" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "reqwest", @@ -1641,15 +1641,15 @@ dependencies = [ [[package]] name = "devbase-vault-frontmatter" -version = "0.20.0" +version = "0.20.1" [[package]] name = "devbase-vault-wikilink" -version = "0.20.0" +version = "0.20.1" [[package]] name = "devbase-workflow-interpolate" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "regex", @@ -1659,7 +1659,7 @@ dependencies = [ [[package]] name = "devbase-workflow-model" -version = "0.20.0" +version = "0.20.1" dependencies = [ "serde", "serde_json",