1use crate::model::EdgeKind;
4
5pub const DEFAULT_MAX_BODY_BYTES: usize = 200_000;
6pub const JEV_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
7pub const JEV_API_KEY_ENV: &str = "TYPESAFE_API_KEY";
8pub const JEV_ENDPOINT_OVERRIDE_ENV: &str = "MIWA_JEV_ENDPOINT";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum Verdict {
13 Consistent,
15 Review,
17 Skipped,
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub struct EdgeInput {
23 pub edge_id: String,
24 pub kind: EdgeKind,
25 pub from_text: String,
26 pub req_text: String,
28 pub from_hash: String,
29 pub to_hash: String,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct Judgment {
34 pub edge: String,
35 pub kind: EdgeKind,
36 pub verdict: Verdict,
37 pub score: Option<f64>,
38}
39
40#[derive(Debug, thiserror::Error)]
41pub enum JudgeError {
42 #[error("environment variable {0} is not set")]
43 MissingApiKey(String),
44 #[error("edge {0} is not a judge target")]
45 NotATarget(String),
46 #[error("api.typesafe.ai returned HTTP {status}")]
47 HttpStatus { status: u16 },
48 #[error("api.typesafe.ai request failed: {message}")]
49 Transport { message: String },
50 #[error("invalid Jev response: {0}")]
51 InvalidJevResponse(String),
52}
53
54pub fn select_targets(
56 graph: &crate::model::Graph,
57 findings: &[crate::check::Finding],
58 requested: &[String],
59) -> Result<Vec<EdgeInput>, JudgeError> {
60 use crate::check::FindingKind;
61 use crate::model::TargetKind;
62 use std::collections::BTreeSet;
63
64 use crate::model::NodeKind;
65
66 let invalid: BTreeSet<(TargetKind, &str)> = graph
67 .invalid
68 .iter()
69 .map(|i| (i.target_kind, i.target.as_str()))
70 .collect();
71 let broken_from: BTreeSet<&crate::model::NodeKey> =
72 graph.broken.iter().map(|b| &b.from).collect();
73 let is_invalid = |key: &crate::model::NodeKey| {
74 let kind = match key.kind {
75 NodeKind::Doc => TargetKind::Doc,
76 NodeKind::Req => TargetKind::Req,
77 NodeKind::Test => TargetKind::Test,
78 };
79 invalid.contains(&(kind, key.id.as_str()))
80 };
81
82 let mut targets: BTreeSet<&str> = BTreeSet::new();
83 for f in findings {
84 if matches!(f.kind, FindingKind::Drift | FindingKind::New)
85 && f.target_kind == TargetKind::Edge
86 {
87 targets.insert(f.target.as_str());
88 }
89 }
90
91 let mut candidates = Vec::new();
92 for id in targets {
93 let Some(edge) = graph.edges.get(id) else {
94 continue;
95 };
96 if is_invalid(&edge.from) || is_invalid(&edge.to) || broken_from.contains(&edge.from) {
97 continue;
98 }
99 let (Some(from), Some(to)) = (graph.nodes.get(&edge.from), graph.nodes.get(&edge.to))
100 else {
101 continue;
102 };
103 candidates.push((edge, from, to));
104 }
105
106 if !requested.is_empty() {
107 let wanted: BTreeSet<&str> = requested.iter().map(String::as_str).collect();
108 for id in &wanted {
109 if !candidates.iter().any(|(e, _, _)| e.id == *id) {
110 return Err(JudgeError::NotATarget((*id).to_string()));
111 }
112 }
113 candidates.retain(|(e, _, _)| wanted.contains(e.id.as_str()));
114 }
115
116 Ok(candidates
117 .into_iter()
118 .map(|(edge, from, to)| {
119 let (from_text, req_text) = match edge.kind {
120 EdgeKind::DocReq | EdgeKind::TestReq => (from.content.clone(), to.content.clone()),
121 EdgeKind::Desc => (from.description_text().to_string(), from.content.clone()),
122 };
123 let (from_hash, to_hash) = crate::model::edge_hashes(graph, edge);
124 EdgeInput {
125 edge_id: edge.id.clone(),
126 kind: edge.kind,
127 from_text,
128 req_text,
129 from_hash,
130 to_hash,
131 }
132 })
133 .collect())
134}
135
136pub fn build_jev_request(input: &EdgeInput) -> serde_json::Value {
137 let (state, instructions, true_c, false_c) = match input.kind {
138 EdgeKind::DocReq => (
139 serde_json::json!({"doc": input.from_text, "req": input.req_text}),
140 "`req` は `doc` の該当部分と矛盾せず、その部分を具体化したシステム要件になっているか",
141 "req が扱う事項について doc と矛盾がなく、doc の記述を具体化している",
142 "req が doc と矛盾している、または doc に対応する記述がない",
143 ),
144 EdgeKind::TestReq => (
145 serde_json::json!({"test": input.from_text, "req": input.req_text}),
146 "`test` は `req` に書かれた振る舞いを検証しており、`req` と矛盾する期待値を持たないか",
147 "test が req の振る舞いを検証しており、req と矛盾する期待値がない",
148 "test が req の振る舞いを検証していない、または req と矛盾する期待値を持つ",
149 ),
150 EdgeKind::Desc => (
151 serde_json::json!({"description": input.from_text, "body": input.req_text}),
152 "`description` は `body` の内容を正しく要約しているか",
153 "description が body の主題と内容を正しく表しており、body にない事柄を述べていない",
154 "description が body の内容と食い違っている、または body の主題を表していない",
155 ),
156 };
157 serde_json::json!({
158 "model": "jev-latest",
159 "state": state,
160 "questions": {
161 "consistent": {
162 "type": "noul",
163 "instructions": instructions,
164 "criteria": {"true": true_c, "false": false_c},
165 }
166 }
167 })
168}
169
170pub fn parse_jev_response(body: &str) -> Result<f64, JudgeError> {
171 let value: serde_json::Value = serde_json::from_str(body)
172 .map_err(|e| JudgeError::InvalidJevResponse(format!("not valid JSON: {e}")))?;
173 let score = value
174 .pointer("/answers/consistent/noul")
175 .and_then(|v| v.as_f64())
176 .ok_or_else(|| {
177 JudgeError::InvalidJevResponse("answers.consistent.noul is missing".to_string())
178 })?;
179 if !score.is_finite() || !(0.0..=1.0).contains(&score) {
180 return Err(JudgeError::InvalidJevResponse(format!(
181 "score {score} is outside 0..=1"
182 )));
183 }
184 Ok(if score == 0.0 { 0.0 } else { score })
186}
187
188pub type HttpCall<'a> = &'a dyn Fn(&serde_json::Value) -> Result<String, JudgeError>;
189
190pub fn judge_edges(
192 inputs: &[EdgeInput],
193 threshold: f64,
194 max_body_bytes: usize,
195 jev: HttpCall,
196) -> Result<Vec<Judgment>, JudgeError> {
197 let mut sorted: Vec<&EdgeInput> = inputs.iter().collect();
198 sorted.sort_by(|a, b| a.edge_id.cmp(&b.edge_id));
199 let mut out = Vec::new();
200 for input in sorted {
201 let (verdict, score) = if input.from_text.len() + input.req_text.len() > max_body_bytes {
202 (Verdict::Skipped, None)
203 } else {
204 let body = jev(&build_jev_request(input))?;
205 let score = parse_jev_response(&body)?;
206 let verdict = if score >= threshold {
207 Verdict::Consistent
208 } else {
209 Verdict::Review
210 };
211 (verdict, Some(score))
212 };
213 out.push(Judgment {
214 edge: input.edge_id.clone(),
215 kind: input.kind,
216 verdict,
217 score,
218 });
219 }
220 Ok(out)
221}
222
223pub fn apply_auto_reconcile(
225 lock: &crate::lock::Lock,
226 inputs: &[EdgeInput],
227 judgments: &[Judgment],
228 now: &str,
229) -> crate::lock::Lock {
230 let mut new_lock = lock.clone();
231 for j in judgments
232 .iter()
233 .filter(|j| j.verdict == Verdict::Consistent)
234 {
235 if let Some(input) = inputs.iter().find(|i| i.edge_id == j.edge) {
236 new_lock.edges.insert(
237 j.edge.clone(),
238 crate::lock::LockEntry {
239 from: input.from_hash.clone(),
240 to: input.to_hash.clone(),
241 by: "jev".to_string(),
242 reason: None,
243 score: j.score,
244 at: now.to_string(),
245 },
246 );
247 }
248 }
249 new_lock
250}
251
252pub fn render_text(judgments: &[Judgment]) -> String {
254 if judgments.is_empty() {
255 return "対象なし\n".to_string();
256 }
257 let mut out = String::new();
258 for j in judgments {
259 let verdict = match j.verdict {
260 Verdict::Consistent => "consistent",
261 Verdict::Review => "review",
262 Verdict::Skipped => "skipped",
263 };
264 let score = j.score.map_or("-".to_string(), |s| format!("{s:.2}"));
265 out.push_str(&format!("{verdict} {} score={score}\n", j.edge));
266 }
267 out
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::check::check;
274 use crate::lock::{Lock, LockEntry};
275 use crate::model::{
276 Broken, Edge, Graph, Invalid, Node, NodeKey, NodeKind, ReasonCode, TargetKind,
277 };
278 use std::cell::Cell;
279
280 fn add_node(
281 graph: &mut Graph,
282 kind: NodeKind,
283 id: &str,
284 path: &str,
285 content: &str,
286 hash: &str,
287 ) {
288 let key = NodeKey {
289 kind,
290 id: id.to_string(),
291 };
292 graph.nodes.insert(
293 key.clone(),
294 Node {
295 key,
296 path: path.to_string(),
297 content: content.to_string(),
298 hash: hash.to_string(),
299 description: None,
300 },
301 );
302 }
303
304 fn add_edge(graph: &mut Graph, kind: EdgeKind, from: &str, to: &str) -> String {
305 let id = crate::model::edge_id(kind, from, to);
306 let from_kind = match kind {
307 EdgeKind::DocReq => NodeKind::Doc,
308 EdgeKind::TestReq => NodeKind::Test,
309 EdgeKind::Desc => unreachable!("use add_desc_edge"),
310 };
311 graph.edges.insert(
312 id.clone(),
313 Edge {
314 id: id.clone(),
315 kind,
316 from: NodeKey {
317 kind: from_kind,
318 id: from.to_string(),
319 },
320 to: NodeKey {
321 kind: NodeKind::Req,
322 id: to.to_string(),
323 },
324 },
325 );
326 id
327 }
328
329 fn lock_entry(from: &str, to: &str) -> LockEntry {
330 LockEntry {
331 from: from.to_string(),
332 to: to.to_string(),
333 by: "human".to_string(),
334 reason: Some("既存の合意".to_string()),
335 score: None,
336 at: "2026-01-01T00:00:00Z".to_string(),
337 }
338 }
339
340 fn mixed_fixture() -> (Graph, Lock) {
342 let mut graph = Graph::default();
343 add_node(
345 &mut graph,
346 NodeKind::Doc,
347 "DOC-A",
348 "docs/a.md",
349 "doc a",
350 "sha256:da",
351 );
352 add_node(
353 &mut graph,
354 NodeKind::Req,
355 "FR-001",
356 "req/fr1.md",
357 "req 1",
358 "sha256:r1",
359 );
360 add_edge(&mut graph, EdgeKind::DocReq, "DOC-A", "FR-001");
361 add_node(
363 &mut graph,
364 NodeKind::Doc,
365 "DOC-B",
366 "docs/b.md",
367 "doc b",
368 "sha256:db-new",
369 );
370 add_node(
371 &mut graph,
372 NodeKind::Req,
373 "FR-002",
374 "req/fr2.md",
375 "req 2",
376 "sha256:r2",
377 );
378 add_edge(&mut graph, EdgeKind::DocReq, "DOC-B", "FR-002");
379 add_node(
381 &mut graph,
382 NodeKind::Doc,
383 "DOC-C",
384 "docs/c.md",
385 "doc c",
386 "sha256:dc",
387 );
388 add_node(
389 &mut graph,
390 NodeKind::Req,
391 "FR-003",
392 "req/fr3.md",
393 "req 3",
394 "sha256:r3",
395 );
396 add_edge(&mut graph, EdgeKind::DocReq, "DOC-C", "FR-003");
397 add_node(
399 &mut graph,
400 NodeKind::Test,
401 "tests/t::checks",
402 "tests/t.rs",
403 "fn checks() {}",
404 "sha256:t1",
405 );
406 add_edge(&mut graph, EdgeKind::TestReq, "tests/t::checks", "FR-001");
407 add_node(
409 &mut graph,
410 NodeKind::Doc,
411 "DOC-E",
412 "docs/e.md",
413 "doc e",
414 "sha256:de",
415 );
416 add_node(
417 &mut graph,
418 NodeKind::Req,
419 "FR-005",
420 "req/fr5.md",
421 "req 5",
422 "sha256:r5",
423 );
424 add_edge(&mut graph, EdgeKind::DocReq, "DOC-E", "FR-005");
425 graph.broken.push(Broken {
426 from: NodeKey {
427 kind: NodeKind::Doc,
428 id: "DOC-E".to_string(),
429 },
430 target: "../req/missing.md".to_string(),
431 reason: ReasonCode::MissingReq,
432 detail: "missing".to_string(),
433 });
434 add_node(
436 &mut graph,
437 NodeKind::Doc,
438 "DOC-D",
439 "docs/d.md",
440 "doc d",
441 "sha256:dd",
442 );
443 add_node(
444 &mut graph,
445 NodeKind::Req,
446 "FR-004",
447 "req/fr4.md",
448 "req 4",
449 "sha256:r4",
450 );
451 add_edge(&mut graph, EdgeKind::DocReq, "DOC-D", "FR-004");
452 graph.invalid.push(Invalid {
453 target_kind: TargetKind::Req,
454 target: "FR-004".to_string(),
455 reason: ReasonCode::DuplicateId,
456 detail: "dup".to_string(),
457 });
458
459 let mut lock = Lock::default();
460 lock.edges.insert(
461 "doc-req:DOC-B:FR-002".to_string(),
462 lock_entry("sha256:db-old", "sha256:r2"),
463 );
464 lock.edges.insert(
465 "doc-req:DOC-C:FR-003".to_string(),
466 lock_entry("sha256:dc", "sha256:r3"),
467 );
468 lock.edges.insert(
470 "doc-req:DOC-GONE:FR-009".to_string(),
471 lock_entry("sha256:x", "sha256:y"),
472 );
473 (graph, lock)
474 }
475
476 fn select(requested: &[&str]) -> Result<Vec<EdgeInput>, JudgeError> {
477 let (graph, lock) = mixed_fixture();
478 let findings = check(&graph, &lock);
479 let requested: Vec<String> = requested.iter().map(|s| s.to_string()).collect();
480 select_targets(&graph, &findings, &requested)
481 }
482
483 fn input(id: &str, kind: EdgeKind, from_text: &str, req_text: &str) -> EdgeInput {
484 EdgeInput {
485 edge_id: id.to_string(),
486 kind,
487 from_text: from_text.to_string(),
488 req_text: req_text.to_string(),
489 from_hash: "sha256:from".to_string(),
490 to_hash: "sha256:to".to_string(),
491 }
492 }
493
494 fn jev_body(score: f64) -> String {
495 format!(
496 r#"{{"model":"jev-1.13.0","answers":{{"consistent":{{"type":"noul","noul":{score}}}}},"usage":{{"input_tokens":1,"output_tokens":1}}}}"#
497 )
498 }
499
500 fn judgment(edge: &str, verdict: Verdict, score: Option<f64>) -> Judgment {
501 Judgment {
502 edge: edge.to_string(),
503 kind: EdgeKind::DocReq,
504 verdict,
505 score,
506 }
507 }
508
509 #[test]
511 fn select_targetsはdriftとnewのエッジだけをエッジid昇順で返す() {
512 let targets = select(&[]).unwrap();
513 let ids: Vec<&str> = targets.iter().map(|t| t.edge_id.as_str()).collect();
514 assert_eq!(
515 ids,
516 vec![
517 "doc-req:DOC-A:FR-001",
518 "doc-req:DOC-B:FR-002",
519 "test-req:tests/t::checks:FR-001",
520 ]
521 );
522 }
523
524 #[test]
526 fn select_targetsはエッジの両端のパス_本文_ハッシュをedge_inputに詰める() {
527 let targets = select(&["doc-req:DOC-B:FR-002"]).unwrap();
528 assert_eq!(targets.len(), 1);
529 assert_eq!(
530 targets[0],
531 EdgeInput {
532 edge_id: "doc-req:DOC-B:FR-002".to_string(),
533 kind: EdgeKind::DocReq,
534 from_text: "doc b".to_string(),
535 req_text: "req 2".to_string(),
536 from_hash: "sha256:db-new".to_string(),
537 to_hash: "sha256:r2".to_string(),
538 }
539 );
540 }
541
542 #[test]
544 fn select_targetsはbrokenやinvalidに関わるエッジとremovedを対象にしない() {
545 let targets = select(&[]).unwrap();
546 let ids: Vec<&str> = targets.iter().map(|t| t.edge_id.as_str()).collect();
547 assert!(
548 !ids.contains(&"doc-req:DOC-E:FR-005"),
549 "broken 関連: {ids:?}"
550 );
551 assert!(
552 !ids.contains(&"doc-req:DOC-D:FR-004"),
553 "invalid 関連: {ids:?}"
554 );
555 assert!(
556 !ids.contains(&"doc-req:DOC-GONE:FR-009"),
557 "removed: {ids:?}"
558 );
559 assert!(
560 !ids.contains(&"doc-req:DOC-C:FR-003"),
561 "finding なし: {ids:?}"
562 );
563 }
564
565 #[test]
567 fn select_targetsはedge指定で絞り込み重複指定は1つにまとめる() {
568 let targets = select(&[
569 "test-req:tests/t::checks:FR-001",
570 "doc-req:DOC-A:FR-001",
571 "doc-req:DOC-A:FR-001",
572 ])
573 .unwrap();
574 let ids: Vec<&str> = targets.iter().map(|t| t.edge_id.as_str()).collect();
575 assert_eq!(
576 ids,
577 vec!["doc-req:DOC-A:FR-001", "test-req:tests/t::checks:FR-001"]
578 );
579 }
580
581 #[test]
583 fn select_targetsは対象外のidを指定するとnot_a_targetになる() {
584 for bad in [
585 "doc-req:DOC-C:FR-003",
586 "doc-req:DOC-GONE:FR-009",
587 "doc-req:DOC-E:FR-005",
588 "doc-req:DOC-D:FR-004",
589 "doc-req:NOPE:FR-404",
590 ] {
591 let err = select(&["doc-req:DOC-A:FR-001", bad]).unwrap_err();
592 assert!(
593 matches!(&err, JudgeError::NotATarget(id) if id == bad),
594 "{bad} should be NotATarget, got {err:?}"
595 );
596 }
597 }
598
599 #[test]
601 fn build_jev_requestはdoc_reqエッジのstateとquestionsを組み立てる() {
602 let i = input(
603 "doc-req:DOC-A:FR-001",
604 EdgeKind::DocReq,
605 "DOC 本文",
606 "REQ 本文",
607 );
608 let v = build_jev_request(&i);
609 assert_eq!(v["model"], "jev-latest");
610 assert_eq!(v["state"]["doc"], "DOC 本文");
611 assert_eq!(v["state"]["req"], "REQ 本文");
612 assert!(v["state"].get("test").is_none());
613 let questions = v["questions"].as_object().unwrap();
614 assert_eq!(questions.len(), 1);
615 let q = &v["questions"]["consistent"];
616 assert_eq!(q["type"], "noul");
617 let instructions = q["instructions"].as_str().unwrap();
618 assert!(instructions.contains("矛盾せず"), "{instructions}");
619 assert!(instructions.contains("具体化"), "{instructions}");
620 assert!(q["criteria"]["true"].as_str().unwrap().contains("具体化"));
621 assert!(q["criteria"]["false"].as_str().unwrap().contains("矛盾"));
622 }
623
624 #[test]
626 fn build_jev_requestはtest_reqエッジのstateとquestionsを組み立てる() {
627 let i = input(
628 "test-req:tests/t::checks:FR-001",
629 EdgeKind::TestReq,
630 "fn checks() {}",
631 "REQ 本文",
632 );
633 let v = build_jev_request(&i);
634 assert_eq!(v["model"], "jev-latest");
635 assert_eq!(v["state"]["test"], "fn checks() {}");
636 assert_eq!(v["state"]["req"], "REQ 本文");
637 assert!(v["state"].get("doc").is_none());
638 assert_eq!(v["questions"].as_object().unwrap().len(), 1);
639 let q = &v["questions"]["consistent"];
640 assert_eq!(q["type"], "noul");
641 let instructions = q["instructions"].as_str().unwrap();
642 assert!(instructions.contains("検証して"), "{instructions}");
643 assert!(q["criteria"]["true"].as_str().unwrap().contains("検証して"));
644 assert!(q["criteria"]["false"]
645 .as_str()
646 .unwrap()
647 .contains("検証していない"));
648 }
649
650 #[test]
652 fn parse_jev_responseは正常な応答からスコアを取り出す() {
653 let body = r#"{"model":"jev-1.13.0","answers":{"consistent":{"type":"noul","noul":0.95}},"usage":{"input_tokens":1,"output_tokens":1}}"#;
654 assert_eq!(parse_jev_response(body).unwrap(), 0.95);
655 }
656
657 #[test]
659 fn parse_jev_responseは0と1の境界値を受け入れる() {
660 assert_eq!(parse_jev_response(&jev_body(0.0)).unwrap(), 0.0);
661 assert_eq!(parse_jev_response(&jev_body(1.0)).unwrap(), 1.0);
662 }
663
664 #[test]
666 fn parse_jev_responseは範囲外_欠落_非数値_壊れたjsonを拒否する() {
667 let bad = [
668 jev_body(-0.1),
669 jev_body(1.5),
670 r#"{"answers":{}}"#.to_string(),
671 r#"{"answers":{"consistent":{"type":"noul"}}}"#.to_string(),
672 r#"{"answers":{"consistent":{"type":"noul","noul":"0.9"}}}"#.to_string(),
673 r#"{"answers":{"consistent":{"type":"noul","noul":null}}}"#.to_string(),
674 "{ not json".to_string(),
675 String::new(),
676 ];
677 for body in bad {
678 let err = parse_jev_response(&body).unwrap_err();
679 assert!(
680 matches!(err, JudgeError::InvalidJevResponse(_)),
681 "{body}: {err:?}"
682 );
683 }
684 }
685
686 fn judge_one(score: f64, threshold: f64) -> Verdict {
687 let inputs = vec![input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "d", "r")];
688 let body = jev_body(score);
689 let jev = move |_: &serde_json::Value| Ok(body.clone());
690 let result = judge_edges(&inputs, threshold, DEFAULT_MAX_BODY_BYTES, &jev).unwrap();
691 assert_eq!(result.len(), 1);
692 assert_eq!(result[0].score, Some(score));
693 result[0].verdict
694 }
695
696 #[test]
698 fn スコアが閾値ちょうどならconsistentになる() {
699 assert_eq!(judge_one(0.9, 0.9), Verdict::Consistent);
700 }
701
702 #[test]
704 fn スコアが閾値未満ならreviewになる() {
705 assert_eq!(judge_one(0.89, 0.9), Verdict::Review);
706 }
707
708 #[test]
710 fn judge_edgesはエッジid昇順で判定結果を返す() {
711 let inputs = vec![
712 input("doc-req:DOC-B:FR-002", EdgeKind::DocReq, "dB", "r"),
713 input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "dA", "r"),
714 ];
715 let scores = Cell::new(0usize);
716 let order = std::cell::RefCell::new(Vec::new());
717 let jev = |v: &serde_json::Value| {
718 order
719 .borrow_mut()
720 .push(v["state"]["doc"].as_str().unwrap().to_string());
721 let n = scores.get();
722 scores.set(n + 1);
723 Ok(jev_body(if n == 0 { 0.95 } else { 0.3 }))
724 };
725 let result = judge_edges(&inputs, 0.9, DEFAULT_MAX_BODY_BYTES, &jev).unwrap();
726 assert_eq!(*order.borrow(), vec!["dA".to_string(), "dB".to_string()]);
727 assert_eq!(result[0].edge, "doc-req:DOC-A:FR-001");
728 assert_eq!(result[0].verdict, Verdict::Consistent);
729 assert_eq!(result[1].edge, "doc-req:DOC-B:FR-002");
730 assert_eq!(result[1].verdict, Verdict::Review);
731 }
732
733 #[test]
735 fn jevがhttpエラーを返すとjudge_edgesはエラーになる() {
736 let inputs = vec![input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "d", "r")];
737 let jev = |_: &serde_json::Value| Err(JudgeError::HttpStatus { status: 503 });
738 let err = judge_edges(&inputs, 0.9, DEFAULT_MAX_BODY_BYTES, &jev).unwrap_err();
739 assert!(
740 matches!(err, JudgeError::HttpStatus { status: 503 }),
741 "{err:?}"
742 );
743 }
744
745 #[test]
747 fn 本文合計が上限を超えるとjevを呼ばずskippedになる() {
748 let big = "a".repeat(DEFAULT_MAX_BODY_BYTES);
749 let inputs = vec![input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, &big, "b")];
750 let calls = Cell::new(0usize);
751 let jev = |_: &serde_json::Value| {
752 calls.set(calls.get() + 1);
753 Ok(jev_body(0.95))
754 };
755 let result = judge_edges(&inputs, 0.9, DEFAULT_MAX_BODY_BYTES, &jev).unwrap();
756 assert_eq!(result[0].verdict, Verdict::Skipped);
757 assert_eq!(result[0].score, None);
758 assert_eq!(calls.get(), 0);
759 }
760
761 #[test]
763 fn 本文合計が上限ちょうどならjevを呼ぶ() {
764 let big = "a".repeat(DEFAULT_MAX_BODY_BYTES - 3);
765 let inputs = vec![input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, &big, "あ")];
767 let calls = Cell::new(0usize);
768 let jev = |_: &serde_json::Value| {
769 calls.set(calls.get() + 1);
770 Ok(jev_body(0.95))
771 };
772 let result = judge_edges(&inputs, 0.9, DEFAULT_MAX_BODY_BYTES, &jev).unwrap();
773 assert_eq!(result[0].verdict, Verdict::Consistent);
774 assert_eq!(calls.get(), 1);
775 }
776
777 #[test]
779 fn 上限は引数で指定でき小さい上限なら通常サイズでもskippedになる() {
780 let inputs = vec![input(
781 "doc-req:DOC-A:FR-001",
782 EdgeKind::DocReq,
783 "aaaaaa",
784 "bbbbb",
785 )];
786 let calls = Cell::new(0usize);
787 let jev = |_: &serde_json::Value| {
788 calls.set(calls.get() + 1);
789 Ok(jev_body(0.95))
790 };
791 let result = judge_edges(&inputs, 0.9, 10, &jev).unwrap();
792 assert_eq!(result[0].verdict, Verdict::Skipped);
793 assert_eq!(calls.get(), 0);
794 let result = judge_edges(&inputs, 0.9, 11, &jev).unwrap();
795 assert_eq!(result[0].verdict, Verdict::Consistent);
796 assert_eq!(calls.get(), 1);
797 }
798
799 #[test]
801 fn render_textは1エッジ1行でverdictとスコアを小数2桁で表示する() {
802 let judgments = vec![
803 judgment("doc-req:DOC-A:FR-001", Verdict::Consistent, Some(0.94)),
804 judgment("doc-req:DOC-B:FR-002", Verdict::Review, Some(0.31)),
805 judgment("doc-req:DOC-C:FR-003", Verdict::Skipped, None),
806 ];
807 assert_eq!(
808 render_text(&judgments),
809 "consistent doc-req:DOC-A:FR-001 score=0.94\n\
810 review doc-req:DOC-B:FR-002 score=0.31\n\
811 skipped doc-req:DOC-C:FR-003 score=-\n"
812 );
813 }
814
815 #[test]
817 fn render_textはスコアを小数第2位に丸めて表示する() {
818 let judgments = vec![
819 judgment("doc-req:DOC-A:FR-001", Verdict::Consistent, Some(1.0)),
820 judgment("doc-req:DOC-B:FR-002", Verdict::Review, Some(0.0)),
821 judgment("doc-req:DOC-C:FR-003", Verdict::Review, Some(0.3)),
822 ];
823 assert_eq!(
824 render_text(&judgments),
825 "consistent doc-req:DOC-A:FR-001 score=1.00\n\
826 review doc-req:DOC-B:FR-002 score=0.00\n\
827 review doc-req:DOC-C:FR-003 score=0.30\n"
828 );
829 }
830
831 #[test]
833 fn render_textは0件なら対象なしを返す() {
834 assert_eq!(render_text(&[]), "対象なし\n");
835 }
836
837 fn reconcile_fixture() -> (Lock, Vec<EdgeInput>, Vec<Judgment>) {
838 let mut a = input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "a", "r");
839 a.from_hash = "sha256:a-from".to_string();
840 a.to_hash = "sha256:a-to".to_string();
841 let mut b = input("doc-req:DOC-B:FR-002", EdgeKind::DocReq, "b", "r");
842 b.from_hash = "sha256:b-from".to_string();
843 b.to_hash = "sha256:b-to".to_string();
844 let mut c = input("doc-req:DOC-C:FR-003", EdgeKind::DocReq, "c", "r");
845 c.from_hash = "sha256:c-from".to_string();
846 c.to_hash = "sha256:c-to".to_string();
847 let judgments = vec![
848 judgment("doc-req:DOC-A:FR-001", Verdict::Consistent, Some(0.95)),
849 judgment("doc-req:DOC-B:FR-002", Verdict::Review, Some(0.3)),
850 judgment("doc-req:DOC-C:FR-003", Verdict::Skipped, None),
851 ];
852 let mut lock = Lock::default();
853 lock.edges.insert(
854 "doc-req:DOC-B:FR-002".to_string(),
855 lock_entry("sha256:old-b", "sha256:old-b-to"),
856 );
857 (lock, vec![a, b, c], judgments)
858 }
859
860 #[test]
862 fn apply_auto_reconcileはconsistentのエッジだけをjevの合意として記録する() {
863 let (lock, inputs, judgments) = reconcile_fixture();
864 let new_lock = apply_auto_reconcile(&lock, &inputs, &judgments, "2026-02-03T04:05:06Z");
865 let e = &new_lock.edges["doc-req:DOC-A:FR-001"];
866 assert_eq!(e.by, "jev");
867 assert_eq!(e.score, Some(0.95));
868 assert_eq!(e.reason, None);
869 assert_eq!(e.at, "2026-02-03T04:05:06Z");
870 assert_eq!(e.from, "sha256:a-from");
871 assert_eq!(e.to, "sha256:a-to");
872 }
873
874 #[test]
876 fn apply_auto_reconcileはreviewとskippedを記録せず既存エントリを変えない() {
877 let (lock, inputs, judgments) = reconcile_fixture();
878 let new_lock = apply_auto_reconcile(&lock, &inputs, &judgments, "2026-02-03T04:05:06Z");
879 assert!(!new_lock.edges.contains_key("doc-req:DOC-C:FR-003"));
880 assert_eq!(
881 new_lock.edges["doc-req:DOC-B:FR-002"],
882 lock.edges["doc-req:DOC-B:FR-002"]
883 );
884 assert_eq!(new_lock.edges.len(), 2);
885 }
886
887 #[test]
889 fn apply_auto_reconcileは元のロックを変更しない() {
890 let (lock, inputs, judgments) = reconcile_fixture();
891 let before = lock.clone();
892 let _ = apply_auto_reconcile(&lock, &inputs, &judgments, "2026-02-03T04:05:06Z");
893 assert_eq!(lock, before);
894 }
895
896 #[test]
898 fn parse_jev_responseは負のゼロを0に正規化する() {
899 let score = parse_jev_response(&jev_body(-0.0)).unwrap();
900 assert!(score.is_sign_positive(), "{score}");
901 let out = render_text(&[judgment(
902 "doc-req:DOC-A:FR-001",
903 Verdict::Review,
904 Some(score),
905 )]);
906 assert_eq!(out, "review doc-req:DOC-A:FR-001 score=0.00\n");
907 }
908
909 #[test]
911 fn jevの質問文言はreqの文言と完全一致する() {
912 let d = build_jev_request(&input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "d", "r"));
913 let q = &d["questions"]["consistent"];
914 assert_eq!(
915 q["instructions"],
916 "`req` は `doc` の該当部分と矛盾せず、その部分を具体化したシステム要件になっているか"
917 );
918 assert_eq!(
919 q["criteria"]["true"],
920 "req が扱う事項について doc と矛盾がなく、doc の記述を具体化している"
921 );
922 assert_eq!(
923 q["criteria"]["false"],
924 "req が doc と矛盾している、または doc に対応する記述がない"
925 );
926 let t = build_jev_request(&input("test-req:t:FR-001", EdgeKind::TestReq, "t", "r"));
927 let q = &t["questions"]["consistent"];
928 assert_eq!(
929 q["instructions"],
930 "`test` は `req` に書かれた振る舞いを検証しており、`req` と矛盾する期待値を持たないか"
931 );
932 assert_eq!(
933 q["criteria"]["true"],
934 "test が req の振る舞いを検証しており、req と矛盾する期待値がない"
935 );
936 assert_eq!(
937 q["criteria"]["false"],
938 "test が req の振る舞いを検証していない、または req と矛盾する期待値を持つ"
939 );
940 }
941
942 #[test]
944 fn select_targetsはinvalidの種別と端点の種別が一致するときだけ除外する() {
945 let build = |invalid_kind: TargetKind| {
946 let mut graph = Graph::default();
947 add_node(
948 &mut graph,
949 NodeKind::Doc,
950 "X",
951 "docs/x.md",
952 "doc",
953 "sha256:d",
954 );
955 add_node(
956 &mut graph,
957 NodeKind::Req,
958 "X",
959 "req/x.md",
960 "req",
961 "sha256:r",
962 );
963 add_node(
964 &mut graph,
965 NodeKind::Test,
966 "tests/t::c",
967 "tests/t.rs",
968 "fn c() {}",
969 "sha256:t",
970 );
971 let id = add_edge(&mut graph, EdgeKind::TestReq, "tests/t::c", "X");
972 graph.invalid.push(Invalid {
973 target_kind: invalid_kind,
974 target: "X".to_string(),
975 reason: ReasonCode::DuplicateId,
976 detail: "dup".to_string(),
977 });
978 let findings = check(&graph, &Lock::default());
979 (select_targets(&graph, &findings, &[]).unwrap(), id)
980 };
981 let (targets, id) = build(TargetKind::Doc);
982 assert_eq!(targets.len(), 1);
983 assert_eq!(targets[0].edge_id, id);
984 let (targets, _) = build(TargetKind::Req);
985 assert!(targets.is_empty(), "{targets:?}");
986 }
987
988 fn add_desc_node(
990 graph: &mut Graph,
991 kind: NodeKind,
992 id: &str,
993 description: Option<&str>,
994 content: &str,
995 hash: &str,
996 ) -> String {
997 add_node(graph, kind, id, &format!("docs/{id}.md"), content, hash);
998 let key = NodeKey {
999 kind,
1000 id: id.to_string(),
1001 };
1002 graph.nodes.get_mut(&key).unwrap().description = description.map(str::to_string);
1003 add_desc_edge(graph, kind, id)
1004 }
1005
1006 fn add_desc_edge(graph: &mut Graph, kind: NodeKind, id: &str) -> String {
1007 let key = NodeKey {
1008 kind,
1009 id: id.to_string(),
1010 };
1011 let edge_id = crate::model::desc_edge_id(&key);
1012 graph.edges.insert(
1013 edge_id.clone(),
1014 Edge {
1015 id: edge_id.clone(),
1016 kind: EdgeKind::Desc,
1017 from: key.clone(),
1018 to: key,
1019 },
1020 );
1021 edge_id
1022 }
1023
1024 fn select_in(
1025 graph: &Graph,
1026 lock: &Lock,
1027 requested: &[&str],
1028 ) -> Result<Vec<EdgeInput>, JudgeError> {
1029 let findings = check(graph, lock);
1030 let requested: Vec<String> = requested.iter().map(|s| s.to_string()).collect();
1031 select_targets(graph, &findings, &requested)
1032 }
1033
1034 #[test]
1036 fn select_targetsはdescのdriftを対象にし要約と本文を詰める() {
1037 let mut graph = Graph::default();
1038 add_desc_node(
1039 &mut graph,
1040 NodeKind::Doc,
1041 "DOC-A",
1042 Some("新しい要約"),
1043 "doc 本文",
1044 "sha256:body",
1045 );
1046 let mut lock = Lock::default();
1047 lock.edges.insert(
1048 "desc:doc:DOC-A".to_string(),
1049 lock_entry(&crate::hash::content_hash("古い要約"), "sha256:body"),
1050 );
1051 let targets = select_in(&graph, &lock, &[]).unwrap();
1052 assert_eq!(
1053 targets,
1054 vec![EdgeInput {
1055 edge_id: "desc:doc:DOC-A".to_string(),
1056 kind: EdgeKind::Desc,
1057 from_text: "新しい要約".to_string(),
1058 req_text: "doc 本文".to_string(),
1059 from_hash: crate::hash::content_hash("新しい要約"),
1060 to_hash: "sha256:body".to_string(),
1061 }]
1062 );
1063 }
1064
1065 #[test]
1067 fn select_targetsはdescのnewを対象にし要約が無ければfrom_textを空文字列にする() {
1068 let mut graph = Graph::default();
1069 add_desc_node(
1070 &mut graph,
1071 NodeKind::Req,
1072 "FR-001",
1073 None,
1074 "req 本文",
1075 "sha256:r",
1076 );
1077 let targets = select_in(&graph, &Lock::default(), &[]).unwrap();
1078 assert_eq!(targets.len(), 1, "{targets:?}");
1079 assert_eq!(targets[0].edge_id, "desc:req:FR-001");
1080 assert_eq!(targets[0].kind, EdgeKind::Desc);
1081 assert_eq!(targets[0].from_text, "");
1082 assert_eq!(targets[0].req_text, "req 本文");
1083 assert_eq!(targets[0].from_hash, crate::hash::content_hash(""));
1084 assert_eq!(targets[0].to_hash, "sha256:r");
1085 }
1086
1087 #[test]
1089 fn select_targetsはdescエッジもエッジid昇順に並べ_descがdoc_reqより先になる() {
1090 let (mut graph, lock) = mixed_fixture();
1091 add_desc_node(
1092 &mut graph,
1093 NodeKind::Doc,
1094 "DOC-Z",
1095 Some("要約"),
1096 "doc z",
1097 "sha256:dz",
1098 );
1099 let findings = check(&graph, &lock);
1100 let targets = select_targets(&graph, &findings, &[]).unwrap();
1101 let ids: Vec<&str> = targets.iter().map(|t| t.edge_id.as_str()).collect();
1102 assert_eq!(
1103 ids,
1104 vec![
1105 "desc:doc:DOC-Z",
1106 "doc-req:DOC-A:FR-001",
1107 "doc-req:DOC-B:FR-002",
1108 "test-req:tests/t::checks:FR-001",
1109 ]
1110 );
1111 }
1112
1113 #[test]
1115 fn select_targetsはedge指定でdescエッジを選べる() {
1116 let mut graph = Graph::default();
1117 add_desc_node(
1118 &mut graph,
1119 NodeKind::Doc,
1120 "DOC-A",
1121 Some("要約"),
1122 "本文",
1123 "sha256:a",
1124 );
1125 add_desc_node(
1126 &mut graph,
1127 NodeKind::Doc,
1128 "DOC-B",
1129 Some("要約"),
1130 "本文",
1131 "sha256:b",
1132 );
1133 let targets = select_in(
1134 &graph,
1135 &Lock::default(),
1136 &["desc:doc:DOC-B", "desc:doc:DOC-B"],
1137 )
1138 .unwrap();
1139 assert_eq!(targets.len(), 1);
1140 assert_eq!(targets[0].edge_id, "desc:doc:DOC-B");
1141 }
1142
1143 #[test]
1145 fn select_targetsはfindingのないdescエッジを対象にせずedge指定はnot_a_targetになる() {
1146 let mut graph = Graph::default();
1147 add_desc_node(
1148 &mut graph,
1149 NodeKind::Doc,
1150 "DOC-A",
1151 Some("要約"),
1152 "本文",
1153 "sha256:a",
1154 );
1155 let mut lock = Lock::default();
1156 lock.edges.insert(
1157 "desc:doc:DOC-A".to_string(),
1158 lock_entry(&crate::hash::content_hash("要約"), "sha256:a"),
1159 );
1160 assert!(select_in(&graph, &lock, &[]).unwrap().is_empty());
1161 assert!(matches!(
1162 select_in(&graph, &lock, &["desc:doc:DOC-A"]),
1163 Err(JudgeError::NotATarget(_))
1164 ));
1165 }
1166
1167 #[test]
1169 fn select_targetsはロックにのみ存在するdescエッジのedge指定をnot_a_targetにする() {
1170 let mut lock = Lock::default();
1171 lock.edges.insert(
1172 "desc:doc:DOC-X".to_string(),
1173 lock_entry(&crate::hash::content_hash("要約"), "sha256:x"),
1174 );
1175 assert!(matches!(
1176 select_in(&Graph::default(), &lock, &["desc:doc:DOC-X"]),
1177 Err(JudgeError::NotATarget(_))
1178 ));
1179 }
1180
1181 #[test]
1183 fn select_targetsはリンク切れを持つdocのdescエッジを対象にしない() {
1184 let mut graph = Graph::default();
1185 add_desc_node(
1186 &mut graph,
1187 NodeKind::Doc,
1188 "DOC-A",
1189 Some("要約"),
1190 "本文",
1191 "sha256:a",
1192 );
1193 graph.broken.push(Broken {
1194 from: NodeKey {
1195 kind: NodeKind::Doc,
1196 id: "DOC-A".to_string(),
1197 },
1198 target: "../req/missing.md".to_string(),
1199 reason: ReasonCode::MissingReq,
1200 detail: "missing".to_string(),
1201 });
1202 assert!(select_in(&graph, &Lock::default(), &[]).unwrap().is_empty());
1203 assert!(matches!(
1204 select_in(&graph, &Lock::default(), &["desc:doc:DOC-A"]),
1205 Err(JudgeError::NotATarget(_))
1206 ));
1207 }
1208
1209 #[test]
1211 fn select_targetsはinvalidに関わるノードのdescエッジを対象にしない() {
1212 let mut graph = Graph::default();
1213 add_desc_node(
1214 &mut graph,
1215 NodeKind::Req,
1216 "FR-001",
1217 Some("要約"),
1218 "本文",
1219 "sha256:r",
1220 );
1221 graph.invalid.push(Invalid {
1222 target_kind: TargetKind::Req,
1223 target: "FR-001".to_string(),
1224 reason: ReasonCode::DuplicateId,
1225 detail: "dup".to_string(),
1226 });
1227 assert!(select_in(&graph, &Lock::default(), &[]).unwrap().is_empty());
1228 }
1229
1230 #[test]
1232 fn build_jev_requestはdescエッジのstateと質問文言を組み立てる() {
1233 let i = input("desc:doc:DOC-A", EdgeKind::Desc, "要約です", "本文です");
1234 let v = build_jev_request(&i);
1235 assert_eq!(v["model"], "jev-latest");
1236 assert_eq!(
1237 v["state"],
1238 serde_json::json!({"description": "要約です", "body": "本文です"})
1239 );
1240 assert_eq!(v["questions"].as_object().unwrap().len(), 1);
1241 let q = &v["questions"]["consistent"];
1242 assert_eq!(q["type"], "noul");
1243 assert_eq!(
1244 q["instructions"],
1245 "`description` は `body` の内容を正しく要約しているか"
1246 );
1247 assert_eq!(
1248 q["criteria"]["true"],
1249 "description が body の主題と内容を正しく表しており、body にない事柄を述べていない"
1250 );
1251 assert_eq!(
1252 q["criteria"]["false"],
1253 "description が body の内容と食い違っている、または body の主題を表していない"
1254 );
1255 }
1256
1257 #[test]
1259 fn build_jev_requestはdescで要約が空でもdescriptionキーを空文字列で送る() {
1260 let i = input("desc:doc:DOC-A", EdgeKind::Desc, "", "本文です");
1261 let v = build_jev_request(&i);
1262 assert_eq!(v["state"]["description"], "");
1263 assert_eq!(v["state"]["body"], "本文です");
1264 }
1265
1266 #[test]
1268 fn descエッジも要約と本文の合計が上限を超えるとjevを呼ばずskippedになる() {
1269 let calls = Cell::new(0);
1270 let jev = |_: &serde_json::Value| -> Result<String, JudgeError> {
1271 calls.set(calls.get() + 1);
1272 Ok(jev_body(0.95))
1273 };
1274 let inputs = vec![input("desc:doc:DOC-A", EdgeKind::Desc, "12345", "123456")];
1275 let out = judge_edges(&inputs, 0.9, 10, &jev).unwrap();
1276 assert_eq!(out.len(), 1);
1277 assert_eq!(out[0].kind, EdgeKind::Desc);
1278 assert_eq!(out[0].verdict, Verdict::Skipped);
1279 assert_eq!(out[0].score, None);
1280 assert_eq!(calls.get(), 0);
1281 }
1282
1283 #[test]
1285 fn descエッジの要約と本文の合計が上限ちょうどならjevを呼ぶ() {
1286 let calls = Cell::new(0);
1287 let jev = |_: &serde_json::Value| -> Result<String, JudgeError> {
1288 calls.set(calls.get() + 1);
1289 Ok(jev_body(0.95))
1290 };
1291 let inputs = vec![input("desc:doc:DOC-A", EdgeKind::Desc, "12345", "12345")];
1292 let out = judge_edges(&inputs, 0.9, 10, &jev).unwrap();
1293 assert_eq!(out[0].verdict, Verdict::Consistent);
1294 assert_eq!(calls.get(), 1);
1295 }
1296
1297 #[test]
1299 fn apply_auto_reconcileはdescエッジの要約と本文のハッシュをjevの合意として記録する() {
1300 let mut i = input("desc:doc:DOC-A", EdgeKind::Desc, "要約", "本文");
1301 i.from_hash = "sha256:desc-from".to_string();
1302 i.to_hash = "sha256:desc-to".to_string();
1303 let judgments = vec![Judgment {
1304 edge: "desc:doc:DOC-A".to_string(),
1305 kind: EdgeKind::Desc,
1306 verdict: Verdict::Consistent,
1307 score: Some(0.97),
1308 }];
1309 let new_lock =
1310 apply_auto_reconcile(&Lock::default(), &[i], &judgments, "2026-02-03T04:05:06Z");
1311 let e = &new_lock.edges["desc:doc:DOC-A"];
1312 assert_eq!(e.by, "jev");
1313 assert_eq!(e.score, Some(0.97));
1314 assert_eq!(e.from, "sha256:desc-from");
1315 assert_eq!(e.to, "sha256:desc-to");
1316 assert_eq!(e.at, "2026-02-03T04:05:06Z");
1317 }
1318}