diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..5d2ae1e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,87 @@ +# Copilot instructions for codeweb + +Purpose +- Give future Copilot sessions concise, repo-specific guidance: how to build/test/lint, the big-picture architecture, and non-obvious conventions. + +Quick commands +- Build (default features): + - cargo build +- Build with features: + - cargo build --features serve + - cargo build --features mcp + - cargo build --features full +- Tests: + - Run all tests: cargo test + - Run tests with feature: cargo test --features serve + - Run a single unit test by name: cargo test + - Run a single integration test file: cargo test --test +- Lint & format checks (CI uses these): + - cargo fmt -- --check + - cargo clippy --features full -- -D warnings + - cargo fmt (to autoformat) + +CI notes +- CI runs clippy and tests with --features full. Some long/host-bound tests are skipped in CI via -- --skip test_path_mapping_applied --skip test_serve_. +- Follow the project's Definition of Done: verify builds/tests/clippy/format under relevant feature combos (especially --features full when touching feature-gated code). + +High-level architecture (big picture) +- Purpose: build a semantic directed graph linking Java methods, MyBatis mappers, SQL, and stored procedures/tables. +- Layers: + - CLI (src/main.rs) — clap commands (init, analyze, trace, export, serve, mcp, tui) + - Parser layer (src/parser/*) — ogsql-parser for SQL; tree-sitter-java for Java; ibatis XML loader for mappers; JSP preprocessing when jsp feature enabled + - Graph model (src/graph/*) — GraphStore, builder, resolver, traversal and declarative QuerySpec + - Export/import (src/export, src/import) — DOT/JSON/Mermaid and CGEF import/merge + - Runtime surfaces: + - TUI (feature: tui) + - HTTP server + Browser UI (feature: serve) + - MCP server (feature: mcp) — exposes MCP tools for LLM clients +- Incremental analysis: file fingerprinting (blake3) used to avoid re-parsing unchanged files; GraphStore serialized with bincode. +- Exports: DOT, JSON, Mermaid. Imports: CGEF JSON. + +Key repo-specific conventions and mapping rules +- Feature flags matter: default = [cli, tui, jsp]. Use `--features full` when making changes that touch multiple gated areas. +- Java <-> Mapper <-> SQL mapping rules (important for trace tasks): + - Java interface FQN == mapper namespace + - Java method name == mapper statement id + - Calls like sqlSession.selectList("namespace.id") map to a MappedStatement +- JSP feature: jsp preprocesses JSP into synthetic Java and extracts SQL via ogsql-parser's Java extraction; JDBC escape `{call ...}` may require post-processing +- Node types & tags: procedures (proc/ proc*), functions (func/func*), table/table*, mapper, method, jsp/jspsql, etc. Many commands accept node-type filters (e.g., --type proc) +- Incremental test/dev workflow: + - Run targeted unit tests during development: cargo test + - For integration tests use: cargo test --test + - CI may skip long tests — be aware when reproducing CI locally (remove --skip args to run everything) +- Code quality gates: + - No any/anyhow in libraries; prefer thiserror for error types + - Use cargo fmt and cargo clippy with -D warnings before PR +- Git workflow: never push directly to main; branch naming: feature/ or fix/ (kebab-case); create PRs and ensure CI passes +- Commit trailer: include Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> when a Copilot-made commit is created (project policy enforced by tooling in this workspace) + +Where to look for deeper context +- README.md, CONTRIBUTION.md, AGENTS.md, docs/DeveloperGuide.md, docs/getting-started.md, docs/serve-api-guide.md +- parser/ and graph/ folders for how relationships are extracted and represented + +Existing AI-agent configs +- AGENTS.md exists and documents phased goals and conventions for automated agents (useful for MCP/tool integration). No CLAUDE.md or other assistant-specific config files were found. + +MCP server integration snippet (from README) +- Claude Desktop example: + { + "mcpServers": { + "codeweb": { + "command": "/path/to/codeweb", + "args": ["mcp", "--project", "/path/to/your/project"] + } + } + } + +Notes for Copilot sessions +- When reasoning about call chains prefer starting from parser outputs (parser/*) and GraphStore APIs rather than searching ad-hoc across the repo. +- When editing or adding feature-gated code, run cargo build/test/clippy with --features full locally before opening PR. +- Use exported node types and mapping rules to resolve cross-language edges (Java → mapper → SQL → proc) + +Files used to compose this guidance +- README.md, CONTRIBUTION.md, AGENTS.md, Cargo.toml, .github/workflows/ci.yml + +--- + +If you'd like, configure MCP servers (Claude/Desktop, Cursor, VS Code Copilot Chat) for this repo now — say which server(s) to add and Copilot will prepare suggested config snippets and a short setup checklist. diff --git a/.github/hooks/workmux-status/hooks.json b/.github/hooks/workmux-status/hooks.json new file mode 100644 index 0000000..3e69b55 --- /dev/null +++ b/.github/hooks/workmux-status/hooks.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "hooks": { + "userPromptSubmitted": [ + { + "type": "command", + "bash": "workmux set-window-status working" + } + ], + "postToolUse": [ + { + "type": "command", + "bash": "workmux set-window-status working" + } + ], + "agentStop": [ + { + "type": "command", + "bash": "workmux set-window-status done" + } + ] + } +} diff --git a/.sisyphus/plans/fix-analyze-stale-store-version.md b/.sisyphus/plans/fix-analyze-stale-store-version.md new file mode 100644 index 0000000..d255850 --- /dev/null +++ b/.sisyphus/plans/fix-analyze-stale-store-version.md @@ -0,0 +1,119 @@ +# Fix: analyze 不校验 store 版本 → 升级二进制后 stale 缓存死循环 + +## 问题(来自真实用户场景) + +用户升级 codeweb 二进制(STORE_VERSION 7→8, PR #148)后,在旧项目目录执行: + +``` +codeweb analyze → "Up to date. 136 files, 0 nodes, 0 edges." (不重建) +codeweb stats → error: unsupported cache version 7, expected 8 — run `codeweb analyze` to regenerate +``` + +死循环:错误信息让用户跑 analyze,但 analyze 因指纹未变而拒绝重建。 + +## 根因 + +`src/project/mod.rs:143-172` — `analyze()` 的 up-to-date 判定只比较**文件指纹 vs manifest 边车**(`load_manifest_only` → `compute_changes`),从不触碰 store 文件本身。而 STORE_VERSION 升级作废的是 store(`src/graph/store.rs:22` `STORE_VERSION: u32 = 8`),不是 manifest 边车(边无版本头,`FileRecord` 布局 v7↔v8 未变,旧边车反序列化成功)→ `changes.is_empty()` 为真 → 提前返回,v7 store 原样保留。 + +次生问题:up-to-date 提前返回时 `self.store` 为 `None`,报告里 `nodes/edges` 来自 `unwrap_or(0)`(mod.rs:158-167),`print_analyze_report`(main.rs:3758-3764)于是永远打印 `0 nodes, 0 edges` —— 与 store 实际内容无关,误导用户。 + +## 修复设计(两个 TDD 循环) + +### 循环 1(核心):analyze 在 up-to-date 判定前校验 store 版本,不匹配 → 强制 full rebuild + +**行为断言(Red 测试)**:`analyze_rebuilds_when_store_version_stale`(`src/project/mod.rs` `mod tests`,行 579 处已有测试模块,可在模块内直接构造 `Project { root, config, store: None }`,config 用 `ProjectConfig::load(toml_str)` 从字符串解析) + +测试步骤: +1. `tempfile::TempDir` 建项目目录,写入一个 `.sql` 文件(内容任意,`parse_file` 对 tokenizer 失败才返回 Err,普通文本也会记录哈希 → 断言不依赖 SQL 解析成功) +2. `proj.analyze()` → 断言 `report.is_full_build == true`(建立基线,manifest 边车写入) +3. 用 v7 布局字节覆盖 `store.bincode`:`STORE_MAGIC + 7u32.to_le_bytes() + [0u8; 8]`(模板照抄既有测试 `load_bincode_rejects_previous_layout_version`, store.rs:2324-2343);manifest 边车不动 +4. 再跑 `proj.analyze()` → 断言 `report.is_up_to_date == false` 且 `report.is_full_build == true` +5. 断言磁盘上 `store.bincode` 头部版本 == 当前 `STORE_VERSION`(自愈验证) + +**最小实现(Green)**: + +1. `src/graph/store.rs` 新增轻量头探测(不做全量反序列化): +```rust +/// Peek at the on-disk store's format version WITHOUT deserializing the payload. +/// Returns `None` for missing files and legacy headerless (pre-#110) stores; +/// callers treat `None` as stale. +pub fn peek_version(path: &Path) -> Option { + use std::io::Read; + let mut file = std::fs::File::open(path).ok()?; + let mut header = [0u8; 13]; + file.read_exact(&mut header).ok()?; + if header[..9] != STORE_MAGIC { + return None; + } + Some(u32::from_le_bytes([header[9], header[10], header[11], header[12]])) +} +``` + +2. `src/project/mod.rs` `Project` 新增私有方法: +```rust +/// True when the on-disk store exists and its format version matches +/// STORE_VERSION. Mismatch / missing / legacy headerless → stale. +fn store_is_current(&self) -> bool { + let store_path = self.store_path(); + if !store_path.exists() { return false; } + match self.config.store.format { + config::StoreFormat::Bincode => { + GraphStore::peek_version(&store_path).is_some_and(|v| v == STORE_VERSION) + } + config::StoreFormat::Json => { + // JSON 无 13 字节头;全量加载后查 version 字段(JSON 格式为非默认 opt-in, + // 且 load_json 自带版本门禁,mismatch 即 Err → false) + GraphStore::load_json(&store_path).map(|s| s.version == STORE_VERSION).unwrap_or(false) + } + } +} +``` + +3. `analyze()` 修改一行判定(mod.rs:148): +```rust +let is_up_to_date = changes.is_empty() && self.store_is_current(); +``` + STORE_VERSION 不变(保持 8);修复后 analyze 自愈:mismatch → 走既有 full build 路径 → `save_bincode` 覆写为 v8 + 重写边车。 + +**store.rs 单测(随循环 1 一起,锚定 peek 行为)**: +- `peek_version_returns_header_version`:写 `STORE_MAGIC + STORE_VERSION.to_le_bytes()` → `Some(STORE_VERSION)` +- `peek_version_none_for_legacy_headerless`:纯 bincode 字节(模板 store.rs:2346-2361)→ `None` +- `peek_version_none_for_missing_file`:不存在路径 → `None` + +### 循环 2(次生):up-to-date 输出不再显示假 0/0 计数 + +**行为断言(Red 测试)**:把 main.rs:3760-3764 的行文案构造提为纯函数并测试: +```rust +fn format_up_to_date_line(report: &project::AnalyzeReport) -> String { + // store 未加载时(analyze 快路径)报告中的 nodes/edges 恒为 0, + // 打印出来是误导 —— 只报文件数。 + format!("Up to date. {} files.", report.files_scanned) +} +``` +测试:`up_to_date_line_reports_files_without_zero_counts` —— 构造 `AnalyzeReport { nodes: 0, edges: 0, files_scanned: 136, .. }`,断言输出为 `"Up to date. 136 files."` 且不含 `"0 nodes"`。 +(注:核心 bug 修复后 up-to-date 快路径只在 store 版本匹配时可达,加载 store 换真计数会牺牲快路径性能;只报文件数是零成本且诚实的折中。`print_analyze_report` 改调该函数。) + +## 明确不做(Out of scope) + +- 不改 `STORE_VERSION`(保持 8,无需再 bump:修复不改变 v8 布局) +- 不动 `diff` 命令(只展示文件差异,不读 store) +- 不做 store 迁移/升级(full rebuild 即自愈,符合既有"拒绝+重建"设计,store.rs:1172-1179 错误信息语义不变) +- 不修 explore 报告提到的其他静默失败(loader 丢文件等,与本 bug 无关) + +## 验证门禁(AGENTS.md 规定) + +```bash +cargo test --features <新测试名> # 循环内单测 +cargo fmt --all -- --check +cargo clippy --features full -- -D warnings +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +``` + +注:`test_serve_*` / `test_path_mapping_applied` 为 CI 既有环境跳过项,失败与本次无关。 + +## 风险与边界 + +- store 存在但边车缺失:`load_manifest_only` 返回空 → `is_full_build=true` → 本来就 full rebuild,行为不变 +- v7 边车可被 v8 二进制正常反序列化(FileRecord 未变)→ 指纹判定仍为"无变化",由 store 版本校验兜底触发重建 —— 两道检查互补 +- 性能:bincode 快路径只读 13 字节;Json 格式全量加载但属非默认 opt-in +- `is_some_and`:Rust 1.70+ 稳定 API,仓库无 rust-toolchain.toml、CI 用 stable,可用 diff --git a/README.md b/README.md index 2213b30..2e8f5c2 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,9 @@ Add to `claude_desktop_config.json`: | Tool | Description | |------|-------------| +| `codeweb_init` | Create `codeweb.toml` + `.codeweb/` in the served directory (no analysis) | +| `codeweb_analyze` | Build/refresh the graph and hot-swap it in (no restart needed) | +| `codeweb_diff` | List files changed since the last analysis | | `codeweb_stats` | Project statistics (node/edge/file counts by type) | | `codeweb_nodes` | List nodes with search, type filter, pagination | | `codeweb_node_detail` | Node properties, callers, and callees by ID | @@ -271,6 +274,20 @@ Add to `claude_desktop_config.json`: | `codeweb_column_analysis` | Aggregate column-analysis for a procedure/package | | `codeweb_lineage` | Table-level and column-level lineage analysis | +### Lifecycle (init / analyze / diff) + +The server starts even when the directory has no `codeweb.toml` yet: queries then +return `status: "uninitialized"` instead of the process exiting. + +1. `codeweb_init` — writes `codeweb.toml` + `.codeweb/` under the served directory. + It never analyzes on its own. +2. `codeweb_analyze` — builds or incrementally refreshes the graph and hot-swaps it + into the running server, so later queries see it without a restart. +3. `codeweb_diff` — lists files changed since the last analysis. + +Reads may point at any directory (`analysis.paths`), but every write is confined to +the served directory: a tampered `store.path` that escapes it is rejected. + ## Project Structure ``` @@ -607,6 +624,9 @@ codeweb mcp --project /path/to/your/project | 工具 | 说明 | |------|------| +| `codeweb_init` | 在服务目录创建 `codeweb.toml` + `.codeweb/`(不触发分析) | +| `codeweb_analyze` | 构建/刷新图谱并热替换到内存(无需重启) | +| `codeweb_diff` | 列出相对上次分析变更的文件 | | `codeweb_stats` | 项目统计(各类型节点/边/文件数量) | | `codeweb_nodes` | 节点列表(搜索、类型过滤、分页) | | `codeweb_node_detail` | 节点详情:属性 + 上游调用方 + 下游被调用方 | @@ -616,6 +636,18 @@ codeweb mcp --project /path/to/your/project | `codeweb_column_analysis` | 按过程/包聚合列级分析结果 | | `codeweb_lineage` | 表级与列级血缘分析 | +### 生命周期工具(init / analyze / diff) + +即使目录里还没有 `codeweb.toml`,服务也会正常启动:查询返回 +`status: "uninitialized"`,而不是进程直接退出。 + +1. `codeweb_init` —— 在服务目录写入 `codeweb.toml` + `.codeweb/`,**不会**自动分析。 +2. `codeweb_analyze` —— 构建或增量刷新图谱,并热替换到运行中的服务,后续查询无需重启即可看到。 +3. `codeweb_diff` —— 列出相对上次分析变更的文件。 + +读取可以指向任意目录(`analysis.paths`),但所有写入都被限制在服务目录内: +`store.path` 若被改成逃逸出该目录会被拒绝。 + ## 项目结构 ``` diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index 219537e..5eed75f 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -354,6 +354,9 @@ codeweb 提供四种 MCP/外部集成方式: | 工具 | 参数 | 说明 | |------|------|------| +| `codeweb_init` | `name`, `paths` | 在服务目录创建 `codeweb.toml` + `.codeweb/`(不触发分析) | +| `codeweb_analyze` | 无 | 构建/刷新图谱并热替换内存快照(`spawn_blocking`) | +| `codeweb_diff` | 无 | 列出相对上次分析变更的文件 | | `codeweb_stats` | 无 | 项目统计(各类节点/边/文件数量) | | `codeweb_nodes` | `search`, `node_type`, `limit`, `offset` | 节点列表(搜索、类型过滤、分页) | | `codeweb_node_detail` | `id` (usize) | 节点详情:属性 + callers + callees | @@ -382,6 +385,15 @@ codeweb 提供四种 MCP/外部集成方式: - `src/mcp/server.rs` — 服务入口(加载 GraphStore → 启动 tokio runtime → stdio 传输) - 复用 `GraphStore` 的全部索引和查询能力,与 HTTP API 共享后端 +**状态模型(issue #171):** + +- `McpState` 持有 `Arc`:`permitted_root`(唯一可写目录)、`Mutex>`(生命周期工具)、`RwLock`(查询工具)。 +- 查询工具读取 `Arc` 快照后立即释放锁,长查询不会阻塞 `codeweb_analyze`。 +- `codeweb_analyze` 在 `tokio::task::spawn_blocking` 中运行 `Project::analyze`(CPU 密集、同步),完成后把新 store 换入快照。 +- 未初始化目录不再导致进程退出:查询返回 `status: "uninitialized"`,引导调用 `codeweb_init`。 +- 写守卫 `confine_to_root` 做词法归一化后校验路径在 `permitted_root` 内;`store.path` 逃逸时 analyze 直接返回错误。 +- stdout 只用于 JSON-RPC:进度条与报告一律走 stderr,且 MCP 不调用 CLI 的 `print_analyze_report`。 + ### 程序化 API 示例 ```rust diff --git a/docs/plans/2026-09-20-issue-171-mcp-lifecycle-tools.md b/docs/plans/2026-09-20-issue-171-mcp-lifecycle-tools.md new file mode 100644 index 0000000..d27a504 --- /dev/null +++ b/docs/plans/2026-09-20-issue-171-mcp-lifecycle-tools.md @@ -0,0 +1,143 @@ +# Issue #171: MCP 模式生命周期工具(init / analyze / diff) + +## 问题 + +`codeweb mcp` 只暴露 8 个只读查询工具。冷启动路径是断的: + +1. `src/mcp/server.rs` 第一行 `Project::find(project_path)?` —— 目录里没有 `codeweb.toml` 时进程直接退出,客户端拿不到任何 JSON-RPC 响应。 +2. `McpState` 只持有 `Arc`,图中的 store 在启动时加载一次,之后不可变。 +3. store 缺失/损坏/过期时,所有工具返回 `{"status":"empty"}` 并让用户手动去跑 `codeweb analyze` 然后**重启 MCP 进程**。 +4. 即使外部跑了 `analyze`,内存图也不会刷新。 + +结果:LLM 客户端无法自行初始化项目、无法构建图谱、无法查看变更,必须人工介入。 + +## 目标(范围经确认) + +只加三个生命周期工具,**不自动 analyze**: + +| Tool | 语义 | +|---|---| +| `codeweb_init` | 在服务指向的目录创建 `codeweb.toml` + `.codeweb/`,不自动 analyze | +| `codeweb_analyze` | 全量/增量构建图谱,成功后热替换内存 store,无需重启 | +| `codeweb_diff` | 列出相对上次分析的变更文件 | + +约束: + +- 允许读用户指定的任意目录(config `analysis.paths`)。 +- **写必须在许可目录内**(服务启动时确定的项目根)。不引入 `--allow-write` 开关。 +- 可以修改 `Project::init` 的形状(加 `root` 参数)。 + +## 设计决策 + +### 1. 许可目录 = 项目根 + +- 已初始化:`Project::find(project_path)` 找到的 `codeweb.toml` 所在目录。 +- 未初始化:`--project` 参数指向的目录(canonicalize 后)。 + +所有写操作(`codeweb.toml`、`.codeweb/`、store、manifest、`parse.log`)都必须落在该目录树内。 +`confine_to_root(root, candidate)` 做词法归一化(处理 `.` / `..`)后校验 `starts_with(root)`,防止 +`store.path = "../../escape.bincode"` 这类配置把写操作带出许可目录。读路径不校验。 + +### 2. 状态改为可变,查询走快照 + +```rust +pub struct McpState { inner: Arc } + +struct Inner { + permitted_root: PathBuf, // 唯一可写目录树 + project: Mutex, // Option,生命周期工具使用 + graph: RwLock, // 查询工具使用 + // Project 与 store 分开加锁,避免 analyze 期间阻塞查询 +} + +struct GraphSnapshot { + store: Arc, + project_name: String, + empty_reason: Option, +} +``` + +- 查询工具:`RwLock::read()` 取出 `Arc` 快照后立即释放锁。 +- `codeweb_analyze`:`tokio::task::spawn_blocking` 内持有 project 锁;analyze 是 CPU 密集同步函数, + 不能阻塞 async runtime。完成后写锁更新 `GraphSnapshot`。 +- 因为 `Project::analyze()` 结尾会 `self.store = Some(new_store)`,热替换无需重读磁盘。 +- 并发:`spawn_blocking` + `std::sync::Mutex` 串行化,避免两个 analyze 互相覆盖。 + +### 3. 未初始化不再退出 + +`Project::find` 失败时进入 uninitialized 状态,工具列表照常注册: + +- 查询工具返回 `{"status":"uninitialized","hint":"call codeweb_init"}`。 +- `codeweb_analyze` / `codeweb_diff` 同样引导到 `codeweb_init`,而不是让进程死掉。 + +### 4. stdout 通道隔离 + +MCP 的 stdout 是 JSON-RPC 通道。analyze 进度条(indicatif)与报告输出必须只走 stderr, +且**不得**调用 `print_analyze_report`(它走 stderr,但耦合 CLI 格式化),只序列化 `AnalyzeReport` 结构体。 + +## TDD 循环 + +| # | 行为 | 测试层级 | +|---|---|---| +| 1 | `Project::init_at(root, dirs, name)` 在指定目录建配置,相对路径相对 root | 单元(`src/project/mod.rs` tests) | +| 2 | `confine_to_root` 拒绝 `..` 逃逸、接受目录内路径 | 单元(`src/mcp/tools.rs` tests) | +| 3 | 未初始化目录启动 MCP 不退进程,stats 返回 uninitialized | 集成(`tests/mcp_test.rs`) | +| 4 | `codeweb_init` 创建 `codeweb.toml`,且不自动 analyze | 集成 | +| 5 | `codeweb_analyze` 构建图谱并热替换,随后 stats 返回 ready | 集成 | +| 6 | `codeweb_diff` 返回变更文件分类 | 集成 | +| 7 | `store.path` 逃逸许可目录时 analyze 返回错误且不写盘 | 集成 | +| 8 | 已分析且无变更的项目调用 analyze 报 `is_up_to_date:true` 且给出真实 nodes/edges(而非 up-to-date 短路返回的 0) | 集成 | + +循环 7 的 Red 通过临时禁用 `confine_to_root` 验证:无守卫时 analyze 返回 `ready` 并在服务目录外写出文件。 +循环 8 的 Red 通过临时改用 `report.nodes/edges` 验证:此时报告为 `0 nodes`,测试失败。 + +测试权限:`test_mcp_tools_list` 的期望工具数由 8 变 11 是本次 feature 的必然结果, +更新时保持「精确集合」断言而非放宽为子集断言,并在提交信息中说明。 + +## 影响文件 + +- `src/project/mod.rs` — 新增 `init_at`,`init` 委托 +- `src/mcp/tools.rs` — 状态重构、3 个新工具、写守卫 +- `src/mcp/server.rs` — 容错启动 +- `tests/mcp_test.rs` — 新增集成测试 +- `README.md` / `docs/DeveloperGuide.md` — 工具表与说明 + +## 明确不做 + +- `export` / `merge` 等其它写操作。 +- `init` 后自动 analyze。 +- 外部 analyze 后的自动重载(仍需调用 `codeweb_analyze` 或重启)。 +- store 原子写(临时文件 + rename)。 + +## 门禁 + +```sh +cargo build --features full +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +cargo clippy --features full -- -D warnings +cargo fmt --all -- --check +``` + +## 交付状态 + +- 分支:`feat/issue-171-mcp-lifecycle-tools` +- PR:https://github.com/c2j/codeweb/pull/172 +- 门禁结果:`cargo build --features full` 通过;`cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_` 全绿(`mcp_test` 13 passed);`cargo clippy --features full -- -D warnings` 干净;`cargo fmt --all -- --check` 干净;GitHub CI(Lint / Test ubuntu full)通过。 +- 已知遗留:默认(非 mcp)构建下 `node_sub_type_tag`、`TreeNode::has_more/more_count` 报 dead_code,为既有 mcp-gated 代码,与本次改动无关。 +- `tests/mcp_test.rs::test_mcp_tools_list` 期望工具集 8 → 11 为 feature 必然结果,保持精确集合断言。 + +## 验收证据(真实公共接口) + +用真实二进制 + 真实 stdio JSON-RPC(非测试桩)跑了一轮端到端会话,共 40 项断言全通过: + +- 场景:服务目录 `proj`(初始无 `codeweb.toml`),分析路径指向**服务目录之外**的 `src`(含 SQL 存储过程调用链 + iBatis mapper + Java DAO)。 +- `initialize` / `tools/list`(11 个工具,新工具带可用描述)→ 未初始化时 `stats` 返回 `uninitialized`,`analyze`/`diff` 引导到 `codeweb_init`。 +- `codeweb_init`(paths 为外部目录)→ `stats` 变 `empty`(证明未自动分析)→ `codeweb_analyze` 全量构建 10 nodes / 7 edges → 同一进程内 `stats`/`trace`/`nodes`/`search_sql` 立即看到新图(热替换,无重启)。 +- 写边界:外部 `src` 目录内未出现 `.codeweb/` 或 `codeweb.toml`,store 落在服务目录 `.codeweb/store.bincode`。 +- 变更检测:在外部目录新增文件 → `codeweb_diff` 报 `changed` 且列出该文件 → `codeweb_analyze` 增量构建(`is_full_build:false`,`files_added:1`,nodes 10→11)→ `diff` 回到 `up_to_date` → 再次 analyze 报 `is_up_to_date:true` 且仍给出真实 nodes/edges。 + +集成边界回归: + +- `cargo build --features mcp`(不启用 serve/tui/jsp)单独构建的二进制同样通过上述全部断言。 +- CLI 路径未受影响:`codeweb init` / `analyze` / `diff` / `stats` / `files` 在外部分析目录下行为一致,外部目录无写入。 +- HTTP 路径未受影响:`codeweb serve` 的 `/api/v1/stats`、`/api/v1/nodes`、`/api/v1/graph` 正常返回(5 nodes / 4 edges)。 diff --git a/src/mcp/server.rs b/src/mcp/server.rs index d63a75a..79b4a47 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1,46 +1,90 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; -use crate::error::Result; +use crate::error::{CodeWebError, Result}; +use crate::graph::store::GraphStore; use crate::project::Project; use rmcp::transport::io; use rmcp::ServiceExt; use super::tools::McpState; +/// Resolve the directory the server may write into (issue #171). +/// +/// Absolutizes against the cwd and normalizes `..` lexically. Deliberately +/// does not `canonicalize`: the permitted root must stay lexically identical +/// to `Project::root()` so the write guard can compare the two. +fn resolve_workspace(path: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().unwrap_or_default().join(path) + }; + super::tools::normalize_lexically(&absolute) +} + pub fn run(project_path: &Path) -> Result<()> { - let mut proj = Project::find(project_path)?; + let mut project = match Project::find(project_path) { + Ok(project) => Some(project), + Err(CodeWebError::ProjectNotFound { .. }) => None, + Err(other) => return Err(other), + }; - // Intentionally swallow store load errors — MCP server must stay alive for JSON-RPC. - let empty_reason = match proj.try_load_store() { - Some(_) => None, - None => { - let store_path = proj.store_path(); - let reason = if store_path.exists() { - format!( - "Code graph store at {} exists but could not be loaded (corrupted or incompatible format).", - store_path.display() - ) - } else { - format!( - "Code graph has not been built yet (no store at {}).", - store_path.display() - ) + let (workspace, store, project_name, empty_reason) = match project.as_mut() { + Some(proj) => { + // Intentionally swallow store load errors — MCP server must stay alive for JSON-RPC. + let empty_reason = match proj.try_load_store() { + Some(_) => None, + None => { + let store_path = proj.store_path(); + let reason = if store_path.exists() { + format!( + "Code graph store at {} exists but could not be loaded (corrupted or incompatible format).", + store_path.display() + ) + } else { + format!( + "Code graph has not been built yet (no store at {}).", + store_path.display() + ) + }; + eprintln!("codeweb mcp: {}", reason); + eprintln!( + " → Call the `codeweb_analyze` MCP tool (or run `codeweb analyze` in {}) to build the code graph.", + proj.root().display() + ); + Some(reason) + } }; + + let mut store = proj + .take_store() + .unwrap_or_else(|| GraphStore::new(proj.name())); + store.ensure_consistency_with_progress(); + + let workspace = resolve_workspace(proj.root()); + let name = proj.name().to_string(); + (workspace, store, name, empty_reason) + } + None => { + let workspace = resolve_workspace(project_path); + let name = workspace + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("project") + .to_string(); + let reason = format!( + "No codeweb.toml found from {} upward — the project is not initialized.", + workspace.display() + ); eprintln!("codeweb mcp: {}", reason); eprintln!( - " → Run `codeweb analyze` in {} to build the code graph.", - proj.root().display() + " → Call the `codeweb_init` MCP tool to create one (this server will not exit)." ); - Some(reason) + (workspace, GraphStore::new(&name), name, Some(reason)) } }; - let mut store = proj - .take_store() - .unwrap_or_else(|| crate::graph::store::GraphStore::new(proj.name())); - store.ensure_consistency_with_progress(); - - let state = McpState::new(store, proj.name().to_string(), empty_reason); + let state = McpState::new(workspace, project, store, project_name, empty_reason); let runtime = tokio::runtime::Runtime::new().map_err(|e| crate::error::CodeWebError::ExportError { diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 54c3294..8064be2 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -1,4 +1,5 @@ -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; use petgraph::graph::NodeIndex; use petgraph::Direction; @@ -15,52 +16,228 @@ use crate::graph::query::spec::QuerySpec; use crate::graph::store::GraphStore; use crate::graph::traverse; use crate::graph::{CodeGraph, Node}; +use crate::project::Project; // ── Shared state ── -pub struct McpState { +/// Graph snapshot swapped atomically by lifecycle tools (`codeweb_analyze`). +struct GraphSnapshot { store: Arc, project_name: String, empty_reason: Option, + /// No `codeweb.toml` has been found or created yet. + uninitialized: bool, +} + +pub struct McpState { + inner: Arc, +} + +struct Inner { + /// Absolute directory tree this server is allowed to write into. + permitted_root: PathBuf, + /// Loaded project — absent until `codeweb_init`, or found at startup. + project: Mutex>, + /// Query-facing view of the graph, replaced by lifecycle tools. + graph: RwLock, } impl McpState { - pub fn new(store: GraphStore, project_name: String, empty_reason: Option) -> Self { + pub fn new( + permitted_root: PathBuf, + project: Option, + store: GraphStore, + project_name: String, + empty_reason: Option, + ) -> Self { + let uninitialized = project.is_none(); Self { - store: Arc::new(store), - project_name, - empty_reason, + inner: Arc::new(Inner { + permitted_root, + project: Mutex::new(project), + graph: RwLock::new(GraphSnapshot { + store: Arc::new(store), + project_name, + empty_reason, + uninitialized, + }), + }), } } - fn store(&self) -> &GraphStore { - &self.store + fn graph_snapshot(&self) -> std::sync::RwLockReadGuard<'_, GraphSnapshot> { + self.inner.graph.read().unwrap_or_else(|e| e.into_inner()) + } + + fn graph_snapshot_mut(&self) -> std::sync::RwLockWriteGuard<'_, GraphSnapshot> { + self.inner.graph.write().unwrap_or_else(|e| e.into_inner()) + } + + fn project_slot(&self) -> std::sync::MutexGuard<'_, Option> { + self.inner.project.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Clone the current store handle and drop the lock immediately, so long + /// queries never block a concurrent `codeweb_analyze`. + fn store_arc(&self) -> Arc { + self.graph_snapshot().store.clone() } - fn graph(&self) -> &CodeGraph { - self.store.graph() + fn project_name(&self) -> String { + self.graph_snapshot().project_name.clone() } fn graph_empty(&self) -> bool { - self.store.graph().node_count() == 0 + self.store_arc().graph().node_count() == 0 } fn empty_graph_response(&self) -> String { - let message = self.empty_reason.clone().unwrap_or_else(|| { + let snapshot = self.graph_snapshot(); + let message = snapshot.empty_reason.clone().unwrap_or_else(|| { "Code graph has 0 nodes — the project contains no analyzable source files.".to_string() }); + let (status, hint) = if snapshot.uninitialized { + ( + "uninitialized", + "No codeweb project here yet. Call the codeweb_init tool, then codeweb_analyze.", + ) + } else { + ( + "empty", + "Call the codeweb_analyze tool to build the code graph.", + ) + }; serde_json::to_string(&serde_json::json!({ - "status": "empty", - "project": &self.project_name, + "status": status, + "project": &snapshot.project_name, "message": message, - "hint": "Run `codeweb analyze` in the project directory, then restart the MCP server.", + "hint": hint, })) .unwrap_or_default() } } -// ── Parameter structs ── +impl Inner { + /// Build (or incrementally refresh) the graph and swap it into the snapshot. + /// + /// Blocking: callers must run this on `spawn_blocking`, never directly on a + /// runtime worker. + fn run_analyze(&self) -> String { + let mut slot = self.project.lock().unwrap_or_else(|e| e.into_inner()); + let Some(project) = slot.as_mut() else { + return serde_json::to_string(&serde_json::json!({ + "status": "uninitialized", + "message": "No codeweb project here yet.", + "hint": "Call the codeweb_init tool first.", + })) + .unwrap_or_default(); + }; + + // Writes must stay inside the permitted root, so a tampered `store.path` + // cannot redirect the store outside the directory the server was given. + if let Err(message) = confine_to_root(&self.permitted_root, &project.store_path()) { + return serde_json::to_string(&serde_json::json!({ + "status": "error", + "error": message, + })) + .unwrap_or_default(); + } + + let report = match project.analyze() { + Ok(report) => report, + Err(e) => { + return serde_json::to_string(&serde_json::json!({ + "status": "error", + "error": e.to_string(), + })) + .unwrap_or_default(); + } + }; + + // `analyze` short-circuits when everything is up to date and leaves the + // store unloaded; read it back from disk so the snapshot stays accurate. + if project.store().is_none() { + let _ = project.load_store(); + } + let mut snapshot = self.graph.write().unwrap_or_else(|e| e.into_inner()); + if let Some(mut store) = project.take_store() { + store.ensure_consistency_with_progress(); + snapshot.store = Arc::new(store); + } + snapshot.uninitialized = false; + snapshot.empty_reason = None; + + // Report the graph actually in memory, not the up-to-date short-circuit's zeros. + let nodes = snapshot.store.graph().node_count(); + let edges = snapshot.store.graph().edge_count(); + + serde_json::to_string(&serde_json::json!({ + "status": "ready", + "project": project.name(), + "files_scanned": report.files_scanned, + "files_unchanged": report.files_unchanged, + "files_changed": report.files_changed, + "files_added": report.files_added, + "files_deleted": report.files_deleted, + "nodes": nodes, + "edges": edges, + "is_full_build": report.is_full_build, + "is_up_to_date": report.is_up_to_date, + "elapsed_ms": report.elapsed_ms, + })) + .unwrap_or_default() + } + + /// Compare the scanned source tree against the last analysis manifest. + /// + /// Blocking (scans the filesystem): callers must run this on `spawn_blocking`. + fn run_diff(&self) -> String { + let mut slot = self.project.lock().unwrap_or_else(|e| e.into_inner()); + let Some(project) = slot.as_mut() else { + return serde_json::to_string(&serde_json::json!({ + "status": "uninitialized", + "message": "No codeweb project here yet.", + "hint": "Call the codeweb_init tool first.", + })) + .unwrap_or_default(); + }; + + let root = project.root().to_path_buf(); + let relative = |p: &PathBuf| { + pathdiff::diff_paths(p, &root) + .unwrap_or_else(|| p.clone()) + .display() + .to_string() + }; + + match project.diff() { + Ok(changes) => { + let up_to_date = changes.is_empty(); + serde_json::to_string(&serde_json::json!({ + "status": if up_to_date { "up_to_date" } else { "changed" }, + "modified": changes.modified.iter().map(&relative).collect::>(), + "added": changes.added.iter().map(&relative).collect::>(), + "deleted": changes.deleted.iter().map(&relative).collect::>(), + "unchanged": changes.unchanged.len(), + "hint": if up_to_date { + "The graph is in sync with the sources." + } else { + "Call codeweb_analyze to fold these changes into the graph." + }, + })) + .unwrap_or_default() + } + Err(e) => serde_json::to_string(&serde_json::json!({ + "status": "error", + "error": e.to_string(), + })) + .unwrap_or_default(), + } + } +} + +// ── Parameter structs ── #[derive(Deserialize, schemars::JsonSchema)] pub struct NodesParams { #[serde(default)] @@ -118,6 +295,17 @@ pub struct LineageParams { pub depth: Option, } +#[derive(Deserialize, schemars::JsonSchema)] +pub struct InitParams { + /// Project name. Defaults to the served directory's name. + #[serde(default)] + pub name: Option, + /// Source directories to analyze (relative to the project root, or absolute). + /// Defaults to `["."]`. Reads are unrestricted; only writes are confined. + #[serde(default)] + pub paths: Vec, +} + // ── Helper functions ── fn tree_nodes_to_json(nodes: &[traverse::TreeNode], graph: &CodeGraph) -> Vec { @@ -142,6 +330,97 @@ fn tree_nodes_to_json(nodes: &[traverse::TreeNode], graph: &CodeGraph) -> Vec) -> String { + let mut slot = self.project_slot(); + if let Some(existing) = slot.as_ref() { + return serde_json::to_string(&serde_json::json!({ + "status": "already_initialized", + "project": existing.name(), + "root": existing.root(), + "hint": "Call codeweb_analyze to (re)build the graph, or codeweb_diff to inspect changes.", + })) + .unwrap_or_default(); + } + + let root = self.inner.permitted_root.clone(); + let name = params + .name + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| { + root.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("project") + .to_string() + }); + let dirs: Vec = if params.paths.is_empty() { + vec![PathBuf::from(".")] + } else { + params.paths.iter().map(PathBuf::from).collect() + }; + + match Project::init_at(&root, &dirs, &name) { + Ok(project) => { + let project_name = project.name().to_string(); + *slot = Some(project); + + let mut snapshot = self.graph_snapshot_mut(); + snapshot.uninitialized = false; + snapshot.project_name = project_name.clone(); + snapshot.empty_reason = None; + + serde_json::to_string(&serde_json::json!({ + "status": "initialized", + "project": project_name, + "root": root, + "paths": dirs.iter().map(|d| d.display().to_string()).collect::>(), + "hint": "Project configured but the graph is still empty. Call codeweb_analyze to build it.", + })) + .unwrap_or_default() + } + Err(e) => serde_json::to_string(&serde_json::json!({ + "error": e.to_string(), + })) + .unwrap_or_default(), + } + } + + /// Build or refresh the code graph + #[tool( + description = "Build (or incrementally refresh) the code graph for the initialized project and hot-swap it into this server, so later queries see the new graph without a restart. Call codeweb_init first when codeweb_stats reports status='uninitialized'. May take a while on large projects." + )] + async fn codeweb_analyze(&self) -> String { + let inner = self.inner.clone(); + match tokio::task::spawn_blocking(move || inner.run_analyze()).await { + Ok(response) => response, + Err(e) => serde_json::to_string(&serde_json::json!({ + "status": "error", + "error": format!("analysis task failed: {}", e), + })) + .unwrap_or_default(), + } + } + + /// Show source changes since the last analysis + #[tool( + description = "List source files changed since the last codeweb_analyze: added / modified / deleted, relative to the project root. Use it to decide whether the graph is stale before trusting query results." + )] + async fn codeweb_diff(&self) -> String { + let inner = self.inner.clone(); + match tokio::task::spawn_blocking(move || inner.run_diff()).await { + Ok(response) => response, + Err(e) => serde_json::to_string(&serde_json::json!({ + "status": "error", + "error": format!("diff task failed: {}", e), + })) + .unwrap_or_default(), + } + } + /// Get project statistics #[tool( description = "Get project statistics including node counts by type and edge count. Always call this first to check graph status." @@ -150,10 +429,12 @@ impl McpState { if self.graph_empty() { return self.empty_graph_response(); } - let stats = self.store().stats(); + let store = self.store_arc(); + let stats = store.stats(); + let project_name = self.project_name(); let result = serde_json::json!({ "status": "ready", - "project": &self.project_name, + "project": project_name, "procedures": stats.procedures, "functions": stats.functions, "unresolved": stats.unresolved, @@ -187,7 +468,8 @@ impl McpState { if self.graph_empty() { return self.empty_graph_response(); } - let summaries = self.store().node_summaries(); + let store = self.store_arc(); + let summaries = store.node_summaries(); let search_lower = params.search.map(|s| s.to_lowercase()); let type_filter = params.node_type.map(|t| t.to_lowercase()); @@ -256,7 +538,8 @@ impl McpState { if self.graph_empty() { return self.empty_graph_response(); } - let graph = self.graph(); + let store = self.store_arc(); + let graph = store.graph(); let depth = params.depth.unwrap_or(1); let mut results: Vec = Vec::new(); @@ -438,8 +721,8 @@ impl McpState { if self.graph_empty() { return self.empty_graph_response(); } - let store = self.store(); - let graph = self.graph(); + let store = self.store_arc(); + let graph = store.graph(); let matches = store.search_nodes(¶ms.from); if matches.is_empty() { @@ -476,8 +759,9 @@ impl McpState { if self.graph_empty() { return self.empty_graph_response(); } - let graph = self.graph(); - let results = self.store().search_by_sql(¶ms.sql); + let store = self.store_arc(); + let graph = store.graph(); + let results = store.search_by_sql(¶ms.sql); let nodes: Vec = results .into_iter() .map(|(idx, display_key, score)| { @@ -540,7 +824,8 @@ impl McpState { } }; - match spec.execute(self.store.as_ref()) { + let store = self.store_arc(); + match spec.execute(store.as_ref()) { Ok(result) => serde_json::to_string(&result).unwrap_or_default(), Err(e) => { let err = serde_json::json!({"error": e}); @@ -567,12 +852,12 @@ impl McpState { return serde_json::to_string(&err).unwrap_or_default(); } - let store = self.store(); - let graph = self.graph(); + let store = self.store_arc(); + let graph = store.graph(); let table_filter = params.table.as_deref(); let result = if let Some(name) = ¶ms.procedure { - let idx = match resolve_node(store, name, true) { + let idx = match resolve_node(store.as_ref(), name, true) { Ok(idx) => idx, Err(msg) => return msg, }; @@ -585,7 +870,7 @@ impl McpState { crate::graph::columns::column_analysis_of_routine(graph, idx, table_filter) } else { let name = params.package.as_ref().expect("checked exactly-one above"); - let idx = match resolve_node(store, name, true) { + let idx = match resolve_node(store.as_ref(), name, true) { Ok(idx) => idx, Err(msg) => return msg, }; @@ -613,8 +898,8 @@ impl McpState { if self.graph_empty() { return self.empty_graph_response(); } - let graph = self.graph(); - let store = self.store(); + let store = self.store_arc(); + let graph = store.graph(); let depth = params.depth.unwrap_or(5); let direction = params.direction.as_deref().unwrap_or("both"); @@ -658,7 +943,7 @@ impl McpState { serde_json::to_string(&json).unwrap_or_default() } crate::graph::lineage::ParsedLineageTarget::Table(table_name) => { - let table_idx = match resolve_node(store, &table_name, false) { + let table_idx = match resolve_node(store.as_ref(), &table_name, false) { Ok(idx) => idx, Err(msg) => return msg, }; @@ -748,7 +1033,9 @@ use rmcp::ServerHandler; #[tool_handler( name = "codeweb", instructions = "Code graph analysis tools. ALWAYS call codeweb_stats first. \ - If it returns status='empty', the graph has not been built — tell the user to run `codeweb analyze` in the project directory then restart this MCP server, and stop. \ + If it returns status='uninitialized', call codeweb_init (it only writes config, never analyzes), then codeweb_analyze. \ + If it returns status='empty', call codeweb_analyze to build the graph. \ + After changing source files, call codeweb_diff to check staleness and codeweb_analyze to refresh the in-memory graph (no restart needed). \ If status='ready': use codeweb_nodes to find nodes (search + type filter), \ codeweb_trace to follow call chains bidirectionally, \ codeweb_search_sql to find SQL by text content, \ @@ -758,3 +1045,96 @@ use rmcp::ServerHandler; codeweb_lineage for table/column-level lineage tracing." )] impl ServerHandler for McpState {} + +// ── Write confinement ── +// +// MCP may *read* user-specified source paths, but every write must stay inside +// the permitted root (the project directory the server was started for). + +/// Lexically normalize a path: drop `.`, resolve `..` by popping, keep the root. +pub(super) fn normalize_lexically(path: &Path) -> std::path::PathBuf { + use std::path::Component; + let mut out = std::path::PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + // `pop` on the filesystem root is a no-op, so `/..` stays `/`. + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +/// Resolve `candidate` against `root` and accept it only if it stays inside. +/// +/// `root` is expected to be absolute (the MCP server canonicalizes it at +/// startup). Resolution is lexical, so a symlink *inside* the root pointing +/// outside is not caught here. +fn confine_to_root(root: &Path, candidate: &Path) -> Result { + let root_norm = normalize_lexically(root); + let joined = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + root_norm.join(candidate) + }; + let normalized = normalize_lexically(&joined); + + if normalized.starts_with(&root_norm) { + Ok(normalized) + } else { + Err(format!( + "path '{}' escapes the permitted project root '{}'", + candidate.display(), + root.display() + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + #[test] + fn confine_accepts_path_inside_root() { + let root = Path::new("/srv/proj"); + let confined = confine_to_root(root, Path::new(".codeweb/store.bincode")).unwrap(); + assert_eq!(confined, PathBuf::from("/srv/proj/.codeweb/store.bincode")); + + // Redundant `.` components and absolute candidates inside the root are fine. + let abs = confine_to_root(root, Path::new("/srv/proj/.codeweb/./store.bincode")).unwrap(); + assert_eq!(abs, PathBuf::from("/srv/proj/.codeweb/store.bincode")); + } + + #[test] + fn confine_accepts_root_itself() { + let root = Path::new("/srv/proj"); + assert_eq!(confine_to_root(root, Path::new(".")).unwrap(), root); + } + + #[test] + fn confine_rejects_parent_escape() { + let root = Path::new("/srv/proj"); + for candidate in [ + "../escape.bincode", + ".codeweb/../../escape.bincode", + "/srv/other/store.bincode", + "/etc/passwd", + ] { + assert!( + confine_to_root(root, Path::new(candidate)).is_err(), + "'{candidate}' must be rejected as escaping {root:?}" + ); + } + } + + #[test] + fn confine_rejects_sibling_with_shared_prefix() { + // `/srv/proj-evil` must not pass the `/srv/proj` prefix check. + let root = Path::new("/srv/proj"); + assert!(confine_to_root(root, Path::new("/srv/proj-evil/store")).is_err()); + } +} diff --git a/src/project/mod.rs b/src/project/mod.rs index 8fb840a..036dc18 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -57,8 +57,21 @@ impl Project { pub fn init(source_dirs: &[PathBuf], name: &str) -> Result { let cwd = std::env::current_dir().unwrap_or_default(); + Self::init_at(&cwd, source_dirs, name) + } + + /// Initialize a project rooted at `root` instead of the process cwd. + /// + /// Writes `codeweb.toml` and `.codeweb/` under `root` and stores relative + /// analysis paths relative to `root`. Callers that must not write outside a + /// permitted directory (e.g. the MCP server) pass that directory as `root`. + pub fn init_at(root: &Path, source_dirs: &[PathBuf], name: &str) -> Result { + std::fs::create_dir_all(root).map_err(|e| CodeWebError::FileRead { + path: root.to_path_buf(), + source: e, + })?; - let toml_path = cwd.join(CODEWEB_TOML); + let toml_path = root.join(CODEWEB_TOML); if toml_path.exists() { return Err(CodeWebError::ProjectAlreadyExists { path: toml_path }); } @@ -72,7 +85,7 @@ impl Project { if d.is_absolute() { d.to_string_lossy().to_string() } else { - let relative = pathdiff::diff_paths(d, &cwd).unwrap_or_else(|| d.clone()); + let relative = pathdiff::diff_paths(d, root).unwrap_or_else(|| d.clone()); relative.to_string_lossy().to_string() } }) @@ -85,7 +98,7 @@ impl Project { source: e, })?; - let codeweb_dir = cwd.join(".codeweb"); + let codeweb_dir = root.join(".codeweb"); std::fs::create_dir_all(&codeweb_dir).map_err(|e| CodeWebError::FileRead { path: codeweb_dir, source: e, @@ -648,6 +661,56 @@ mod tests { ); } + #[test] + fn init_at_creates_config_under_given_root_not_cwd() { + let tmpdir = tempfile::tempdir().unwrap(); + let root = tmpdir.path().join("workspace"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src").join("a.sql"), "SELECT 1;").unwrap(); + + let proj = Project::init_at(&root, &[PathBuf::from("src")], "demo").unwrap(); + + assert_eq!( + proj.root(), + root, + "init_at must anchor the project at the given root" + ); + assert_eq!(proj.name(), "demo"); + assert!( + root.join(CODEWEB_TOML).exists(), + "codeweb.toml must be written under the given root" + ); + assert!( + root.join(".codeweb").is_dir(), + ".codeweb/ must be created under the given root" + ); + assert_eq!( + proj.config().analysis.paths, + vec!["src".to_string()], + "relative analysis paths must be stored relative to the given root" + ); + assert!( + !tmpdir.path().join(CODEWEB_TOML).exists(), + "init_at must not write to the process cwd" + ); + } + + #[test] + fn init_at_rejects_existing_project_root() { + let tmpdir = tempfile::tempdir().unwrap(); + let root = tmpdir.path().join("workspace"); + fs::create_dir_all(&root).unwrap(); + + Project::init_at(&root, &[PathBuf::from(".")], "first").unwrap(); + let second = Project::init_at(&root, &[PathBuf::from(".")], "second"); + + assert!( + matches!(second, Err(CodeWebError::ProjectAlreadyExists { .. })), + "a second init_at on the same root must be rejected, got {:?}", + second.err().map(|e| e.to_string()) + ); + } + #[test] fn scan_with_fingerprints_deduplicates_overlapping_paths() { let tmpdir = tempfile::tempdir().unwrap(); diff --git a/tests/mcp_test.rs b/tests/mcp_test.rs index 3a28cd5..9e831f6 100644 --- a/tests/mcp_test.rs +++ b/tests/mcp_test.rs @@ -220,6 +220,9 @@ mod tests { .collect(); let expected = [ + "codeweb_init", + "codeweb_analyze", + "codeweb_diff", "codeweb_stats", "codeweb_nodes", "codeweb_node_detail", @@ -245,6 +248,310 @@ mod tests { ); } + #[test] + fn test_mcp_uninitialized_project_stays_alive() { + // A directory with no codeweb.toml anywhere above it: the server used to + // exit before answering `initialize`. It must now stay up and report the + // project as uninitialized instead of dying. + let tmpdir = TempDir::new().expect("failed to create temp dir"); + let project = tmpdir.path().to_path_buf(); + let mut mcp = McpChild::start(&project); + handshake(&mut mcp); + + mcp.send( + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"codeweb_stats","arguments":{}}}"#, + ); + let resp = mcp.recv_response(2); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("stats text"); + let stats: serde_json::Value = serde_json::from_str(text).expect("stats JSON"); + + assert_eq!( + stats["status"], "uninitialized", + "an uninitialized project must report status=uninitialized, got: {stats}" + ); + assert!( + stats["hint"] + .as_str() + .is_some_and(|h| h.contains("codeweb_init")), + "hint should point at codeweb_init, got: {stats}" + ); + } + + #[test] + fn test_mcp_init_creates_project_without_auto_analyze() { + let tmpdir = TempDir::new().expect("failed to create temp dir"); + let project = tmpdir.path().to_path_buf(); + std::fs::create_dir_all(project.join("sql")).expect("create sql dir"); + std::fs::write(project.join("sql").join("a.sql"), "SELECT 1;").expect("write sql"); + + let mut mcp = McpChild::start(&project); + handshake(&mut mcp); + + mcp.send( + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"codeweb_init","arguments":{"name":"demo","paths":["sql"]}}}"#, + ); + let resp = mcp.recv_response(2); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("init text"); + let init: serde_json::Value = serde_json::from_str(text).expect("init JSON"); + + assert_eq!(init["status"], "initialized", "got: {init}"); + assert_eq!(init["project"], "demo", "got: {init}"); + assert!( + project.join("codeweb.toml").exists(), + "codeweb_init must write codeweb.toml under the served directory" + ); + + // `codeweb_init` must not analyze: the graph stays empty until the caller + // explicitly asks for `codeweb_analyze`. + mcp.send( + r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"codeweb_stats","arguments":{}}}"#, + ); + let resp = mcp.recv_response(3); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("stats text"); + let stats: serde_json::Value = serde_json::from_str(text).expect("stats JSON"); + + assert_eq!( + stats["status"], "empty", + "after init but before analyze the graph must be empty (not uninitialized), got: {stats}" + ); + } + + #[test] + fn test_mcp_init_is_idempotent_error() { + let (_tmpdir, project) = create_test_project(); + let mut mcp = McpChild::start(&project); + handshake(&mut mcp); + + mcp.send( + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"codeweb_init","arguments":{"name":"again"}}}"#, + ); + let resp = mcp.recv_response(2); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("init text"); + let init: serde_json::Value = serde_json::from_str(text).expect("init JSON"); + + assert_eq!( + init["status"], "already_initialized", + "re-initializing an existing project must be reported, got: {init}" + ); + } + + /// Shared with `create_analyzed_project`: a small SQL fixture with procedures/tables. + fn copy_serve_demo_fixture(project: &std::path::Path) { + let fixture_sql = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/serve_demo/sample.sql"); + let sql_dir = project.join("sql"); + std::fs::create_dir_all(&sql_dir).expect("create sql dir"); + std::fs::copy(&fixture_sql, sql_dir.join("sample.sql")).expect("copy fixture sql"); + } + + #[test] + fn test_mcp_analyze_builds_graph_and_hot_swaps() { + let tmpdir = TempDir::new().expect("failed to create temp dir"); + let project = tmpdir.path().to_path_buf(); + copy_serve_demo_fixture(&project); + + let mut mcp = McpChild::start(&project); + handshake(&mut mcp); + + // Before init, analyze must guide the caller to codeweb_init. + mcp.send( + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"codeweb_analyze","arguments":{}}}"#, + ); + let resp = mcp.recv_response(2); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("analyze text"); + let before: serde_json::Value = serde_json::from_str(text).expect("analyze JSON"); + assert_eq!( + before["status"], "uninitialized", + "analyze on an uninitialized project must guide to codeweb_init, got: {before}" + ); + + mcp.send( + r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"codeweb_init","arguments":{"name":"mcp-analyze","paths":["sql"]}}}"#, + ); + let _ = mcp.recv_response(3); + + mcp.send( + r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"codeweb_analyze","arguments":{}}}"#, + ); + let resp = mcp.recv_response(4); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("analyze text"); + let analyzed: serde_json::Value = serde_json::from_str(text).expect("analyze JSON"); + + assert_eq!(analyzed["status"], "ready", "got: {analyzed}"); + assert!( + analyzed["nodes"].as_u64().unwrap_or(0) > 0, + "analyze must build a non-empty graph, got: {analyzed}" + ); + assert!( + project.join(".codeweb").join("store.bincode").exists(), + "analyze must persist the store under the served directory" + ); + + // Hot swap: the very next query sees the new graph, no restart. + mcp.send( + r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"codeweb_stats","arguments":{}}}"#, + ); + let resp = mcp.recv_response(5); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("stats text"); + let stats: serde_json::Value = serde_json::from_str(text).expect("stats JSON"); + + assert_eq!(stats["status"], "ready", "got: {stats}"); + assert_eq!( + stats["edges"].as_u64(), + analyzed["edges"].as_u64(), + "stats must reflect the freshly built graph without a server restart" + ); + } + + #[test] + fn test_mcp_analyze_rejects_store_path_escaping_root() { + // `store.path` is user-controlled config: a tampered value must not be + // able to redirect the store write outside the served directory. + let tmpdir = TempDir::new().expect("failed to create temp dir"); + let project = tmpdir.path().join("proj"); + copy_serve_demo_fixture(&project); + + let escaped_store = tmpdir.path().join("outside.bincode"); + let toml = "[project]\n\ + name = \"escape\"\n\ + \n\ + [analysis]\n\ + paths = [\"sql\"]\n\ + \n\ + [store]\n\ + path = \"../outside.bincode\"\n\ + format = \"bincode\"\n"; + std::fs::write(project.join("codeweb.toml"), toml).expect("write codeweb.toml"); + + let mut mcp = McpChild::start(&project); + handshake(&mut mcp); + + mcp.send( + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"codeweb_analyze","arguments":{}}}"#, + ); + let resp = mcp.recv_response(2); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("analyze text"); + let result: serde_json::Value = serde_json::from_str(text).expect("analyze JSON"); + + assert_eq!( + result["status"], "error", + "an escaping store.path must be refused, got: {result}" + ); + assert!( + !escaped_store.exists(), + "analysis must not write outside the served directory" + ); + } + + #[test] + fn test_mcp_analyze_refreshes_already_analyzed_project() { + // The CLI analyzed this project before the server started, so + // `Project::analyze` takes its up-to-date short-circuit and leaves the + // store unloaded. The tool must still report the real graph (not the + // short-circuit's zero counts) and keep it queryable. + let (_tmpdir, project) = create_analyzed_project(); + let mut mcp = McpChild::start(&project); + handshake(&mut mcp); + + mcp.send( + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"codeweb_analyze","arguments":{}}}"#, + ); + let resp = mcp.recv_response(2); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("analyze text"); + let analyzed: serde_json::Value = serde_json::from_str(text).expect("analyze JSON"); + + assert_eq!(analyzed["status"], "ready", "got: {analyzed}"); + assert_eq!( + analyzed["is_up_to_date"], true, + "an unchanged, already-analyzed project must report is_up_to_date, got: {analyzed}" + ); + assert!( + analyzed["nodes"].as_u64().unwrap_or(0) > 0 + && analyzed["edges"].as_u64().unwrap_or(0) > 0, + "the up-to-date path must still report the real graph, got: {analyzed}" + ); + + // The graph stays queryable after the refresh. + mcp.send( + r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"codeweb_stats","arguments":{}}}"#, + ); + let resp = mcp.recv_response(3); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("stats text"); + let stats: serde_json::Value = serde_json::from_str(text).expect("stats JSON"); + + assert_eq!(stats["status"], "ready", "got: {stats}"); + assert_eq!( + stats["edges"].as_u64(), + analyzed["edges"].as_u64(), + "stats must agree with the refresh report" + ); + } + + #[test] + fn test_mcp_diff_reports_changes_since_last_analyze() { + let (_tmpdir, project) = create_analyzed_project(); + let mut mcp = McpChild::start(&project); + handshake(&mut mcp); + + // Right after analysis there is nothing to report. + mcp.send( + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"codeweb_diff","arguments":{}}}"#, + ); + let resp = mcp.recv_response(2); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("diff text"); + let clean: serde_json::Value = serde_json::from_str(text).expect("diff JSON"); + assert_eq!(clean["status"], "up_to_date", "got: {clean}"); + assert_eq!( + clean["added"].as_array().map(Vec::len), + Some(0), + "got: {clean}" + ); + + // A new source file must show up as added. + std::fs::write(project.join("sql").join("brand_new.sql"), "SELECT 42;") + .expect("write new sql file"); + + mcp.send( + r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"codeweb_diff","arguments":{}}}"#, + ); + let resp = mcp.recv_response(3); + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("diff text"); + let changed: serde_json::Value = serde_json::from_str(text).expect("diff JSON"); + + assert_eq!(changed["status"], "changed", "got: {changed}"); + let added = changed["added"].as_array().expect("added array"); + assert!( + added + .iter() + .any(|p| p.as_str().is_some_and(|s| s.contains("brand_new.sql"))), + "added must contain brand_new.sql, got: {changed}" + ); + } + #[test] fn test_mcp_call_stats() { let (_tmpdir, project) = create_test_project();