Skip to main content

miwa/
check.rs

1//! `check`: compare the current graph against the lock and report findings.
2
3#[derive(
4    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
5)]
6pub enum FindingKind {
7    Drift,
8    New,
9    Removed,
10    Broken,
11    Orphan,
12    NoTest,
13    Invalid,
14    Index,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
18pub struct Finding {
19    pub kind: FindingKind,
20    pub target_kind: crate::model::TargetKind,
21    pub target: String,
22    pub reason: crate::model::ReasonCode,
23    pub detail: String,
24}
25
26/// Findings are sorted by `(kind, target_kind, target, reason)`.
27pub fn check(graph: &crate::model::Graph, lock: &crate::lock::Lock) -> Vec<Finding> {
28    use crate::model::{EdgeKind, NodeKind, ReasonCode, TargetKind};
29
30    let mut findings = Vec::new();
31
32    for (edge_id, edge) in &graph.edges {
33        let (from_hash, to_hash) = crate::model::edge_hashes(graph, edge);
34        match lock.edges.get(edge_id) {
35            Some(entry) => {
36                let from_changed = entry.from != from_hash;
37                let to_changed = entry.to != to_hash;
38                if from_changed || to_changed {
39                    let reason = match (from_changed, to_changed, edge.kind) {
40                        (true, true, _) => ReasonCode::BothChanged,
41                        (true, false, EdgeKind::DocReq) => ReasonCode::DocChanged,
42                        (true, false, EdgeKind::TestReq) => ReasonCode::TestChanged,
43                        (true, false, EdgeKind::Desc) => ReasonCode::DescriptionChanged,
44                        (false, true, EdgeKind::Desc) => ReasonCode::BodyChanged,
45                        (false, true, _) => ReasonCode::ReqChanged,
46                        (false, false, _) => {
47                            unreachable!("guarded by from_changed || to_changed")
48                        }
49                    };
50                    findings.push(Finding {
51                        kind: FindingKind::Drift,
52                        target_kind: TargetKind::Edge,
53                        target: edge_id.clone(),
54                        reason,
55                        detail: format!("edge {edge_id} drifted from the agreed state"),
56                    });
57                }
58            }
59            None => {
60                findings.push(Finding {
61                    kind: FindingKind::New,
62                    target_kind: TargetKind::Edge,
63                    target: edge_id.clone(),
64                    reason: ReasonCode::NewEdge,
65                    detail: format!("edge {edge_id} is not recorded in the lock"),
66                });
67            }
68        }
69    }
70
71    for edge_id in lock.edges.keys() {
72        if !graph.edges.contains_key(edge_id) {
73            findings.push(Finding {
74                kind: FindingKind::Removed,
75                target_kind: TargetKind::Edge,
76                target: edge_id.clone(),
77                reason: ReasonCode::RemovedEdge,
78                detail: format!("edge {edge_id} no longer exists in the source"),
79            });
80        }
81    }
82
83    // Precompute, in a single pass over the edges, which docs/reqs already
84    // have an incoming/outgoing edge, so the orphan/no-test checks below can
85    // look each node up in O(1) instead of re-scanning all edges per node.
86    let mut docs_with_req: std::collections::HashSet<&crate::model::NodeKey> =
87        std::collections::HashSet::new();
88    let mut reqs_with_doc: std::collections::HashSet<&crate::model::NodeKey> =
89        std::collections::HashSet::new();
90    let mut reqs_with_test: std::collections::HashSet<&crate::model::NodeKey> =
91        std::collections::HashSet::new();
92    for edge in graph.edges.values() {
93        match edge.kind {
94            EdgeKind::DocReq => {
95                docs_with_req.insert(&edge.from);
96                reqs_with_doc.insert(&edge.to);
97            }
98            EdgeKind::TestReq => {
99                reqs_with_test.insert(&edge.to);
100            }
101            EdgeKind::Desc => {}
102        }
103    }
104
105    for node in graph.nodes.values() {
106        match node.key.kind {
107            NodeKind::Req => {
108                if !reqs_with_doc.contains(&node.key) {
109                    findings.push(Finding {
110                        kind: FindingKind::Orphan,
111                        target_kind: TargetKind::Req,
112                        target: node.key.id.clone(),
113                        reason: ReasonCode::ReqWithoutDoc,
114                        detail: format!("{} is not linked from any doc", node.key.id),
115                    });
116                }
117                if !reqs_with_test.contains(&node.key) {
118                    findings.push(Finding {
119                        kind: FindingKind::NoTest,
120                        target_kind: TargetKind::Req,
121                        target: node.key.id.clone(),
122                        reason: ReasonCode::ReqWithoutTest,
123                        detail: format!("{} has no verifying test", node.key.id),
124                    });
125                }
126            }
127            NodeKind::Doc => {
128                if !docs_with_req.contains(&node.key) {
129                    findings.push(Finding {
130                        kind: FindingKind::Orphan,
131                        target_kind: TargetKind::Doc,
132                        target: node.key.id.clone(),
133                        reason: ReasonCode::DocWithoutReq,
134                        detail: format!("{} does not link to any req", node.key.id),
135                    });
136                }
137            }
138            NodeKind::Test => {}
139        }
140    }
141
142    for invalid in &graph.invalid {
143        findings.push(Finding {
144            kind: FindingKind::Invalid,
145            target_kind: invalid.target_kind,
146            target: invalid.target.clone(),
147            reason: invalid.reason,
148            detail: invalid.detail.clone(),
149        });
150    }
151
152    for broken in &graph.broken {
153        let target_kind = match broken.from.kind {
154            NodeKind::Doc => TargetKind::Doc,
155            NodeKind::Test => TargetKind::Test,
156            NodeKind::Req => TargetKind::Req,
157        };
158        findings.push(Finding {
159            kind: FindingKind::Broken,
160            target_kind,
161            target: broken.from.id.clone(),
162            reason: broken.reason,
163            detail: broken.detail.clone(),
164        });
165    }
166
167    for issue in &graph.index_issues {
168        findings.push(Finding {
169            kind: FindingKind::Index,
170            target_kind: TargetKind::Index,
171            target: issue.target.clone(),
172            reason: issue.reason,
173            detail: issue.detail.clone(),
174        });
175    }
176
177    findings.sort();
178    findings
179}
180
181/// `current - base`, keyed by `(kind, target_kind, target, reason)`.
182pub fn diff_findings(current: &[Finding], base: &[Finding]) -> Vec<Finding> {
183    let base_keys: std::collections::HashSet<_> = base
184        .iter()
185        .map(|f| (f.kind, f.target_kind, f.target.clone(), f.reason))
186        .collect();
187    current
188        .iter()
189        .filter(|f| !base_keys.contains(&(f.kind, f.target_kind, f.target.clone(), f.reason)))
190        .cloned()
191        .collect()
192}
193
194/// Returns 2 if `gate` and `findings` is non-empty, else 0.
195pub fn exit_code(findings: &[Finding], gate: bool) -> i32 {
196    if gate && !findings.is_empty() {
197        2
198    } else {
199        0
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::lock::{Lock, LockEntry};
207    use crate::model::{Edge, EdgeKind, Graph, Node, NodeKey, NodeKind, ReasonCode};
208
209    fn node(kind: NodeKind, id: &str, hash: &str) -> Node {
210        Node {
211            key: NodeKey {
212                kind,
213                id: id.to_string(),
214            },
215            path: format!("{id}.md"),
216            content: String::new(),
217            hash: hash.to_string(),
218            description: None,
219        }
220    }
221
222    fn lock_entry(from: &str, to: &str) -> LockEntry {
223        LockEntry {
224            from: from.to_string(),
225            to: to.to_string(),
226            by: "human".to_string(),
227            reason: None,
228            score: None,
229            at: "2026-01-01T00:00:00Z".to_string(),
230        }
231    }
232
233    fn doc_req_graph(doc_hash: &str, req_hash: &str) -> Graph {
234        let mut graph = Graph::default();
235        let doc_key = NodeKey {
236            kind: NodeKind::Doc,
237            id: "DOC-A".to_string(),
238        };
239        let req_key = NodeKey {
240            kind: NodeKind::Req,
241            id: "FR-001".to_string(),
242        };
243        graph
244            .nodes
245            .insert(doc_key.clone(), node(NodeKind::Doc, "DOC-A", doc_hash));
246        graph
247            .nodes
248            .insert(req_key.clone(), node(NodeKind::Req, "FR-001", req_hash));
249        let edge_id = "doc-req:DOC-A:FR-001".to_string();
250        graph.edges.insert(
251            edge_id.clone(),
252            Edge {
253                id: edge_id,
254                kind: EdgeKind::DocReq,
255                from: doc_key,
256                to: req_key,
257            },
258        );
259        graph
260    }
261
262    fn test_req_graph(test_hash: &str, req_hash: &str) -> Graph {
263        let mut graph = Graph::default();
264        let test_key = NodeKey {
265            kind: NodeKind::Test,
266            id: "src/foo::bar".to_string(),
267        };
268        let req_key = NodeKey {
269            kind: NodeKind::Req,
270            id: "FR-001".to_string(),
271        };
272        graph.nodes.insert(
273            test_key.clone(),
274            node(NodeKind::Test, "src/foo::bar", test_hash),
275        );
276        graph
277            .nodes
278            .insert(req_key.clone(), node(NodeKind::Req, "FR-001", req_hash));
279        let edge_id = "test-req:src/foo::bar:FR-001".to_string();
280        graph.edges.insert(
281            edge_id.clone(),
282            Edge {
283                id: edge_id,
284                kind: EdgeKind::TestReq,
285                from: test_key,
286                to: req_key,
287            },
288        );
289        graph
290    }
291
292    // [FR-024]
293    #[test]
294    fn testだけ変わった場合はtest_changedとしてdriftを報告する() {
295        let graph = test_req_graph("sha256:test-new", "sha256:req-same");
296        let mut lock = Lock::default();
297        lock.edges.insert(
298            "test-req:src/foo::bar:FR-001".to_string(),
299            lock_entry("sha256:test-old", "sha256:req-same"),
300        );
301        let findings = check(&graph, &lock);
302        assert!(findings.iter().any(|f| {
303            f.kind == FindingKind::Drift
304                && f.reason == ReasonCode::TestChanged
305                && f.target == "test-req:src/foo::bar:FR-001"
306        }));
307    }
308
309    // [FR-024]
310    #[test]
311    fn docだけ変わった場合はdoc_changedとしてdriftを報告する() {
312        let graph = doc_req_graph("sha256:doc-new", "sha256:req-same");
313        let mut lock = Lock::default();
314        lock.edges.insert(
315            "doc-req:DOC-A:FR-001".to_string(),
316            lock_entry("sha256:doc-old", "sha256:req-same"),
317        );
318        let findings = check(&graph, &lock);
319        assert!(findings.iter().any(|f| f.kind == FindingKind::Drift
320            && f.reason == ReasonCode::DocChanged
321            && f.target == "doc-req:DOC-A:FR-001"));
322    }
323
324    // [FR-024]
325    #[test]
326    fn reqだけ変わった場合はreq_changedとしてdriftを報告する() {
327        let graph = doc_req_graph("sha256:doc-same", "sha256:req-new");
328        let mut lock = Lock::default();
329        lock.edges.insert(
330            "doc-req:DOC-A:FR-001".to_string(),
331            lock_entry("sha256:doc-same", "sha256:req-old"),
332        );
333        let findings = check(&graph, &lock);
334        assert!(findings
335            .iter()
336            .any(|f| f.kind == FindingKind::Drift && f.reason == ReasonCode::ReqChanged));
337    }
338
339    // [FR-024]
340    #[test]
341    fn 両側が変わった場合はboth_changedとして1件だけ報告する() {
342        let graph = doc_req_graph("sha256:doc-new", "sha256:req-new");
343        let mut lock = Lock::default();
344        lock.edges.insert(
345            "doc-req:DOC-A:FR-001".to_string(),
346            lock_entry("sha256:doc-old", "sha256:req-old"),
347        );
348        let findings = check(&graph, &lock);
349        let drifts: Vec<_> = findings
350            .iter()
351            .filter(|f| f.kind == FindingKind::Drift && f.target == "doc-req:DOC-A:FR-001")
352            .collect();
353        assert_eq!(
354            drifts.len(),
355            1,
356            "expected exactly one drift finding: {drifts:?}"
357        );
358        assert_eq!(drifts[0].reason, ReasonCode::BothChanged);
359    }
360
361    // [FR-025]
362    #[test]
363    fn ロックにないエッジはnewとして報告する() {
364        let graph = doc_req_graph("sha256:doc", "sha256:req");
365        let lock = Lock::default();
366        let findings = check(&graph, &lock);
367        assert!(findings.iter().any(|f| f.kind == FindingKind::New
368            && f.target == "doc-req:DOC-A:FR-001"
369            && f.reason == ReasonCode::NewEdge));
370    }
371
372    // [FR-025]
373    #[test]
374    fn ソースから消えたエッジはremovedとして報告する() {
375        let mut graph = Graph::default();
376        graph.nodes.insert(
377            NodeKey {
378                kind: NodeKind::Req,
379                id: "FR-001".to_string(),
380            },
381            node(NodeKind::Req, "FR-001", "sha256:req"),
382        );
383        let mut lock = Lock::default();
384        lock.edges.insert(
385            "doc-req:DOC-A:FR-001".to_string(),
386            lock_entry("sha256:doc-old", "sha256:req-old"),
387        );
388        let findings = check(&graph, &lock);
389        assert!(findings.iter().any(|f| {
390            f.kind == FindingKind::Removed
391                && f.target == "doc-req:DOC-A:FR-001"
392                && f.reason == ReasonCode::RemovedEdge
393        }));
394    }
395
396    // [FR-026]
397    #[test]
398    fn どのdocからもリンクされないreqはorphanになる() {
399        let mut graph = Graph::default();
400        graph.nodes.insert(
401            NodeKey {
402                kind: NodeKind::Req,
403                id: "FR-001".to_string(),
404            },
405            node(NodeKind::Req, "FR-001", "sha256:req"),
406        );
407        let findings = check(&graph, &Lock::default());
408        assert!(findings.iter().any(|f| {
409            f.kind == FindingKind::Orphan
410                && f.reason == ReasonCode::ReqWithoutDoc
411                && f.target == "FR-001"
412        }));
413    }
414
415    // [FR-026]
416    #[test]
417    fn どのreqにもリンクしないdocはorphanになる() {
418        let mut graph = Graph::default();
419        graph.nodes.insert(
420            NodeKey {
421                kind: NodeKind::Doc,
422                id: "DOC-A".to_string(),
423            },
424            node(NodeKind::Doc, "DOC-A", "sha256:doc"),
425        );
426        let findings = check(&graph, &Lock::default());
427        assert!(findings.iter().any(|f| {
428            f.kind == FindingKind::Orphan
429                && f.reason == ReasonCode::DocWithoutReq
430                && f.target == "DOC-A"
431        }));
432    }
433
434    // [FR-027]
435    #[test]
436    fn テストで検証されていないreqはno_testになる() {
437        let graph = doc_req_graph("sha256:doc", "sha256:req");
438        let findings = check(&graph, &Lock::default());
439        assert!(findings.iter().any(|f| {
440            f.kind == FindingKind::NoTest
441                && f.reason == ReasonCode::ReqWithoutTest
442                && f.target == "FR-001"
443        }));
444    }
445
446    // [FR-030]
447    #[test]
448    fn exit_codeはgate指定かつfindingがあれば2を返す() {
449        let graph = doc_req_graph("sha256:doc", "sha256:req");
450        let findings = check(&graph, &Lock::default());
451        assert!(!findings.is_empty());
452        assert_eq!(exit_code(&findings, true), 2);
453    }
454
455    // [FR-030]
456    #[test]
457    fn exit_codeはgateなしなら常に0を返す() {
458        let graph = doc_req_graph("sha256:doc", "sha256:req");
459        let findings = check(&graph, &Lock::default());
460        assert!(!findings.is_empty());
461        assert_eq!(exit_code(&findings, false), 0);
462        assert_eq!(exit_code(&[], false), 0);
463    }
464
465    // [FR-030]
466    #[test]
467    fn exit_codeはfindingが0件でgateがあっても0を返す() {
468        assert_eq!(exit_code(&[], true), 0);
469    }
470
471    // [FR-031]
472    #[test]
473    fn diff_findingsは種別_対象種別_対象_理由が一致するものを除外する() {
474        let base_finding = Finding {
475            kind: FindingKind::Drift,
476            target_kind: crate::model::TargetKind::Edge,
477            target: "doc-req:DOC-A:FR-001".to_string(),
478            reason: ReasonCode::DocChanged,
479            detail: "旧い説明文".to_string(),
480        };
481        let mut current_same = base_finding.clone();
482        current_same.detail = "新しい説明文".to_string(); // 説明文の違いは同一性に影響しない
483
484        let new_finding = Finding {
485            kind: FindingKind::New,
486            target_kind: crate::model::TargetKind::Edge,
487            target: "doc-req:DOC-B:FR-002".to_string(),
488            reason: ReasonCode::NewEdge,
489            detail: String::new(),
490        };
491
492        let current = vec![current_same, new_finding.clone()];
493        let base = vec![base_finding];
494        let diff = diff_findings(&current, &base);
495        assert_eq!(diff, vec![new_finding]);
496    }
497
498    fn desc_node(kind: NodeKind, id: &str, description: Option<&str>, hash: &str) -> Node {
499        Node {
500            description: description.map(str::to_string),
501            ..node(kind, id, hash)
502        }
503    }
504
505    fn add_desc_edge(graph: &mut Graph, kind: NodeKind, id: &str) {
506        let key = NodeKey {
507            kind,
508            id: id.to_string(),
509        };
510        let edge_id = crate::model::desc_edge_id(&key);
511        graph.edges.insert(
512            edge_id.clone(),
513            Edge {
514                id: edge_id,
515                kind: EdgeKind::Desc,
516                from: key.clone(),
517                to: key,
518            },
519        );
520    }
521
522    /// A single Doc node `DOC-A` with a `desc:doc:DOC-A` edge.
523    fn desc_graph(description: Option<&str>, body_hash: &str) -> Graph {
524        let mut graph = Graph::default();
525        let n = desc_node(NodeKind::Doc, "DOC-A", description, body_hash);
526        graph.nodes.insert(n.key.clone(), n);
527        add_desc_edge(&mut graph, NodeKind::Doc, "DOC-A");
528        graph
529    }
530
531    fn desc_lock(description: &str, body_hash: &str) -> Lock {
532        let mut lock = Lock::default();
533        lock.edges.insert(
534            "desc:doc:DOC-A".to_string(),
535            lock_entry(&crate::hash::content_hash(description), body_hash),
536        );
537        lock
538    }
539
540    fn desc_drifts(findings: &[Finding]) -> Vec<&Finding> {
541        findings
542            .iter()
543            .filter(|f| f.kind == FindingKind::Drift && f.target == "desc:doc:DOC-A")
544            .collect()
545    }
546
547    // [FR-024]
548    #[test]
549    fn descは要約だけ変わるとdescription_changedのdriftになる() {
550        let graph = desc_graph(Some("新しい要約"), "sha256:body");
551        let findings = check(&graph, &desc_lock("古い要約", "sha256:body"));
552        let drifts = desc_drifts(&findings);
553        assert_eq!(drifts.len(), 1, "{findings:?}");
554        assert_eq!(drifts[0].reason, ReasonCode::DescriptionChanged);
555    }
556
557    // [FR-024]
558    #[test]
559    fn descは本文だけ変わるとbody_changedのdriftになる() {
560        let graph = desc_graph(Some("要約"), "sha256:body-new");
561        let findings = check(&graph, &desc_lock("要約", "sha256:body-old"));
562        let drifts = desc_drifts(&findings);
563        assert_eq!(drifts.len(), 1, "{findings:?}");
564        assert_eq!(drifts[0].reason, ReasonCode::BodyChanged);
565    }
566
567    // [FR-088]
568    #[test]
569    fn descは要約と本文の両方が変わるとboth_changedのdriftが1件になる() {
570        let graph = desc_graph(Some("新しい要約"), "sha256:body-new");
571        let findings = check(&graph, &desc_lock("古い要約", "sha256:body-old"));
572        let drifts = desc_drifts(&findings);
573        assert_eq!(drifts.len(), 1, "{findings:?}");
574        assert_eq!(drifts[0].reason, ReasonCode::BothChanged);
575    }
576
577    // [FR-088]
578    #[test]
579    fn descは変化がなければdriftを報告しない() {
580        let graph = desc_graph(Some("要約"), "sha256:body");
581        let findings = check(&graph, &desc_lock("要約", "sha256:body"));
582        assert!(
583            findings.iter().all(|f| f.target != "desc:doc:DOC-A"),
584            "{findings:?}"
585        );
586    }
587
588    // [FR-088]
589    #[test]
590    fn ロックにないdescエッジはnewとして報告する() {
591        let graph = desc_graph(Some("要約"), "sha256:body");
592        let findings = check(&graph, &Lock::default());
593        assert!(findings.iter().any(|f| f.kind == FindingKind::New
594            && f.target == "desc:doc:DOC-A"
595            && f.reason == ReasonCode::NewEdge));
596    }
597
598    // [FR-088]
599    #[test]
600    fn ソースから消えたdescエッジはremovedとして報告する() {
601        let findings = check(&Graph::default(), &desc_lock("要約", "sha256:body"));
602        assert!(findings.iter().any(|f| f.kind == FindingKind::Removed
603            && f.target == "desc:doc:DOC-A"
604            && f.reason == ReasonCode::RemovedEdge));
605    }
606
607    // [FR-088]
608    #[test]
609    fn 要約を消すとロックの要約が非空ならdescription_changedになる() {
610        let graph = desc_graph(None, "sha256:body");
611        let findings = check(&graph, &desc_lock("要約", "sha256:body"));
612        let drifts = desc_drifts(&findings);
613        assert_eq!(drifts.len(), 1, "{findings:?}");
614        assert_eq!(drifts[0].reason, ReasonCode::DescriptionChanged);
615    }
616
617    // [FR-088]
618    #[test]
619    fn ロックの要約が空で要約も無ければdriftを報告しない() {
620        let graph = desc_graph(None, "sha256:body");
621        let findings = check(&graph, &desc_lock("", "sha256:body"));
622        assert!(desc_drifts(&findings).is_empty(), "{findings:?}");
623    }
624
625    fn lock_of(graph: &Graph) -> Lock {
626        let mut lock = Lock::default();
627        for (id, edge) in &graph.edges {
628            let (from, to) = crate::model::edge_hashes(graph, edge);
629            lock.edges.insert(id.clone(), lock_entry(&from, &to));
630        }
631        lock
632    }
633
634    /// DOC-A links FR-001 and FR-002; FR-001 is also tested; desc edges for
635    /// DOC-A and FR-001 (the req is under an index root).
636    fn full_graph(doc_hash: &str, req_hash: &str) -> Graph {
637        let mut graph = Graph::default();
638        for n in [
639            desc_node(NodeKind::Doc, "DOC-A", Some("doc 要約"), doc_hash),
640            desc_node(NodeKind::Req, "FR-001", Some("req 要約"), req_hash),
641            desc_node(NodeKind::Req, "FR-002", None, "sha256:r2"),
642            node(NodeKind::Test, "src/foo::bar", "sha256:t"),
643        ] {
644            graph.nodes.insert(n.key.clone(), n);
645        }
646        for (kind, from_kind, from, to) in [
647            (EdgeKind::DocReq, NodeKind::Doc, "DOC-A", "FR-001"),
648            (EdgeKind::DocReq, NodeKind::Doc, "DOC-A", "FR-002"),
649            (EdgeKind::TestReq, NodeKind::Test, "src/foo::bar", "FR-001"),
650        ] {
651            let id = crate::model::edge_id(kind, from, to);
652            graph.edges.insert(
653                id.clone(),
654                Edge {
655                    id,
656                    kind,
657                    from: NodeKey {
658                        kind: from_kind,
659                        id: from.to_string(),
660                    },
661                    to: NodeKey {
662                        kind: NodeKind::Req,
663                        id: to.to_string(),
664                    },
665                },
666            );
667        }
668        add_desc_edge(&mut graph, NodeKind::Doc, "DOC-A");
669        add_desc_edge(&mut graph, NodeKind::Req, "FR-001");
670        graph
671    }
672
673    fn drift_reasons(findings: &[Finding]) -> Vec<(String, ReasonCode)> {
674        findings
675            .iter()
676            .filter(|f| f.kind == FindingKind::Drift)
677            .map(|f| (f.target.clone(), f.reason))
678            .collect()
679    }
680
681    // [FR-088]
682    #[test]
683    fn doc本文を編集するとdoc_reqは本数分のdoc_changedとdescのbody_changedが別々に出る() {
684        let lock = lock_of(&full_graph("sha256:d-old", "sha256:r1"));
685        let findings = check(&full_graph("sha256:d-new", "sha256:r1"), &lock);
686        assert_eq!(
687            drift_reasons(&findings),
688            vec![
689                ("desc:doc:DOC-A".to_string(), ReasonCode::BodyChanged),
690                ("doc-req:DOC-A:FR-001".to_string(), ReasonCode::DocChanged),
691                ("doc-req:DOC-A:FR-002".to_string(), ReasonCode::DocChanged),
692            ]
693        );
694    }
695
696    // [FR-088]
697    #[test]
698    fn 索引配下のreq本文を編集するとreq_changedとdescのbody_changedが出る() {
699        let lock = lock_of(&full_graph("sha256:d", "sha256:r1-old"));
700        let findings = check(&full_graph("sha256:d", "sha256:r1-new"), &lock);
701        assert_eq!(
702            drift_reasons(&findings),
703            vec![
704                ("desc:req:FR-001".to_string(), ReasonCode::BodyChanged),
705                ("doc-req:DOC-A:FR-001".to_string(), ReasonCode::ReqChanged),
706                (
707                    "test-req:src/foo::bar:FR-001".to_string(),
708                    ReasonCode::ReqChanged
709                ),
710            ]
711        );
712    }
713
714    // [FR-087]
715    #[test]
716    fn 要約だけを変えてもdoc_reqとtest_reqにfindingは出ない() {
717        let old = full_graph("sha256:d", "sha256:r1");
718        let lock = lock_of(&old);
719        let mut new = old.clone();
720        for n in new.nodes.values_mut() {
721            if n.description.is_some() {
722                n.description = Some("変更後の要約".to_string());
723            }
724        }
725        let findings = check(&new, &lock);
726        assert_eq!(
727            drift_reasons(&findings),
728            vec![
729                ("desc:doc:DOC-A".to_string(), ReasonCode::DescriptionChanged),
730                (
731                    "desc:req:FR-001".to_string(),
732                    ReasonCode::DescriptionChanged
733                ),
734            ]
735        );
736    }
737
738    // [FR-086]
739    #[test]
740    fn descしか持たないreqはorphanとno_testのままになる() {
741        let mut graph = Graph::default();
742        let n = desc_node(NodeKind::Req, "FR-001", Some("要約"), "sha256:r");
743        graph.nodes.insert(n.key.clone(), n);
744        add_desc_edge(&mut graph, NodeKind::Req, "FR-001");
745        let findings = check(&graph, &lock_of(&graph));
746        assert!(findings.iter().any(|f| f.kind == FindingKind::Orphan
747            && f.reason == ReasonCode::ReqWithoutDoc
748            && f.target == "FR-001"));
749        assert!(findings.iter().any(|f| f.kind == FindingKind::NoTest
750            && f.reason == ReasonCode::ReqWithoutTest
751            && f.target == "FR-001"));
752    }
753
754    // [FR-086]
755    #[test]
756    fn descしか持たないdocはorphanのままになる() {
757        let graph = desc_graph(Some("要約"), "sha256:body");
758        let findings = check(&graph, &lock_of(&graph));
759        assert!(findings.iter().any(|f| f.kind == FindingKind::Orphan
760            && f.reason == ReasonCode::DocWithoutReq
761            && f.target == "DOC-A"));
762    }
763}