Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src-tauri/crates/tw-scan/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ pub struct Finding {
pub detail: Msg,
/// 命中的那一行。不可见字符已经换成可见记号
pub excerpt: String,
/// 这条发现说的是什么,**和它在文件里的位置无关**:远端 MCP 是服务器名和地址,
/// 规则命中是命中的那一段,藏起来的字符是那一行,skill 是它的名字。
///
/// 「是不是新出现的」按它认([`crate::watch::Seen`]),不按行号:文件上面多一行、
/// 少一行,下面的每一条都会挪位置,按行号认就会把早就看过的整批再报一遍
pub subject: String,
}

/// 一个 MCP server 在某个客户端里的样子。
Expand Down Expand Up @@ -703,6 +709,7 @@ fn hidden_finding(src: &Source, h: hidden::Kind, line: usize, excerpt: String) -
why,
src.kind.why()
),
subject: excerpt.clone(),
excerpt,
}
}
Expand All @@ -729,6 +736,8 @@ fn not_all_checked(src: &Source, line: usize, listed: usize) -> Finding {
=> "It holds too many to list one by one. The first {count} are listed; from line {line} on it was not checked for them."
),
excerpt: String::new(),
// 一份文件只会有一条
subject: String::new(),
}
}

Expand Down Expand Up @@ -772,6 +781,7 @@ pub fn scan(sources: &[Source], rules: &Rules) -> Report {
=> "That server is at {url}, and using it sends the surrounding context there."
),
excerpt,
subject: format!("{}|{}", m.name, m.url.as_deref().unwrap_or("")),
});
}
}
Expand Down Expand Up @@ -807,6 +817,7 @@ pub fn scan(sources: &[Source], rules: &Rules) -> Report {
title: msg!("scan.skill.all_tools", name = name.clone() => "skill `{name}` declares allowed-tools: [\"*\"]"),
detail: msg!("scan.skill.all_tools.detail" => "That skill may use any tool. It may well need to; it is worth confirming that it does."),
excerpt,
subject: name.clone(),
});
}
r.skills.push(SkillEntry {
Expand Down Expand Up @@ -855,6 +866,7 @@ pub fn scan(sources: &[Source], rules: &Rules) -> Report {
continue;
};
let (line, excerpt) = line_of(&text, m.as_str());
let subject = format!("{}|{}", of.slug(), m.as_str());
// `msg!` 会把 `rule`、`kind` 遮住,所以句子要用到的几段先取出来
let why = rule.why.clone();
let name = rule.name.clone();
Expand Down Expand Up @@ -889,6 +901,7 @@ pub fn scan(sources: &[Source], rules: &Rules) -> Report {
kind_why
),
excerpt,
subject,
});
}
}
Expand Down
66 changes: 52 additions & 14 deletions src-tauri/crates/tw-scan/src/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
//!
//! **盯目录不盯文件、不递归、去抖**,和配置文件的监听是同一份([`tw_watch`])。

