1#[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
26pub 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 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
181pub 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
194pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
467 fn exit_codeはfindingが0件でgateがあっても0を返す() {
468 assert_eq!(exit_code(&[], true), 0);
469 }
470
471 #[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(); 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(¤t, &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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}