Skip to main content

code_repo_wiki/incremental/
change.rs

1//! 实体级代码变化分类(演进计划 T2.1)
2//!
3//! 把文件级 diff([`GitDiffResult`])细化为实体级变化分类。
4//! 方法:对 modified 文件从 from_commit 读取旧内容(git2),用同一
5//! ParserRegistry 重解析出旧实体集,与当前工作区实体集(FileInsight)
6//! 按 name 对比判定变化类型。
7//!
8//! 分类语义(驱动 T2.2 语义传播):
9//! - **新增/删除/签名变更** = 接口级变化:影响调用方,需向依赖方传播;
10//! - **正文变化**(BodyChanged)= 实现级变化:只影响本模块产物。
11//!
12//! 边界:非 UTF-8 旧文件、无对应解析器的文件、from_commit 中不存在的
13//! 文件,均安全降级(视为无旧实体或空变化),不中断增量流程。
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18use anyhow::{Context, Result};
19
20use crate::ingest::parser::{Entity, FileInsight, ParserRegistry};
21
22use super::diff::GitDiffResult;
23
24/// 实体变化类型
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum EntityChangeKind {
27    /// 新增实体(接口级)
28    Added,
29    /// 实体被删除(接口级)
30    Removed,
31    /// 实体签名变化(接口级:需向依赖方传播)
32    SignatureChanged,
33    /// 实体实现变化(函数体修改,仅影响本模块)
34    BodyChanged,
35}
36
37/// 单个实体的变化记录
38#[derive(Debug, Clone)]
39pub struct EntityChange {
40    pub file: PathBuf,
41    pub entity_name: String,
42    pub kind: EntityChangeKind,
43    /// 变化前位置(新增时为 None)
44    pub old_range: Option<(usize, usize)>,
45    /// 变化后位置(删除时为 None)
46    pub new_range: Option<(usize, usize)>,
47}
48
49/// 实体级变化集合
50#[derive(Debug, Clone, Default)]
51pub struct EntityChangeSet {
52    pub changes: Vec<EntityChange>,
53}
54
55impl EntityChangeSet {
56    /// 是否包含接口级变化(新增/删除/签名变更)
57    pub fn has_interface_change(&self) -> bool {
58        self.changes.iter().any(|c| match c.kind {
59            EntityChangeKind::Added
60            | EntityChangeKind::Removed
61            | EntityChangeKind::SignatureChanged => true,
62            EntityChangeKind::BodyChanged => false,
63        })
64    }
65}
66
67/// 计算「无实体变更」文件集合(v23 A1)
68///
69/// 判据:文件在 changed_files(git diff 报告有变化)且仍存在于磁盘,但
70/// `entity_changes` 中没有任何该文件的记录(compare_entities 的三元组全等
71/// 判定跳过)——变化只可能发生在注释/空白/换行符等不产出实体的文本上。
72/// 这类文件不触发模块重生成:模块页内容与 api.md 行号引用均不受影响。
73///
74/// 调用方必须用它**同时**剔除两类输入,否则语义不一致:
75/// - 影响传播的起点集合(mod.rs):无实体变更文件作为起点会走「仅本模块」
76///   分支,把本模块捞入 affected_modules → 模块页仍被重生成;
77/// - 生成过滤(generate/mod.rs):changed_insights 过滤条件。
78///
79/// 删除文件不在此集合(磁盘不存在,判据第一项排除),快照回填逻辑不受影响。
80pub fn no_entity_change_files(
81    changed_files: &[PathBuf],
82    entity_changes: &EntityChangeSet,
83    root: &crate::project::ProjectRoot,
84) -> std::collections::HashSet<PathBuf> {
85    // 空变化集 = 无分类信息(非 git 仓库/无上次提交时 classify 返回
86    // 默认空集,见 classify_entity_changes_at:107),不是"确无实体变化"。
87    // 此时剔除会把起点全清空,FileWatch 增量在非 git 仓库退化为 0 模块
88    // (回归:test_watch_e2e 修改文件后 api.md 永不更新)。与
89    // classification_failed 同语义:无法分类时保守保留全部起点。
90    if entity_changes.changes.is_empty() {
91        return std::collections::HashSet::new();
92    }
93    changed_files
94        .iter()
95        .filter(|f| {
96            root.path().join(f).exists()
97                && !entity_changes
98                    .changes
99                    .iter()
100                    .any(|c| c.file.as_path() == f.as_path())
101        })
102        .cloned()
103        .collect()
104}
105
106/// 在指定项目根下对 Git diff 做实体级变化分类
107///
108/// git 仓库定位基准显式注入(与 analyze_git_diff 同源,root 由
109/// run_incremental_update_at 统一传入):不再依赖进程 cwd。
110pub fn classify_entity_changes_at(
111    root: &crate::project::ProjectRoot,
112    diff: &GitDiffResult,
113    current_insights: &[FileInsight],
114) -> Result<EntityChangeSet> {
115    if diff.from_commit.is_empty() {
116        // 无上次生成记录(首次生成)时无法读旧内容,视为无实体变化
117        return Ok(EntityChangeSet::default());
118    }
119    let repo = git2::Repository::open(root.path())
120        .with_context(|| "实体级变化分类需要 Git 仓库")?;
121    // commit/tree 只解析一次(避免每个文件重复 find_commit 的 ODB 开销)
122    let from_commit = repo.find_commit(git2::Oid::from_str(&diff.from_commit)?)?;
123    let from_tree = from_commit.tree()?;
124    let registry = ParserRegistry::new();
125
126    // 当前工作区实体:路径 → 实体列表(键归一化:git diff 路径正斜杠,
127    // insight 路径在 Windows 上是反斜杠,统一为 "/" 才能匹配;且 insight
128    // 路径是绝对路径,须先相对化(strip root),否则与 diff.modified 的
129    // 相对路径永远不匹配→旧实体有、新实体空→误判 Removed(接口级双向
130    // 传播误伤依赖方,见 test_incremental_git_e2e 场景 A 回归)
131    let current: HashMap<String, Vec<Entity>> = current_insights
132        .iter()
133        .map(|i| {
134            let rel = std::path::Path::new(&i.path)
135                .strip_prefix(root.path())
136                .unwrap_or(std::path::Path::new(&i.path));
137            (super::norm_sep(&rel.to_string_lossy()), i.entities.clone())
138        })
139        .collect();
140
141    let mut set = EntityChangeSet::default();
142
143    // modified:新旧实体集对比
144    for path in &diff.modified {
145        let old_entities = read_old_entities(&repo, &from_tree, path, &registry)?;
146        let new_entities = current
147            .get(&super::norm_sep(&path.to_string_lossy()))
148            .cloned()
149            .unwrap_or_default();
150        compare_entities(&mut set, path, &old_entities, &new_entities);
151    }
152    // added:全部实体视为新增
153    for path in &diff.added {
154        if let Some(ents) = current.get(&super::norm_sep(&path.to_string_lossy())) {
155            for e in ents {
156                set.changes.push(EntityChange {
157                    file: path.clone(),
158                    entity_name: e.name.clone(),
159                    kind: EntityChangeKind::Added,
160                    old_range: None,
161                    new_range: Some((e.line_start, e.line_end)),
162                });
163            }
164        }
165    }
166    // deleted:从旧内容解析全部实体视为删除
167    for path in &diff.deleted {
168        for e in read_old_entities(&repo, &from_tree, path, &registry)? {
169            set.changes.push(EntityChange {
170                file: path.clone(),
171                entity_name: e.name.clone(),
172                kind: EntityChangeKind::Removed,
173                old_range: Some((e.line_start, e.line_end)),
174                new_range: None,
175            });
176        }
177    }
178    Ok(set)
179}
180
181/// 对比同一文件的新旧实体集,产出逐实体变化
182///
183/// 同名实体按签名归一化集合比较:签名集合不变 → BodyChanged;
184/// 签名集合变化 → SignatureChanged;数量变化 → Added/Removed。
185fn compare_entities(
186    set: &mut EntityChangeSet,
187    path: &Path,
188    old: &[Entity],
189    new: &[Entity],
190) {
191    let old_by_name: HashMap<&str, Vec<&Entity>> = group_by_name(old);
192    let new_by_name: HashMap<&str, Vec<&Entity>> = group_by_name(new);
193
194    // 新增实体
195    for (name, entries) in &new_by_name {
196        if !old_by_name.contains_key(*name) {
197            for e in entries {
198                set.changes.push(EntityChange {
199                    file: path.to_path_buf(),
200                    entity_name: e.name.clone(),
201                    kind: EntityChangeKind::Added,
202                    old_range: None,
203                    new_range: Some((e.line_start, e.line_end)),
204                });
205            }
206        }
207    }
208    // 删除实体
209    for (name, entries) in &old_by_name {
210        if !new_by_name.contains_key(*name) {
211            for e in entries {
212                set.changes.push(EntityChange {
213                    file: path.to_path_buf(),
214                    entity_name: e.name.clone(),
215                    kind: EntityChangeKind::Removed,
216                    old_range: Some((e.line_start, e.line_end)),
217                    new_range: None,
218                });
219            }
220        }
221    }
222    // 同名实体:按签名集合比较判定接口/实现变化
223    for (name, old_entries) in &old_by_name {
224        if let Some(new_entries) = new_by_name.get(*name) {
225            let old_sigs: Vec<String> = old_entries
226                .iter()
227                .map(|e| normalize_sig(e.signature.as_deref()))
228                .collect();
229            let new_sigs: Vec<String> = new_entries
230                .iter()
231                .map(|e| normalize_sig(e.signature.as_deref()))
232                .collect();
233            let kind = if old_sigs == new_sigs {
234                EntityChangeKind::BodyChanged
235            } else {
236                EntityChangeKind::SignatureChanged
237            };
238            // 按位置顺序配对输出(同名多实体取并集逐条记录)
239            for (old_e, new_e) in old_entries.iter().zip(new_entries.iter()) {
240                // v23 A1 实体级分类精化:签名集合相等时进一步比较配对实体三元组
241                // (起止行号 + 签名)。三元组全等 = 该实体的声明与位置均未变化
242                // (变化只可能发生在注释/空白等不产实体的文本上),不记录任何
243                // 变更——生成层据此把整文件判为「无实体变更」跳过重生成。
244                // 任一元素不等(含 zip 顺序错位)→ 保守记录 BodyChanged。
245                let unchanged = kind == EntityChangeKind::BodyChanged
246                    && old_e.line_start == new_e.line_start
247                    && old_e.line_end == new_e.line_end
248                    && normalize_sig(old_e.signature.as_deref())
249                        == normalize_sig(new_e.signature.as_deref());
250                if unchanged {
251                    continue;
252                }
253                set.changes.push(EntityChange {
254                    file: path.to_path_buf(),
255                    entity_name: (*name).to_string(),
256                    kind,
257                    old_range: Some((old_e.line_start, old_e.line_end)),
258                    new_range: Some((new_e.line_start, new_e.line_end)),
259                });
260            }
261        }
262    }
263}
264
265/// 签名归一化:删除全部空白字符比较(容忍换行/多余空格/括号内空格)
266fn normalize_sig(sig: Option<&str>) -> String {
267    sig.unwrap_or("")
268        .chars()
269        .filter(|c| !c.is_whitespace())
270        .collect()
271}
272
273/// 从 from_commit 读取文件旧内容并解析出实体集
274///
275/// 文件在 from_commit 中不存在(新文件被判定为 modified 的边界场景)
276/// 返回空集;非 UTF-8 或无可解析器返回空集(降级不中断)。
277fn read_old_entities(
278    repo: &git2::Repository,
279    from_tree: &git2::Tree,
280    path: &Path,
281    registry: &ParserRegistry,
282) -> Result<Vec<Entity>> {
283    let entry = match from_tree.get_path(path) {
284        Ok(e) => e,
285        Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(Vec::new()),
286        Err(e) => return Err(e.into()),
287    };
288    let obj = entry
289        .to_object(repo)
290        .with_context(|| format!("读取 {} 旧版本失败", path.display()))?;
291    let blob = obj
292        .into_blob()
293        .map_err(|_| anyhow::anyhow!("{} 在 from_commit 中不是 blob", path.display()))?;
294    let content = match std::str::from_utf8(blob.content()) {
295        Ok(c) => c,
296        // 二进制文件(图片/数据库/序列化产物等)无实体概念:降级为空集,
297        // 不中断分类——否则仓库内任一被提交的二进制文件会让整个 classify
298        // 失败回退保守传播(模块级误重生成)。与头部注释的降级语义一致。
299        Err(_) => return Ok(Vec::new()),
300    };
301    Ok(match registry.get_for_file(path) {
302        Some(parser) => parser.parse(content, path)?.entities,
303        None => Vec::new(),
304    })
305}
306
307/// 按实体名分组(保留同名多实体,如重载/多 impl 块)
308fn group_by_name(entities: &[Entity]) -> HashMap<&str, Vec<&Entity>> {
309    let mut map: HashMap<&str, Vec<&Entity>> = HashMap::new();
310    for e in entities {
311        map.entry(e.name.as_str()).or_default().push(e);
312    }
313    map
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn make_entity(name: &str, sig: &str, start: usize, end: usize) -> Entity {
321        Entity {
322            name: name.into(),
323            kind: "fn".into(),
324            line_start: start,
325            line_end: end,
326            doc_comment: None,
327            signature: Some(sig.into()), visibility: None,
328        }
329    }
330
331    #[test]
332    fn test_compare_added_and_removed() {
333        let mut set = EntityChangeSet::default();
334        let old = vec![make_entity("gone", "fn gone()", 1, 2)];
335        let new = vec![make_entity("fresh", "fn fresh()", 5, 6)];
336        compare_entities(&mut set, Path::new("src/a.rs"), &old, &new);
337        assert_eq!(set.changes.len(), 2);
338        assert_eq!(set.changes[0].kind, EntityChangeKind::Added);
339        assert_eq!(set.changes[1].kind, EntityChangeKind::Removed);
340        assert!(set.has_interface_change());
341    }
342
343    #[test]
344    fn test_compare_signature_changed() {
345        let mut set = EntityChangeSet::default();
346        let old = vec![make_entity("f", "fn f(a: i32)", 1, 3)];
347        let new = vec![make_entity("f", "fn f(a: i32, b: i32)", 1, 4)];
348        compare_entities(&mut set, Path::new("src/a.rs"), &old, &new);
349        assert_eq!(set.changes.len(), 1);
350        assert_eq!(set.changes[0].kind, EntityChangeKind::SignatureChanged);
351        assert!(set.has_interface_change());
352    }
353
354    #[test]
355    fn test_compare_body_changed_only() {
356        let mut set = EntityChangeSet::default();
357        // 签名一致、行号范围变化 = 正文修改
358        let old = vec![make_entity("f", "fn f()", 1, 3)];
359        let new = vec![make_entity("f", "fn f()", 1, 5)];
360        compare_entities(&mut set, Path::new("src/a.rs"), &old, &new);
361        assert_eq!(set.changes.len(), 1);
362        assert_eq!(set.changes[0].kind, EntityChangeKind::BodyChanged);
363        assert!(!set.has_interface_change());
364    }
365
366    #[test]
367    fn test_normalize_sig_ignores_whitespace() {
368        let a = "fn  f( a : i32 )";
369        let b = "fn f(a: i32)";
370        assert_eq!(normalize_sig(Some(a)), normalize_sig(Some(b)));
371    }
372}