use std::collections::{BTreeSet, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;

Expand Down Expand Up @@ -260,26 +260,38 @@ fn is_ours(p: &Path) -> bool {
/// 全文留在内存里没有任何必要。
#[derive(Debug, Default)]
pub struct Seen {
/// 每条发现的指纹
known: HashSet<String>,
/// 每条发现的指纹(只存哈希,不留文件里的任何内容),和这个指纹此刻有几条
known: HashMap<u64, usize>,
/// 扫过了没有。**第一次扫的结果不算「新出现」** —— 否则用户第一次
/// 打开就会被一屏「新发现」砸中,而那些东西可能在他机器上放了半年
primed: bool,
}

fn key(f: &crate::report::Finding) -> String {
format!("{}|{}|{}|{}", f.path.display(), f.rule, f.line, f.excerpt)
/// **不带行号**:认的是哪份文件、哪条规则、说的是什么([`crate::report::Finding::subject`])。
/// 改一下 `~/.codex/config.toml`,下面的 MCP 服务器整体挪了几行,它们还是那几条
fn key(f: &crate::report::Finding) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(&f.path, &f.rule, &f.subject).hash(&mut h);
h.finish()
}

impl Seen {
/// 吃掉一次扫描结果,返回**这次新出现的那些**。
///
/// 同一个指纹按条数算:原来有一条、现在有两条,多出来的那一条是新的
pub fn diff(&mut self, findings: &[crate::report::Finding]) -> Vec<crate::report::Finding> {
let fresh: Vec<_> = findings
.iter()
.filter(|f| !self.known.contains(&key(f)))
.cloned()
.collect();
self.known = findings.iter().map(key).collect();
let mut now: HashMap<u64, usize> = HashMap::new();
let mut fresh = Vec::new();
for f in findings {
let k = key(f);
let n = now.entry(k).or_default();
*n += 1;
if *n > self.known.get(&k).copied().unwrap_or(0) {
fresh.push(f.clone());
}
}
self.known = now;
if !self.primed {
self.primed = true;
return Vec::new();
Expand Down Expand Up @@ -308,6 +320,7 @@ mod tests {
title: tw_types::msg!("t.title" => "t"),
detail: tw_types::msg!("t.detail" => "d"),
excerpt: "e".into(),
subject: "s".into(),
}
}

Expand Down Expand Up @@ -344,11 +357,34 @@ mod tests {
}

#[test]
fn the_same_finding_moving_to_another_line_counts_as_new() {
// 行号变了通常意味着文件被编辑过 —— 那正是我们想知道的时刻。
fn the_same_finding_moving_to_another_line_is_not_new() {
// 改一下 `~/.codex/config.toml`,上面多一行,下面的 MCP 服务器整体往下挪:
// 它们还是早就报过的那几条,不该再报一遍。
let mut seen = Seen::default();
seen.diff(&[f("a", "remote-mcp", 1), f("a", "tag", 3)]);
assert!(
seen.diff(&[f("a", "remote-mcp", 12), f("a", "tag", 40)])
.is_empty()
);
}

#[test]
fn something_else_under_the_same_rule_is_new() {
// 同一份文件、同一条规则,说的是另一个东西(另一台远端服务器):是新的。
let mut seen = Seen::default();
seen.diff(&[f("a", "remote-mcp", 1)]);
let mut other = f("a", "remote-mcp", 1);
other.subject = "other|https://mcp.example.com".into();
assert_eq!(seen.diff(&[f("a", "remote-mcp", 2), other]).len(), 1);
}

#[test]
fn one_more_of_the_same_is_new() {
// 原来一条,现在两条一模一样的:多出来的那一条是新的。
let mut seen = Seen::default();
seen.diff(&[f("a", "tag", 1)]);
assert_eq!(seen.diff(&[f("a", "tag", 40)]).len(), 1);
assert_eq!(seen.diff(&[f("a", "tag", 1), f("a", "tag", 5)]).len(), 1);
assert!(seen.diff(&[f("a", "tag", 2), f("a", "tag", 9)]).is_empty());
}

#[test]
Expand All @@ -358,6 +394,8 @@ mod tests {
let mut secret = f("a", "tag", 1);
secret.detail = tw_types::msg!("t.detail" => "这里有一段很私密的提示词内容");
secret.title = tw_types::msg!("t.title" => "标题里也有私密内容");
secret.excerpt = "私密的那一行".into();
secret.subject = "私密的那一行".into();
seen.diff(&[secret]);
let dump = format!("{:?}", seen);
assert!(!dump.contains("私密"), "{dump}");
Expand Down
43 changes: 43 additions & 0 deletions src-tauri/src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,49 @@ mod tests {
assert_eq!(rules_of(&alerts), vec!["zero_width".to_string()]);
}

/// 改一下 `~/.codex/config.toml`(上面加几行、换个模型),下面早就报过的远端 MCP 服务器
/// 整体挪了位置:**不再报一遍**。真的新加了一台,才报那一台
#[tokio::test]
async fn editing_codex_config_does_not_report_known_mcp_servers_again() {
let home = tempfile::tempdir().unwrap();
let cfg = home.path().join(".codex/config.toml");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
let docs = "[mcp_servers.docs]\nurl = \"https://mcp.example.com/sse\"\n";
std::fs::write(&cfg, format!("model = \"gpt-5\"\n\n{docs}")).unwrap();
let (_w, mut rx) = watching(home.path());
tokio::time::sleep(Duration::from_millis(400)).await;

// 上面多了几行:服务器还是那一台,只是挪了位置
std::fs::write(
&cfg,
format!("model = \"gpt-5-codex\"\nmodel_reasoning_effort = \"high\"\n\n[tools]\nweb_search = true\n\n{docs}"),
)
.unwrap();
assert!(matches!(
next_event(&mut rx, "文件变了的那一声").await,
wire::LocalEvent::ClientsChanged { .. }
));
let quiet = tokio::time::timeout(Duration::from_millis(1500), async {
loop {
if let Some(wire::LocalEvent::ScanAlert { alerts, .. }) = rx.recv().await {
return alerts;
}
}
})
.await;
assert!(quiet.is_err(), "挪了位置也报了一遍:{:#?}", quiet.ok());

// 真的新加了一台:只报这一台
std::fs::write(
&cfg,
format!("model = \"gpt-5-codex\"\n\n{docs}\n[mcp_servers.search]\nurl = \"https://search.example.org/mcp\"\n"),
)
.unwrap();
let alerts = next_alert(&mut rx).await;
assert_eq!(alerts.len(), 1, "{alerts:#?}");
assert_eq!(alerts[0].rule, "remote-mcp");
}

/// 写死的那条纪律:只报告,不自动删除。
#[tokio::test]
async fn the_watcher_never_touches_a_file() {
Expand Down
Loading