1#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum ReconcileTarget {
5 All,
6 Edges(Vec<String>),
7}
8
9#[derive(Debug, thiserror::Error)]
10pub enum ReconcileError {
11 #[error("unknown edge: {0}")]
12 UnknownEdge(String),
13 #[error("edge {0} has structural problems")]
14 Blocked(String),
15 #[error("graph has broken references or invalid nodes")]
16 GraphNotClean,
17}
18
19pub fn reconcile(
20 graph: &crate::model::Graph,
21 lock: &crate::lock::Lock,
22 target: ReconcileTarget,
23 reason: Option<String>,
24 now: &str,
25) -> Result<crate::lock::Lock, ReconcileError> {
26 use crate::lock::{Lock, LockEntry};
27
28 match target {
29 ReconcileTarget::All => {
30 if !graph.invalid.is_empty() || !graph.broken.is_empty() {
31 return Err(ReconcileError::GraphNotClean);
32 }
33 let mut new_lock = Lock::default();
34 for (edge_id, edge) in &graph.edges {
35 let (from_hash, to_hash) = crate::model::edge_hashes(graph, edge);
36 let entry = match lock.edges.get(edge_id) {
37 Some(existing) if existing.from == from_hash && existing.to == to_hash => {
38 existing.clone()
39 }
40 _ => LockEntry {
41 from: from_hash,
42 to: to_hash,
43 by: "human".to_string(),
44 reason: reason.clone(),
45 score: None,
46 at: now.to_string(),
47 },
48 };
49 new_lock.edges.insert(edge_id.clone(), entry);
50 }
51 Ok(new_lock)
52 }
53 ReconcileTarget::Edges(ids) => {
54 let mut new_lock = lock.clone();
55 for id in ids {
56 if let Some(edge) = graph.edges.get(&id) {
57 let blocked = graph
58 .invalid
59 .iter()
60 .any(|inv| inv.target == edge.from.id || inv.target == edge.to.id)
61 || graph.broken.iter().any(|b| b.from == edge.from);
62 if blocked {
63 return Err(ReconcileError::Blocked(id));
64 }
65 let (from_hash, to_hash) = crate::model::edge_hashes(graph, edge);
66 new_lock.edges.insert(
67 id,
68 LockEntry {
69 from: from_hash,
70 to: to_hash,
71 by: "human".to_string(),
72 reason: reason.clone(),
73 score: None,
74 at: now.to_string(),
75 },
76 );
77 } else if new_lock.edges.contains_key(&id) {
78 if let Some((kind_prefix, from_id, to_id)) = parse_edge_id(&id) {
85 let mut blocked = graph
86 .invalid
87 .iter()
88 .any(|inv| inv.target == from_id || inv.target == to_id)
89 || graph.broken.iter().any(|b| b.from.id == from_id);
90 if !blocked && kind_prefix == "test-req" {
91 blocked = test_source_has_parse_error(&graph.invalid, from_id);
92 }
93 if blocked {
94 return Err(ReconcileError::Blocked(id));
95 }
96 }
97 new_lock.edges.remove(&id);
98 } else {
99 return Err(ReconcileError::UnknownEdge(id));
100 }
101 }
102 Ok(new_lock)
103 }
104 }
105}
106
107fn parse_edge_id(id: &str) -> Option<(&str, &str, &str)> {
115 if let Some(node_id) = id
116 .strip_prefix("desc:doc:")
117 .or_else(|| id.strip_prefix("desc:req:"))
118 {
119 return Some(("desc", node_id, node_id));
120 }
121 let (rest, to_id) = id.rsplit_once(':')?;
122 let (kind_prefix, from_id) = rest.split_once(':')?;
123 Some((kind_prefix, from_id, to_id))
124}
125
126fn test_source_has_parse_error(invalid: &[crate::model::Invalid], test_id: &str) -> bool {
140 let prefix = test_id.split_once(':').map_or(test_id, |(p, _)| p);
141 invalid.iter().any(|inv| {
142 if inv.reason != crate::model::ReasonCode::ParseError {
143 return false;
144 }
145 let target = inv.target.as_str();
146 if target == format!("{prefix}.rs")
147 || target == format!("{prefix}.cs")
148 || target == format!("{prefix}.java")
149 || target == format!("{prefix}.kt")
150 || crate::extract::TYPESCRIPT_TEST_EXTENSIONS
151 .iter()
152 .any(|ext| target == format!("{prefix}.{ext}"))
153 {
154 return true;
155 }
156 if prefix == "." {
157 !target.contains('/')
158 } else {
159 target.starts_with(&format!("{prefix}/"))
160 }
161 })
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::lock::{Lock, LockEntry};
168 use crate::model::{
169 Edge, EdgeKind, Graph, Invalid, Node, NodeKey, NodeKind, ReasonCode, TargetKind,
170 };
171
172 fn node(kind: NodeKind, id: &str, hash: &str) -> Node {
173 Node {
174 key: NodeKey {
175 kind,
176 id: id.to_string(),
177 },
178 path: format!("{id}.md"),
179 content: String::new(),
180 hash: hash.to_string(),
181 description: None,
182 }
183 }
184
185 fn lock_entry(from: &str, to: &str, at: &str) -> LockEntry {
186 LockEntry {
187 from: from.to_string(),
188 to: to.to_string(),
189 by: "human".to_string(),
190 reason: None,
191 score: None,
192 at: at.to_string(),
193 }
194 }
195
196 fn clean_graph() -> Graph {
198 let mut graph = Graph::default();
199 let doc_key = NodeKey {
200 kind: NodeKind::Doc,
201 id: "DOC-A".to_string(),
202 };
203 let req_key = NodeKey {
204 kind: NodeKind::Req,
205 id: "FR-001".to_string(),
206 };
207 graph.nodes.insert(
208 doc_key.clone(),
209 node(NodeKind::Doc, "DOC-A", "sha256:doc-current"),
210 );
211 graph.nodes.insert(
212 req_key.clone(),
213 node(NodeKind::Req, "FR-001", "sha256:req-current"),
214 );
215 let edge_id = "doc-req:DOC-A:FR-001".to_string();
216 graph.edges.insert(
217 edge_id.clone(),
218 Edge {
219 id: edge_id,
220 kind: EdgeKind::DocReq,
221 from: doc_key,
222 to: req_key,
223 },
224 );
225 graph
226 }
227
228 #[test]
230 fn edgeを指定すると現在の両端のハッシュがロックに記録される() {
231 let graph = clean_graph();
232 let lock = Lock::default();
233 let result = reconcile(
234 &graph,
235 &lock,
236 ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
237 None,
238 "2026-01-01T00:00:00Z",
239 )
240 .unwrap();
241 let entry = result
242 .edges
243 .get("doc-req:DOC-A:FR-001")
244 .expect("edge should be recorded");
245 assert_eq!(entry.from, "sha256:doc-current");
246 assert_eq!(entry.to, "sha256:req-current");
247 assert_eq!(entry.by, "human");
248 }
249
250 #[test]
252 fn ロックにのみ存在するエッジを指定するとロックから削除される() {
253 let graph = Graph::default(); let mut lock = Lock::default();
255 lock.edges.insert(
256 "doc-req:DOC-A:FR-001".to_string(),
257 lock_entry("sha256:old-doc", "sha256:old-req", "2025-01-01T00:00:00Z"),
258 );
259 let result = reconcile(
260 &graph,
261 &lock,
262 ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
263 None,
264 "2026-01-01T00:00:00Z",
265 )
266 .unwrap();
267 assert!(!result.edges.contains_key("doc-req:DOC-A:FR-001"));
268 }
269
270 #[test]
272 fn ロックにのみ存在するエッジの終端がinvalidな場合はblockedとして拒否される() {
273 let graph = {
274 let mut graph = Graph::default(); graph.invalid.push(Invalid {
276 target_kind: TargetKind::Req,
277 target: "FR-001".to_string(),
278 reason: ReasonCode::DuplicateId,
279 detail: "duplicate id".to_string(),
280 });
281 graph
282 };
283 let mut lock = Lock::default();
284 lock.edges.insert(
285 "doc-req:DOC-A:FR-001".to_string(),
286 lock_entry("sha256:old-doc", "sha256:old-req", "2025-01-01T00:00:00Z"),
287 );
288 let before = lock.clone();
289
290 let result = reconcile(
291 &graph,
292 &lock,
293 ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
294 None,
295 "2026-01-01T00:00:00Z",
296 );
297 assert!(matches!(result, Err(ReconcileError::Blocked(_))));
298 assert_eq!(lock, before);
299 }
300
301 #[test]
303 fn ロックにのみ存在するrustのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
304 let mut graph = Graph::default(); graph.invalid.push(Invalid {
306 target_kind: TargetKind::File,
307 target: "tests/sample.rs".to_string(),
308 reason: ReasonCode::ParseError,
309 detail: "parse error".to_string(),
310 });
311 let mut lock = Lock::default();
312 lock.edges.insert(
313 "test-req:tests/sample::t:FR-001".to_string(),
314 lock_entry("sha256:old-test", "sha256:old-req", "2025-01-01T00:00:00Z"),
315 );
316 let before = lock.clone();
317
318 let result = reconcile(
319 &graph,
320 &lock,
321 ReconcileTarget::Edges(vec!["test-req:tests/sample::t:FR-001".to_string()]),
322 None,
323 "2026-01-01T00:00:00Z",
324 );
325 assert!(matches!(result, Err(ReconcileError::Blocked(_))));
326 assert_eq!(lock, before);
327 }
328
329 #[test]
331 fn ロックにのみ存在するgoのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
332 let mut graph = Graph::default(); graph.invalid.push(Invalid {
334 target_kind: TargetKind::File,
335 target: "internal/auth/login_test.go".to_string(),
336 reason: ReasonCode::ParseError,
337 detail: "parse error".to_string(),
338 });
339 let mut lock = Lock::default();
340 lock.edges.insert(
341 "test-req:internal/auth:TestX:FR-001".to_string(),
342 lock_entry("sha256:old-test", "sha256:old-req", "2025-01-01T00:00:00Z"),
343 );
344 let before = lock.clone();
345
346 let result = reconcile(
347 &graph,
348 &lock,
349 ReconcileTarget::Edges(vec!["test-req:internal/auth:TestX:FR-001".to_string()]),
350 None,
351 "2026-01-01T00:00:00Z",
352 );
353 assert!(matches!(result, Err(ReconcileError::Blocked(_))));
354 assert_eq!(lock, before);
355 }
356
357 #[test]
359 fn ロックにもグラフにも存在しないエッジを指定するとエラーになる() {
360 let graph = Graph::default();
361 let lock = Lock::default();
362 let result = reconcile(
363 &graph,
364 &lock,
365 ReconcileTarget::Edges(vec!["doc-req:NOWHERE:FR-999".to_string()]),
366 None,
367 "2026-01-01T00:00:00Z",
368 );
369 assert!(matches!(result, Err(ReconcileError::UnknownEdge(_))));
370 }
371
372 #[test]
374 fn 対象reqがinvalidに関わるエッジはreconcileを拒否される() {
375 let mut graph = clean_graph();
376 graph.invalid.push(Invalid {
378 target_kind: TargetKind::Req,
379 target: "FR-001".to_string(),
380 reason: ReasonCode::DuplicateId,
381 detail: "duplicate id".to_string(),
382 });
383 let lock = Lock::default();
384 let result = reconcile(
385 &graph,
386 &lock,
387 ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
388 None,
389 "2026-01-01T00:00:00Z",
390 );
391 assert!(matches!(result, Err(ReconcileError::Blocked(_))));
392 }
394
395 #[test]
397 fn allは変更のないエッジを据え置き_新規を追加し_消えたエッジを削除する() {
398 let mut graph = clean_graph();
399 let test_key = NodeKey {
401 kind: NodeKind::Test,
402 id: "src/foo::bar".to_string(),
403 };
404 graph.nodes.insert(
405 test_key.clone(),
406 node(NodeKind::Test, "src/foo::bar", "sha256:test-current"),
407 );
408 let req_key = NodeKey {
409 kind: NodeKind::Req,
410 id: "FR-001".to_string(),
411 };
412 let new_edge_id = "test-req:src/foo::bar:FR-001".to_string();
413 graph.edges.insert(
414 new_edge_id.clone(),
415 Edge {
416 id: new_edge_id.clone(),
417 kind: EdgeKind::TestReq,
418 from: test_key,
419 to: req_key,
420 },
421 );
422
423 let mut lock = Lock::default();
424 lock.edges.insert(
426 "doc-req:DOC-A:FR-001".to_string(),
427 lock_entry(
428 "sha256:doc-current",
429 "sha256:req-current",
430 "2020-01-01T00:00:00Z",
431 ),
432 );
433 lock.edges.insert(
435 "doc-req:DOC-STALE:FR-999".to_string(),
436 lock_entry(
437 "sha256:stale-doc",
438 "sha256:stale-req",
439 "2020-01-01T00:00:00Z",
440 ),
441 );
442
443 let result = reconcile(
444 &graph,
445 &lock,
446 ReconcileTarget::All,
447 None,
448 "2026-01-01T00:00:00Z",
449 )
450 .unwrap();
451
452 let kept = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
454 assert_eq!(kept.at, "2020-01-01T00:00:00Z");
455
456 assert!(!result.edges.contains_key("doc-req:DOC-STALE:FR-999"));
458
459 let added = result.edges.get("test-req:src/foo::bar:FR-001").unwrap();
461 assert_eq!(added.from, "sha256:test-current");
462 assert_eq!(added.to, "sha256:req-current");
463 assert_eq!(added.by, "human");
464 }
465
466 #[test]
468 fn 対象にbrokenまたはinvalidが1件でもあればallは全く変更せず失敗する() {
469 let mut graph = clean_graph();
470 graph.invalid.push(Invalid {
471 target_kind: TargetKind::Req,
472 target: "FR-999".to_string(),
473 reason: ReasonCode::MissingId,
474 detail: "missing id".to_string(),
475 });
476 let mut lock = Lock::default();
477 lock.edges.insert(
478 "doc-req:DOC-A:FR-001".to_string(),
479 lock_entry(
480 "sha256:doc-current",
481 "sha256:req-current",
482 "2020-01-01T00:00:00Z",
483 ),
484 );
485 let before = lock.clone();
486
487 let result = reconcile(
488 &graph,
489 &lock,
490 ReconcileTarget::All,
491 None,
492 "2026-01-01T00:00:00Z",
493 );
494 assert!(matches!(result, Err(ReconcileError::GraphNotClean)));
495 assert_eq!(lock, before);
497 }
498
499 #[test]
501 fn reasonを指定した場合はエントリに記録される() {
502 let graph = clean_graph();
503 let result = reconcile(
504 &graph,
505 &Lock::default(),
506 ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
507 Some("最小長の記述が一致".to_string()),
508 "2026-01-01T00:00:00Z",
509 )
510 .unwrap();
511 let entry = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
512 assert_eq!(entry.by, "human");
513 assert_eq!(entry.reason, Some("最小長の記述が一致".to_string()));
514 }
515
516 #[test]
518 fn reasonを指定しない場合は記録されない() {
519 let graph = clean_graph();
520 let result = reconcile(
521 &graph,
522 &Lock::default(),
523 ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
524 None,
525 "2026-01-01T00:00:00Z",
526 )
527 .unwrap();
528 let entry = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
529 assert_eq!(entry.by, "human");
530 assert_eq!(entry.reason, None);
531 }
532
533 fn assert_blocked_by_parse_error(file: &str, edge: &str) {
534 let mut graph = Graph::default();
535 graph.invalid.push(Invalid {
536 target_kind: TargetKind::File,
537 target: file.to_string(),
538 reason: ReasonCode::ParseError,
539 detail: "parse error".to_string(),
540 });
541 let mut lock = Lock::default();
542 lock.edges.insert(
543 edge.to_string(),
544 lock_entry("sha256:old-test", "sha256:old-req", "2025-01-01T00:00:00Z"),
545 );
546 let before = lock.clone();
547
548 let result = reconcile(
549 &graph,
550 &lock,
551 ReconcileTarget::Edges(vec![edge.to_string()]),
552 None,
553 "2026-01-01T00:00:00Z",
554 );
555 assert!(matches!(result, Err(ReconcileError::Blocked(_))), "{edge}");
556 assert_eq!(lock, before);
557 }
558
559 #[test]
561 fn ロックにのみ存在するcsharpのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
562 assert_blocked_by_parse_error(
563 "tests/AuthTests.cs",
564 "test-req:tests/AuthTests:AuthTests:T:FR-001",
565 );
566 }
567
568 #[test]
570 fn ロックにのみ存在するjavaのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
571 assert_blocked_by_parse_error(
572 "tests/AuthTests.java",
573 "test-req:tests/AuthTests:AuthTests:t:FR-001",
574 );
575 }
576
577 #[test]
579 fn ロックにのみ存在するkotlinのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
580 assert_blocked_by_parse_error(
581 "tests/AuthTest.kt",
582 "test-req:tests/AuthTest:AuthTest:一覧 取得:FR-001",
583 );
584 }
585
586 #[test]
588 fn ロックにのみ存在するtypescriptのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
589 assert_blocked_by_parse_error("src/a.test.ts", "test-req:src/a.test:d:t:FR-001");
590 assert_blocked_by_parse_error("src/a.spec.ts", "test-req:src/a.spec:d:t:FR-001");
591 }
592
593 #[test]
595 fn ロックにのみ存在するtsxのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
596 assert_blocked_by_parse_error("src/a.test.tsx", "test-req:src/a.test:d:t:FR-001");
597 assert_blocked_by_parse_error("src/a.spec.tsx", "test-req:src/a.spec::t:FR-001");
598 }
599
600 #[test]
602 fn ロックにのみ存在するjsのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
603 assert_blocked_by_parse_error("src/a.test.js", "test-req:src/a.test:d:t:FR-001");
604 assert_blocked_by_parse_error("src/a.test.mjs", "test-req:src/a.test:d:t:FR-001");
605 assert_blocked_by_parse_error("src/a.spec.cjs", "test-req:src/a.spec:d:t:FR-001");
606 assert_blocked_by_parse_error("src/a.spec.mts", "test-req:src/a.spec:d:t:FR-001");
607 assert_blocked_by_parse_error("src/a.test.cts", "test-req:src/a.test:d:t:FR-001");
608 assert_blocked_by_parse_error("src/a.spec.jsx", "test-req:src/a.spec:d:t:FR-001");
609 }
610
611 fn desc_graph() -> Graph {
613 let mut graph = clean_graph();
614 let key = NodeKey {
615 kind: NodeKind::Doc,
616 id: "DOC-A".to_string(),
617 };
618 graph.nodes.get_mut(&key).unwrap().description = Some("要約A".to_string());
619 graph.edges.insert(
620 "desc:doc:DOC-A".to_string(),
621 Edge {
622 id: "desc:doc:DOC-A".to_string(),
623 kind: EdgeKind::Desc,
624 from: key.clone(),
625 to: key,
626 },
627 );
628 graph
629 }
630
631 #[test]
633 fn descエッジを指定すると現在の要約と本文のハッシュが記録される() {
634 let graph = desc_graph();
635 let result = reconcile(
636 &graph,
637 &Lock::default(),
638 ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
639 None,
640 "2026-01-01T00:00:00Z",
641 )
642 .unwrap();
643 let entry = result.edges.get("desc:doc:DOC-A").expect("recorded");
644 assert_eq!(entry.from, crate::hash::content_hash("要約A"));
645 assert_eq!(entry.to, "sha256:doc-current");
646 assert_eq!(entry.by, "human");
647 assert!(!result.edges.contains_key("doc-req:DOC-A:FR-001"));
648 }
649
650 #[test]
652 fn descエッジは要約が無ければ空文字列のハッシュを記録する() {
653 let mut graph = desc_graph();
654 let key = NodeKey {
655 kind: NodeKind::Doc,
656 id: "DOC-A".to_string(),
657 };
658 graph.nodes.get_mut(&key).unwrap().description = None;
659 let result = reconcile(
660 &graph,
661 &Lock::default(),
662 ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
663 None,
664 "2026-01-01T00:00:00Z",
665 )
666 .unwrap();
667 assert_eq!(
668 result.edges["desc:doc:DOC-A"].from,
669 crate::hash::content_hash("")
670 );
671 }
672
673 #[test]
675 fn allはdescエッジも記録し変更のないdescは据え置く() {
676 let graph = desc_graph();
677 let mut lock = Lock::default();
678 lock.edges.insert(
679 "desc:doc:DOC-A".to_string(),
680 lock_entry(
681 &crate::hash::content_hash("要約A"),
682 "sha256:doc-current",
683 "2025-01-01T00:00:00Z",
684 ),
685 );
686 let before = lock.edges["desc:doc:DOC-A"].clone();
687 let result = reconcile(
688 &graph,
689 &lock,
690 ReconcileTarget::All,
691 None,
692 "2026-01-01T00:00:00Z",
693 )
694 .unwrap();
695 assert!(result.edges.contains_key("doc-req:DOC-A:FR-001"));
696 assert_eq!(result.edges["desc:doc:DOC-A"], before);
697 }
698
699 #[test]
701 fn allはロックにないdescエッジを新規に記録する() {
702 let result = reconcile(
703 &desc_graph(),
704 &Lock::default(),
705 ReconcileTarget::All,
706 None,
707 "2026-01-01T00:00:00Z",
708 )
709 .unwrap();
710 let entry = result.edges.get("desc:doc:DOC-A").expect("desc recorded");
711 assert_eq!(entry.from, crate::hash::content_hash("要約A"));
712 assert_eq!(entry.to, "sha256:doc-current");
713 assert_eq!(entry.at, "2026-01-01T00:00:00Z");
714 }
715
716 #[test]
718 fn ロックにのみ存在するdescエッジを指定するとロックから削除される() {
719 let mut lock = Lock::default();
720 lock.edges.insert(
721 "desc:doc:DOC-GONE".to_string(),
722 lock_entry("sha256:old", "sha256:old", "2025-01-01T00:00:00Z"),
723 );
724 let result = reconcile(
725 &Graph::default(),
726 &lock,
727 ReconcileTarget::Edges(vec!["desc:doc:DOC-GONE".to_string()]),
728 None,
729 "2026-01-01T00:00:00Z",
730 )
731 .unwrap();
732 assert!(!result.edges.contains_key("desc:doc:DOC-GONE"));
733 }
734
735 #[test]
737 fn ロックにのみ存在するdescエッジのノードがinvalidならblockedになる() {
738 let mut graph = Graph::default();
739 graph.invalid.push(Invalid {
740 target_kind: TargetKind::Doc,
741 target: "DOC-A".to_string(),
742 reason: ReasonCode::DuplicateId,
743 detail: "duplicate id".to_string(),
744 });
745 let mut lock = Lock::default();
746 lock.edges.insert(
747 "desc:doc:DOC-A".to_string(),
748 lock_entry("sha256:old", "sha256:old", "2025-01-01T00:00:00Z"),
749 );
750 let before = lock.clone();
751 let result = reconcile(
752 &graph,
753 &lock,
754 ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
755 None,
756 "2026-01-01T00:00:00Z",
757 );
758 assert!(matches!(result, Err(ReconcileError::Blocked(_))));
759 assert_eq!(lock, before);
760 }
761
762 #[test]
764 fn invalidなdocのdescエッジを指定するとblockedになる() {
765 let mut graph = desc_graph();
766 graph.invalid.push(Invalid {
767 target_kind: TargetKind::Doc,
768 target: "DOC-A".to_string(),
769 reason: ReasonCode::DuplicateId,
770 detail: "duplicate id".to_string(),
771 });
772 let result = reconcile(
773 &graph,
774 &Lock::default(),
775 ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
776 None,
777 "2026-01-01T00:00:00Z",
778 );
779 assert!(matches!(result, Err(ReconcileError::Blocked(_))));
780 }
781
782 #[test]
784 fn リンク切れを持つdocのdescエッジを指定するとblockedになる() {
785 let mut graph = desc_graph();
786 graph.broken.push(crate::model::Broken {
787 from: NodeKey {
788 kind: NodeKind::Doc,
789 id: "DOC-A".to_string(),
790 },
791 target: "../req/missing.md".to_string(),
792 reason: ReasonCode::MissingReq,
793 detail: "missing".to_string(),
794 });
795 let result = reconcile(
796 &graph,
797 &Lock::default(),
798 ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
799 None,
800 "2026-01-01T00:00:00Z",
801 );
802 assert!(matches!(result, Err(ReconcileError::Blocked(_))));
803 }
804
805 #[test]
807 fn parse_edge_idはdescエッジを種別とidに分解する() {
808 assert_eq!(
809 parse_edge_id("desc:doc:DOC-A"),
810 Some(("desc", "DOC-A", "DOC-A"))
811 );
812 assert_eq!(
813 parse_edge_id("doc-req:DOC-A:FR-001"),
814 Some(("doc-req", "DOC-A", "FR-001"))
815 );
816 }
817}