From 5fcd919ac0e5d4107e86e4d1824cae33322ae7d5 Mon Sep 17 00:00:00 2001 From: rollroyces Date: Thu, 24 Sep 2026 23:04:29 +0800 Subject: [PATCH 1/2] fix(server): index mcp.md in the chat-searchable docs corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend corpus at crates/utopia-server/src/docs_corpus.rs only indexed ingest.md. The frontend Docs page (web/src/pages/Docs.tsx) listed both ingest.md and mcp.md, so users reading the docs saw MCP documentation that the chat's search_docs tool could never find. A user asking the chat about MCP tokens, agent access, or write permissions would get nothing back even though the answer is in the docs. Fix: add mcp to ARTICLES. Drift guard: new test every_corpus_md_file_is_indexed walks web/src/docs/ and asserts the .md files match the slugs in ARTICLES one-to-one. The invariant is "on-disk → indexed" (not the other way round) so a new file dropped in without being indexed fails the test. This is the same shape as the_backstop_can_be_raised — a number defined in two places, drift between them is the bug, the test locks the invariant. Signed-off-by: rollroyces --- crates/utopia-server/src/docs_corpus.rs | 55 ++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/utopia-server/src/docs_corpus.rs b/crates/utopia-server/src/docs_corpus.rs index f5fcfffca..38b834c57 100644 --- a/crates/utopia-server/src/docs_corpus.rs +++ b/crates/utopia-server/src/docs_corpus.rs @@ -4,11 +4,18 @@ use utopia_search::{DocsIndex, DocsSection}; /// (slug, 标题, 正文)。slug 必须与前端 DOCS 清单一致(引用链接 /docs/{slug} 才对得上)。 -const ARTICLES: &[(&str, &str, &str)] = &[( - "ingest", - "Ingest interfaces", - include_str!("../../../web/src/docs/ingest.md"), -)]; +const ARTICLES: &[(&str, &str, &str)] = &[ + ( + "ingest", + "Ingest interfaces", + include_str!("../../../web/src/docs/ingest.md"), + ), + ( + "mcp", + "Agents over MCP", + include_str!("../../../web/src/docs/mcp.md"), + ), +]; /// 启动时建索引;语料是编译期常量,失败即程序错误,响亮地死。 pub fn build_index() -> DocsIndex { @@ -100,4 +107,42 @@ mod tests { .iter() .any(|s| s.anchor.is_empty() && s.heading == "Ingest interfaces")); } + + /// 每一份语料文件都得进 `ARTICLES`,否则 chat 的 search_docs 工具找不到它—— + /// 这一行是把 `web/src/docs/*.md` 当事实来源核对一遍,防「前端能看、chat 搜不到」 + /// 的漂移(之前 `mcp.md` 就落过这一摔)。 + /// + /// **不要**写「ARTICLES 里有几个就检查几个」:那样加文件时反而不报错;这里的 + /// 不变式是「文件 → 索引」单向覆盖,文件多出来就算 bug。 + #[test] + fn every_corpus_md_file_is_indexed() { + let docs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/src/docs"); + let mut on_disk: Vec = std::fs::read_dir(&docs_dir) + .unwrap_or_else(|e| panic!("read {}: {e}", docs_dir.display())) + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with(".md") { + Some(name) + } else { + None + } + }) + .collect(); + on_disk.sort(); + + let indexed: Vec = ARTICLES + .iter() + .map(|(slug, _, _)| format!("{slug}.md")) + .collect(); + // indexed 也得排序好让两条 assert 一对一 + let mut indexed_sorted = indexed.clone(); + indexed_sorted.sort(); + + assert_eq!( + on_disk, indexed_sorted, + "web/src/docs/*.md 列表与 ARTICLES 不一致:\n on disk: {on_disk:?}\n indexed: {indexed:?}\n\ + 新增一份语料要在两边都登记;删一份同理。drift = chat 搜不到。" + ); + } } From e6d3a388280dc2623ff4cfab25a3f052505bf426 Mon Sep 17 00:00:00 2001 From: rollroyces Date: Thu, 24 Sep 2026 23:18:39 +0800 Subject: [PATCH 2/2] test(alerts): lock the role rank order across rank(), PartialOrd, and VISIBLE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment at alerts.rs:340 names the invariant: `rank()`, `Role`'s derived `PartialOrd`, and the CASE inside `VISIBLE` all agree on Viewer --- crates/utopia-store/src/alerts.rs | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/utopia-store/src/alerts.rs b/crates/utopia-store/src/alerts.rs index 533580076..034e7c322 100644 --- a/crates/utopia-store/src/alerts.rs +++ b/crates/utopia-store/src/alerts.rs @@ -346,3 +346,60 @@ fn rank(r: Role) -> i32 { Role::Owner => 3, } } + +#[cfg(test)] +mod tests { + use super::*; + use utopia_core::models::Role; + + /// 角色序的「三处一致」:本模块的 `rank()`、派生 `PartialOrd`(来自 `Role` 的 + /// 声明顺序)、以及 [`VISIBLE`] 里那条 CASE 写在同一行串里。 + /// + /// 这一组不变量用「不要让我去查 git blame」的方式锁住:任一处漂了,三条断言 + /// 里至少一条会爆。代价是测试本身写得啰嗦——但这是 `alerts.min_role` 比大小的 + /// 命脉,错一档意味着「viewer 看得到 editor 才能看的告警」或反过来。 + #[test] + fn role_rank_order_is_the_same_in_rust_and_sql() { + // 1. Rust `rank()` 与派生 PartialOrd 同序 + assert!(rank(Role::Viewer) < rank(Role::Editor)); + assert!(rank(Role::Editor) < rank(Role::Admin)); + assert!(rank(Role::Admin) < rank(Role::Owner)); + assert!(Role::Viewer < Role::Editor); + assert!(Role::Editor < Role::Admin); + assert!(Role::Admin < Role::Owner); + + // 2. SQL 里 CASE 的 WHEN 子句按从低到高排列、数值等于 `rank()` + // —— 用 `as_str()` 走一处事实来源,不在测试里再写一遍小写字面量 + // —— 切掉多余空白再比:实际 SQL 有「'admin' THEN」(对齐用两空格), + // 直接 contains 一行对不齐就白测了 + let visible_compact: String = VISIBLE.split_whitespace().collect::>().join(" "); + let expected_lines = [(Role::Viewer, 0), (Role::Editor, 1), (Role::Admin, 2)]; + for (role, want_rank) in expected_lines { + let needle = format!("WHEN '{}' THEN {}", role.as_str(), want_rank); + assert!( + visible_compact.contains(&needle), + "{needle:?} 不在 VISIBLE 里。Role 的序数改了或 CASE 没跟上时这条会爆——\n{VISIBLE}" + ); + } + // Owner 不显式出现:在 CASE 里走 `ELSE 3`,数值必须等于 rank(Owner) + // —— 不然 Owner 看得见 Admin 看不见的告警,或反过来 + assert!( + VISIBLE.contains("ELSE 3 END"), + "Owner 在 VISIBLE 里走 ELSE 兜底,期望 'ELSE 3 END'。drift 后这条会爆" + ); + assert_eq!( + rank(Role::Owner), + 3, + "rank(Owner) != 3:要么 rank() 改了,要么 ELSE 那条数没跟上" + ); + + // 3. `Role` 的声明顺序就是 `PartialOrd` 的序——任何手动调整过 `Role` 的 + // 人都会看到上面两条先爆;这条作为最后兜底 + assert!( + (Role::Viewer as usize) < (Role::Editor as usize) + && (Role::Editor as usize) < (Role::Admin as usize) + && (Role::Admin as usize) < (Role::Owner as usize), + "Role 的声明顺序与 PartialOrd 不再一致——上游 impl 改了?" + ); + } +}