1use std::collections::{BTreeMap, BTreeSet};
2
3use code_system_graph_model::{
4 Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, EvidenceRef, LinkDecision, LinkStatus, Node, NodeId, Provenance, stable_id
5};
6use semver::Version;
7use thiserror::Error;
8
9use crate::{BoundaryRole, HttpBoundary, ManifestError, ManualLinkConfig, validate_manual_links};
10
11const MANUAL_LINK_MATCHER: &str = "manual_exact";
12const MANUAL_LINK_EXTRACTOR: &str = "code-system-graph.manual-link";
13
14#[derive(Debug, Error, PartialEq, Eq)]
16pub enum LinkError {
17 #[error("ambiguous HTTP provider for {method} {path}: {candidates:?}")]
19 AmbiguousProvider {
20 method: String,
22 path: String,
24 candidates: Vec<String>,
26 },
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ManualLinkEndpoint {
32 From,
34 To,
36}
37
38impl std::fmt::Display for ManualLinkEndpoint {
39 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::From => formatter.write_str("from"),
42 Self::To => formatter.write_str("to"),
43 }
44 }
45}
46
47#[derive(Debug, Error)]
49pub enum ManualLinkError {
50 #[error(transparent)]
52 InvalidDeclaration(#[from] ManifestError),
53 #[error(
55 "manualLinks[{index}].{endpoint} `{value}` did not match any exact node ID or stable key"
56 )]
57 MissingEndpoint {
58 index: usize,
60 endpoint: ManualLinkEndpoint,
62 value: String,
64 },
65 #[error(
67 "manualLinks[{index}].{endpoint} `{value}` is ambiguous across exact candidates {candidates:?}"
68 )]
69 AmbiguousEndpoint {
70 index: usize,
72 endpoint: ManualLinkEndpoint,
74 value: String,
76 candidates: Vec<NodeId>,
78 },
79 #[error("manualLinks[{index}] resolves both endpoints to `{node}`")]
81 ResolvedSelfLink {
82 index: usize,
84 node: String,
86 },
87 #[error(
89 "manualLinks[{duplicate}] resolves to the same relationship as manualLinks[{first}]: {source_node:?} -> {target_node:?} ({relation:?})"
90 )]
91 DuplicateResolvedLink {
92 first: usize,
94 duplicate: usize,
96 source_node: NodeId,
98 target_node: NodeId,
100 relation: EdgeKind,
102 },
103 #[error(
105 "manualLinks[{index}] cannot suppress missing automatic relationship {source_node:?} -> {target_node:?} ({relation:?})"
106 )]
107 MissingSuppressionTarget {
108 index: usize,
110 source_node: NodeId,
112 target_node: NodeId,
114 relation: EdgeKind,
116 },
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub struct ManualLinkResolution {
122 pub edges: Vec<Edge>,
124 pub evidence: Vec<Evidence>,
126 pub decisions: Vec<LinkDecision>,
128}
129
130pub fn link_http_boundaries(boundaries: &[HttpBoundary]) -> Result<Vec<Edge>, LinkError> {
139 let mut providers: BTreeMap<(&str, &str), Vec<&HttpBoundary>> = BTreeMap::new();
140 for boundary in boundaries {
141 if boundary.role == BoundaryRole::Provider {
142 let candidates = providers
143 .entry((&boundary.method, &boundary.path))
144 .or_default();
145 if let Some(existing) = candidates
146 .iter()
147 .position(|candidate| candidate.node.id == boundary.node.id)
148 {
149 if boundary.evidence.confidence > candidates[existing].evidence.confidence {
150 candidates[existing] = boundary;
151 }
152 } else {
153 candidates.push(boundary);
154 }
155 }
156 }
157
158 let mut edges = Vec::new();
159 for consumer in boundaries
160 .iter()
161 .filter(|boundary| boundary.role == BoundaryRole::Consumer)
162 {
163 let Some(candidates) = providers.get(&(consumer.method.as_str(), consumer.path.as_str()))
164 else {
165 continue;
166 };
167 if candidates.len() > 1 {
168 let mut candidate_ids = candidates
169 .iter()
170 .map(|candidate| candidate.node.id.as_str().to_owned())
171 .collect::<Vec<_>>();
172 candidate_ids.sort();
173 return Err(LinkError::AmbiguousProvider {
174 method: consumer.method.clone(),
175 path: consumer.path.clone(),
176 candidates: candidate_ids,
177 });
178 }
179 let provider = candidates[0];
180 let edge_key = format!(
181 "{}:calls_remote:{}",
182 consumer.node.id.as_str(),
183 provider.node.id.as_str()
184 );
185 edges.push(Edge {
186 id: EdgeId::new(stable_id("edge", &edge_key)),
187 source: consumer.node.id.clone(),
188 target: provider.node.id.clone(),
189 kind: EdgeKind::CallsRemote,
190 confidence: consumer
191 .evidence
192 .confidence
193 .min(provider.evidence.confidence),
194 status: consensus_status(
195 consumer
196 .evidence
197 .confidence
198 .min(provider.evidence.confidence),
199 ),
200 evidence: vec![consumer.evidence.id.clone(), provider.evidence.id.clone()],
201 });
202 }
203 edges.sort_by(|left, right| left.id.cmp(&right.id));
204 Ok(edges)
205}
206
207fn consensus_status(confidence: f32) -> EpistemicStatus {
208 if confidence >= 1.0 {
209 EpistemicStatus::Confirmed
210 } else {
211 EpistemicStatus::Inferred
212 }
213}
214
215pub fn resolve_manual_links(
227 links: &[ManualLinkConfig],
228 nodes: &[Node],
229 automatic_edges: &[Edge],
230) -> Result<ManualLinkResolution, ManualLinkError> {
231 validate_manual_links(links)?;
232 let mut resolved = Vec::with_capacity(links.len());
233 let mut identities = BTreeMap::new();
234 for (index, link) in links.iter().enumerate() {
235 let source = resolve_manual_endpoint(nodes, index, ManualLinkEndpoint::From, &link.from)?;
236 let target = resolve_manual_endpoint(nodes, index, ManualLinkEndpoint::To, &link.to)?;
237 if source == target {
238 return Err(ManualLinkError::ResolvedSelfLink {
239 index,
240 node: source.as_str().to_owned(),
241 });
242 }
243 let identity = (source.clone(), target.clone(), link.relation);
244 if let Some(first) = identities.insert(identity, index) {
245 return Err(ManualLinkError::DuplicateResolvedLink {
246 first,
247 duplicate: index,
248 source_node: source,
249 target_node: target,
250 relation: link.relation,
251 });
252 }
253 resolved.push(ResolvedManualLink {
254 index,
255 link,
256 source,
257 target,
258 });
259 }
260 resolved.sort_by(|left, right| {
261 (
262 &left.source,
263 &left.target,
264 left.link.relation,
265 left.link.suppress,
266 )
267 .cmp(&(
268 &right.source,
269 &right.target,
270 right.link.relation,
271 right.link.suppress,
272 ))
273 });
274
275 let mut edges = automatic_edges.to_vec();
276 edges.sort_by(|left, right| left.id.cmp(&right.id));
277 let mut evidence = Vec::with_capacity(resolved.len());
278 let mut decisions = Vec::with_capacity(resolved.len());
279 for item in resolved {
280 let manual_evidence = manual_link_evidence(&item);
281 let evidence_ref = EvidenceRef {
282 id: manual_evidence.id.clone(),
283 provenance: Provenance::Manual,
284 };
285 let status = if item.link.suppress {
286 let before = edges.len();
287 edges.retain(|edge| !edge_matches(&item, edge));
288 let removed = before - edges.len();
289 if removed == 0 {
290 return Err(ManualLinkError::MissingSuppressionTarget {
291 index: item.index,
292 source_node: item.source,
293 target_node: item.target,
294 relation: item.link.relation,
295 });
296 }
297 LinkStatus::Suppressed
298 } else {
299 edges.retain(|edge| !edge_matches(&item, edge));
300 edges.push(manual_link_edge(&item, &manual_evidence));
301 LinkStatus::Confirmed
302 };
303 decisions.push(manual_link_decision(&item, evidence_ref, status));
304 evidence.push(manual_evidence);
305 }
306 edges.sort_by(|left, right| left.id.cmp(&right.id));
307 evidence.sort_by(|left, right| left.id.cmp(&right.id));
308 decisions.sort_by(|left, right| {
309 (&left.source, &left.target, left.relation, left.status).cmp(&(
310 &right.source,
311 &right.target,
312 right.relation,
313 right.status,
314 ))
315 });
316 Ok(ManualLinkResolution {
317 edges,
318 evidence,
319 decisions,
320 })
321}
322
323#[derive(Debug)]
324struct ResolvedManualLink<'a> {
325 index: usize,
326 link: &'a ManualLinkConfig,
327 source: NodeId,
328 target: NodeId,
329}
330
331fn resolve_manual_endpoint(
332 nodes: &[Node],
333 index: usize,
334 endpoint: ManualLinkEndpoint,
335 value: &str,
336) -> Result<NodeId, ManualLinkError> {
337 let candidates = nodes
338 .iter()
339 .filter(|node| node.id.as_str() == value || node.stable_key == value)
340 .map(|node| node.id.clone())
341 .collect::<BTreeSet<_>>();
342 match candidates.len() {
343 0 => Err(ManualLinkError::MissingEndpoint {
344 index,
345 endpoint,
346 value: value.to_owned(),
347 }),
348 1 => candidates
349 .into_iter()
350 .next()
351 .ok_or_else(|| ManualLinkError::MissingEndpoint {
352 index,
353 endpoint,
354 value: value.to_owned(),
355 }),
356 _ => Err(ManualLinkError::AmbiguousEndpoint {
357 index,
358 endpoint,
359 value: value.to_owned(),
360 candidates: candidates.into_iter().collect(),
361 }),
362 }
363}
364
365fn edge_matches(link: &ResolvedManualLink<'_>, edge: &Edge) -> bool {
366 edge.source == link.source && edge.target == link.target && edge.kind == link.link.relation
367}
368
369fn manual_link_edge(link: &ResolvedManualLink<'_>, evidence: &Evidence) -> Edge {
370 let key = format!(
371 "{}:{:?}:{}",
372 link.source.as_str(),
373 link.link.relation,
374 link.target.as_str()
375 );
376 Edge {
377 id: EdgeId::new(stable_id("edge", &key)),
378 source: link.source.clone(),
379 target: link.target.clone(),
380 kind: link.link.relation,
381 confidence: 1.0,
382 status: EpistemicStatus::Confirmed,
383 evidence: vec![evidence.id.clone()],
384 }
385}
386
387fn manual_link_evidence(link: &ResolvedManualLink<'_>) -> Evidence {
388 let contract = link.link.contract.as_deref().unwrap_or("");
389 let key = format!(
390 "{}:{}:{:?}:{contract}:{}:{}",
391 link.source.as_str(),
392 link.target.as_str(),
393 link.link.relation,
394 link.link.suppress,
395 link.link.reason
396 );
397 Evidence {
398 id: EvidenceId::new(stable_id("evidence", &key)),
399 repo_id: None,
400 file_path: None,
401 start_line: None,
402 end_line: None,
403 extractor: MANUAL_LINK_EXTRACTOR.to_owned(),
404 extractor_version: "1.0.0".to_owned(),
405 provenance: Provenance::Manual,
406 confidence: 1.0,
407 observed_at_commit: None,
408 content_hash: Some(stable_id("manual-link-declaration", &key)),
409 note: Some(link.link.reason.clone()),
410 }
411}
412
413fn manual_link_decision(
414 link: &ResolvedManualLink<'_>,
415 evidence: EvidenceRef,
416 status: LinkStatus,
417) -> LinkDecision {
418 let mut reasons = vec![
419 link.link.reason.clone(),
420 "both endpoints matched an exact node ID or stable key".to_owned(),
421 ];
422 if let Some(contract) = &link.link.contract {
423 reasons.push(format!("declared contract: {contract}"));
424 }
425 reasons.push(match status {
426 LinkStatus::Confirmed => "manual declaration created the exact relationship".to_owned(),
427 LinkStatus::Suppressed => {
428 "manual declaration removed the exact automatic relationship".to_owned()
429 }
430 LinkStatus::Ambiguous | LinkStatus::Rejected => {
431 "manual declaration was not applied".to_owned()
432 }
433 });
434 LinkDecision {
435 source: link.source.clone(),
436 target: link.target.clone(),
437 relation: link.link.relation,
438 matcher: MANUAL_LINK_MATCHER.to_owned(),
439 matcher_version: Version::new(1, 0, 0),
440 score: 1.0,
441 confidence: 1.0,
442 reasons,
443 rejected_alternatives: Vec::new(),
444 evidence: vec![evidence],
445 status,
446 }
447}
448
449#[must_use]
454pub fn merge_affected_link_neighborhoods<K>(
455 previous_edges: &[Edge],
456 recomputed_edges: &[Edge],
457 affected_keys: &BTreeSet<K>,
458 previous_node_keys: &BTreeMap<NodeId, K>,
459 current_node_keys: &BTreeMap<NodeId, K>,
460 current_node_ids: &BTreeSet<NodeId>,
461) -> Vec<Edge>
462where
463 K: Ord,
464{
465 let mut merged = BTreeMap::new();
466 for edge in previous_edges {
467 let endpoints_exist =
468 current_node_ids.contains(&edge.source) && current_node_ids.contains(&edge.target);
469 if endpoints_exist && !edge_touches_keys(edge, previous_node_keys, affected_keys) {
470 merged.insert(edge.id.clone(), edge.clone());
471 }
472 }
473 for edge in recomputed_edges {
474 if edge_touches_keys(edge, current_node_keys, affected_keys) {
475 merged.insert(edge.id.clone(), edge.clone());
476 }
477 }
478 merged.into_values().collect()
479}
480
481fn edge_touches_keys<K>(
482 edge: &Edge,
483 node_keys: &BTreeMap<NodeId, K>,
484 affected_keys: &BTreeSet<K>,
485) -> bool
486where
487 K: Ord,
488{
489 [&edge.source, &edge.target]
490 .into_iter()
491 .filter_map(|node| node_keys.get(node))
492 .any(|key| affected_keys.contains(key))
493}
494
495#[cfg(test)]
496mod tests {
497 use std::collections::{BTreeMap, BTreeSet};
498
499 use code_system_graph_model::{
500 Edge, EdgeId, EdgeKind, EpistemicStatus, LinkStatus, Node, NodeId, NodeKind, Provenance, RepoId
501 };
502
503 use super::{
504 LinkError, ManualLinkEndpoint, ManualLinkError, link_http_boundaries, merge_affected_link_neighborhoods, resolve_manual_links
505 };
506 use crate::{HttpConsumerConfig, ManualLinkConfig, extract_openapi};
507
508 fn consumer() -> crate::HttpBoundary {
509 crate::HttpBoundary::consumer(
510 RepoId::new("repo:web"),
511 &HttpConsumerConfig {
512 method: "POST".to_owned(),
513 path: "/api/orders".to_owned(),
514 source: "src/checkout.ts".to_owned(),
515 },
516 )
517 }
518
519 fn provider(repo: &str) -> crate::HttpBoundary {
520 let input = r"
521openapi: 3.0.3
522paths:
523 /api/orders:
524 post:
525 operationId: createOrder
526";
527 let result = extract_openapi(&RepoId::new(repo), "openapi.yaml", input);
528 match result {
529 Ok(mut boundaries) => boundaries.remove(0),
530 Err(error) => panic!("test fixture must be valid: {error}"),
531 }
532 }
533
534 fn graph_node(id: &str, stable_key: &str) -> Node {
535 Node {
536 id: NodeId::new(id),
537 kind: NodeKind::Service,
538 repo_id: None,
539 stable_key: stable_key.to_owned(),
540 label: stable_key.to_owned(),
541 }
542 }
543
544 fn manual_link(from: &str, to: &str, relation: EdgeKind, suppress: bool) -> ManualLinkConfig {
545 ManualLinkConfig {
546 from: from.to_owned(),
547 to: to.to_owned(),
548 relation,
549 contract: Some("POST /orders".to_owned()),
550 reason: "Explicit manual boundary".to_owned(),
551 suppress,
552 }
553 }
554
555 fn automatic_edge(id: &str, source: &str, target: &str, kind: EdgeKind) -> Edge {
556 Edge {
557 id: EdgeId::new(id),
558 source: NodeId::new(source),
559 target: NodeId::new(target),
560 kind,
561 confidence: 0.8,
562 status: EpistemicStatus::Inferred,
563 evidence: Vec::new(),
564 }
565 }
566
567 #[test]
568 fn link_http_boundaries_should_require_bilateral_evidence() {
569 let result = link_http_boundaries(&[consumer(), provider("repo:api")]);
570 let evidence_count = result.map(|edges| edges[0].evidence.len());
571
572 assert_eq!(evidence_count, Ok(2));
573 }
574
575 #[test]
576 fn link_http_boundaries_should_reject_duplicate_providers() {
577 let result =
578 link_http_boundaries(&[consumer(), provider("repo:api-a"), provider("repo:api-b")]);
579
580 assert!(matches!(result, Err(LinkError::AmbiguousProvider { .. })));
581 }
582
583 #[test]
584 fn resolve_manual_links_should_create_confirmed_exact_edge_with_manual_evidence() {
585 let nodes = [
586 graph_node("node:web", "service:web"),
587 graph_node("node:api", "service:api"),
588 ];
589 let links = [manual_link(
590 "node:web",
591 "service:api",
592 EdgeKind::Consumes,
593 false,
594 )];
595
596 let result = resolve_manual_links(&links, &nodes, &[])
597 .unwrap_or_else(|error| panic!("manual link should resolve: {error}"));
598
599 assert!(
600 result.edges.len() == 1
601 && result.edges[0].source == NodeId::new("node:web")
602 && result.edges[0].target == NodeId::new("node:api")
603 && result.edges[0].kind == EdgeKind::Consumes
604 && (result.edges[0].confidence - 1.0).abs() < f32::EPSILON
605 && result.edges[0].status == EpistemicStatus::Confirmed
606 && result.evidence.len() == 1
607 && result.evidence[0].provenance == Provenance::Manual
608 && result.decisions.len() == 1
609 && result.decisions[0].status == LinkStatus::Confirmed
610 && (result.decisions[0].score - 1.0).abs() < f32::EPSILON
611 );
612 }
613
614 #[test]
615 fn resolve_manual_links_should_suppress_only_exact_automatic_relationship() {
616 let nodes = [
617 graph_node("node:web", "service:web"),
618 graph_node("node:api", "service:api"),
619 ];
620 let links = [manual_link(
621 "node:web",
622 "node:api",
623 EdgeKind::CallsRemote,
624 true,
625 )];
626 let automatic = [
627 automatic_edge("edge:exact", "node:web", "node:api", EdgeKind::CallsRemote),
628 automatic_edge(
629 "edge:reverse",
630 "node:api",
631 "node:web",
632 EdgeKind::CallsRemote,
633 ),
634 automatic_edge("edge:relation", "node:web", "node:api", EdgeKind::Consumes),
635 ];
636
637 let result = resolve_manual_links(&links, &nodes, &automatic)
638 .unwrap_or_else(|error| panic!("suppression should resolve: {error}"));
639
640 assert!(
641 result.edges.len() == 2
642 && result
643 .edges
644 .iter()
645 .all(|edge| edge.id.as_str() != "edge:exact")
646 && result
647 .edges
648 .iter()
649 .any(|edge| edge.id.as_str() == "edge:reverse")
650 && result
651 .edges
652 .iter()
653 .any(|edge| edge.id.as_str() == "edge:relation")
654 && result.decisions[0].status == LinkStatus::Suppressed
655 );
656 }
657
658 #[test]
659 fn resolve_manual_links_should_fail_for_missing_endpoint() {
660 let nodes = [graph_node("node:web", "service:web")];
661 let links = [manual_link(
662 "node:web",
663 "service:missing",
664 EdgeKind::Consumes,
665 false,
666 )];
667
668 let result = resolve_manual_links(&links, &nodes, &[]);
669
670 assert!(matches!(
671 result,
672 Err(ManualLinkError::MissingEndpoint {
673 endpoint: ManualLinkEndpoint::To,
674 value,
675 ..
676 }) if value == "service:missing"
677 ));
678 }
679
680 #[test]
681 fn resolve_manual_links_should_fail_for_ambiguous_exact_endpoint() {
682 let nodes = [
683 graph_node("node:web", "service:web"),
684 graph_node("node:api-b", "service:shared"),
685 graph_node("node:api-a", "service:shared"),
686 ];
687 let links = [manual_link(
688 "node:web",
689 "service:shared",
690 EdgeKind::Consumes,
691 false,
692 )];
693
694 let result = resolve_manual_links(&links, &nodes, &[]);
695
696 assert!(matches!(
697 result,
698 Err(ManualLinkError::AmbiguousEndpoint { candidates, .. })
699 if candidates
700 == vec![NodeId::new("node:api-a"), NodeId::new("node:api-b")]
701 ));
702 }
703
704 #[test]
705 fn resolve_manual_links_should_be_independent_of_input_order() {
706 let nodes = vec![
707 graph_node("node:web", "service:web"),
708 graph_node("node:api", "service:api"),
709 graph_node("node:worker", "service:worker"),
710 ];
711 let links = vec![
712 manual_link("service:web", "service:api", EdgeKind::CallsRemote, false),
713 manual_link("service:worker", "service:api", EdgeKind::Consumes, false),
714 ];
715 let mut reversed_nodes = nodes.clone();
716 reversed_nodes.reverse();
717 let mut reversed_links = links.clone();
718 reversed_links.reverse();
719
720 let first = resolve_manual_links(&links, &nodes, &[]);
721 let second = resolve_manual_links(&reversed_links, &reversed_nodes, &[]);
722
723 assert_eq!(
724 first.unwrap_or_else(|error| panic!("first resolution should succeed: {error}")),
725 second.unwrap_or_else(|error| panic!("second resolution should succeed: {error}"))
726 );
727 }
728
729 #[test]
730 fn relinking_should_replace_only_affected_neighborhoods() {
731 let orders = link_http_boundaries(&[consumer(), provider("repo:api")])
732 .unwrap_or_else(|error| panic!("fixture must link: {error}"));
733 let mut health_consumer = consumer();
734 health_consumer.method = "GET".to_owned();
735 health_consumer.path = "/health".to_owned();
736 let mut health_provider = provider("repo:health");
737 health_provider.method = "GET".to_owned();
738 health_provider.path = "/health".to_owned();
739 let health = link_http_boundaries(&[health_consumer, health_provider])
740 .unwrap_or_else(|error| panic!("fixture must link: {error}"));
741 let mut previous = orders.clone();
742 previous.extend(health.clone());
743 let affected = BTreeSet::from(["POST:/api/orders".to_owned()]);
744 let previous_keys = BTreeMap::from([
745 (orders[0].source.clone(), "POST:/api/orders".to_owned()),
746 (orders[0].target.clone(), "POST:/api/orders".to_owned()),
747 (health[0].source.clone(), "GET:/health".to_owned()),
748 (health[0].target.clone(), "GET:/health".to_owned()),
749 ]);
750 let current_ids = previous_keys.keys().cloned().collect::<BTreeSet<NodeId>>();
751 let result = merge_affected_link_neighborhoods(
752 &previous,
753 &orders,
754 &affected,
755 &previous_keys,
756 &previous_keys,
757 ¤t_ids,
758 );
759
760 assert_eq!(result.len(), 2);
761 assert!(result.contains(&health[0]));
762 assert!(result.contains(&orders[0]));
763 }
764}