1use std::collections::{BTreeMap, BTreeSet};
2
3use anyhow::Result;
4use serde_json::{json, Value};
5
6use crate::{
7 ids::document_id,
8 model::{ArtifactDoc, EdgeDoc, WarningDoc},
9 security::apply_artifact_security,
10};
11
12#[derive(Clone)]
13struct FlowCandidate {
14 component_name: Option<String>,
15 component_path: Option<String>,
16 line_start: Option<u32>,
17 wrapper_index: usize,
18 transport_index: Option<usize>,
19 source_paths: BTreeSet<String>,
20 related_tests: BTreeSet<String>,
21}
22
23#[derive(Clone)]
24struct CallerEvidence {
25 component_name: Option<String>,
26 component_path: Option<String>,
27 line_start: Option<u32>,
28 source_paths: BTreeSet<String>,
29 related_tests: BTreeSet<String>,
30}
31
32#[derive(Clone)]
33struct EndpointRecord {
34 method: String,
35 normalized_path: String,
36 wrapper_indices: Vec<usize>,
37 endpoint_index: usize,
38}
39
40pub fn augment_frontend_http_flows(
41 artifacts: &mut Vec<ArtifactDoc>,
42 edges: &mut Vec<EdgeDoc>,
43 _warnings: &mut Vec<WarningDoc>,
44) -> Result<()> {
45 let mut transport_by_name: BTreeMap<String, usize> = BTreeMap::new();
46 let mut wrappers_by_name: BTreeMap<String, Vec<usize>> = BTreeMap::new();
47 let mut endpoint_records: BTreeMap<String, EndpointRecord> = BTreeMap::new();
48
49 for (index, artifact) in artifacts.iter().enumerate() {
50 if artifact.kind == "frontend_transport" {
51 if let Some(name) = &artifact.name {
52 transport_by_name.insert(name.clone(), index);
53 }
54 }
55 if artifact.kind == "frontend_api_wrapper" {
56 if let Some(name) = &artifact.name {
57 wrappers_by_name
58 .entry(name.clone())
59 .or_default()
60 .push(index);
61 }
62 }
63 }
64
65 let wrapper_indices: Vec<usize> = artifacts
66 .iter()
67 .enumerate()
68 .filter_map(|(index, artifact)| (artifact.kind == "frontend_api_wrapper").then_some(index))
69 .collect();
70
71 for wrapper_index in wrapper_indices {
72 let Some(method) = artifacts[wrapper_index]
73 .data
74 .get("http_method")
75 .and_then(Value::as_str)
76 .map(str::to_owned)
77 else {
78 continue;
79 };
80 let Some(normalized_path) = artifacts[wrapper_index]
81 .data
82 .get("normalized_path")
83 .and_then(Value::as_str)
84 .map(str::to_owned)
85 else {
86 continue;
87 };
88 let key = endpoint_key(&method, &normalized_path);
89 if let Some(record) = endpoint_records.get_mut(&key) {
90 record.wrapper_indices.push(wrapper_index);
91 continue;
92 }
93
94 let endpoint = endpoint_artifact(
95 &artifacts[wrapper_index].repo,
96 &method,
97 &normalized_path,
98 artifacts[wrapper_index].source_path.as_deref(),
99 artifacts[wrapper_index].line_start,
100 );
101 let endpoint_index = artifacts.len();
102 artifacts.push(endpoint);
103 endpoint_records.insert(
104 key,
105 EndpointRecord {
106 method,
107 normalized_path,
108 wrapper_indices: vec![wrapper_index],
109 endpoint_index,
110 },
111 );
112 }
113
114 let hook_use_indices: Vec<usize> = artifacts
115 .iter()
116 .enumerate()
117 .filter_map(|(index, artifact)| (artifact.kind == "frontend_hook_use").then_some(index))
118 .collect();
119
120 for hook_use_index in hook_use_indices {
121 let Some(wrapper_name) = artifacts[hook_use_index]
122 .data
123 .get("hook_def_name")
124 .and_then(Value::as_str)
125 else {
126 continue;
127 };
128 let Some(wrapper_indices) = wrappers_by_name.get(wrapper_name).cloned() else {
129 continue;
130 };
131 for wrapper_index in wrapper_indices {
132 edges.push(edge(
133 &artifacts[hook_use_index].repo,
134 "calls_api_wrapper",
135 &artifacts[hook_use_index],
136 &artifacts[wrapper_index],
137 "hook callsite name matches API wrapper definition",
138 0.97,
139 ));
140 }
141 }
142
143 let endpoint_records_list: Vec<EndpointRecord> = endpoint_records.values().cloned().collect();
144 for record in endpoint_records_list {
145 for wrapper_index in &record.wrapper_indices {
146 if let Some(transport_name) = artifacts[*wrapper_index]
147 .data
148 .get("transport_name")
149 .and_then(Value::as_str)
150 {
151 if let Some(transport_index) = transport_by_name.get(transport_name).copied() {
152 edges.push(edge(
153 &artifacts[*wrapper_index].repo,
154 "uses_transport",
155 &artifacts[*wrapper_index],
156 &artifacts[transport_index],
157 "wrapper transport name matches transport definition",
158 0.98,
159 ));
160 edges.push(edge(
161 &artifacts[transport_index].repo,
162 "calls_http_route",
163 &artifacts[transport_index],
164 &artifacts[record.endpoint_index],
165 "transport method and wrapper path resolve to endpoint",
166 0.98,
167 ));
168 } else {
169 edges.push(edge(
170 &artifacts[*wrapper_index].repo,
171 "calls_http_route",
172 &artifacts[*wrapper_index],
173 &artifacts[record.endpoint_index],
174 "wrapper directly resolves endpoint",
175 0.92,
176 ));
177 }
178 } else {
179 edges.push(edge(
180 &artifacts[*wrapper_index].repo,
181 "calls_http_route",
182 &artifacts[*wrapper_index],
183 &artifacts[record.endpoint_index],
184 "wrapper directly resolves endpoint",
185 0.9,
186 ));
187 }
188 }
189
190 let candidates =
191 flow_candidates_for_endpoint(artifacts, &transport_by_name, &record.wrapper_indices);
192 if candidates.is_empty() {
193 continue;
194 }
195 let Some(best) = canonical_candidate(&candidates, artifacts) else {
196 continue;
197 };
198 let flow = flow_artifact(artifacts, &record, &candidates, &best);
199 let flow_index = artifacts.len();
200 artifacts.push(flow);
201 edges.push(edge(
202 &artifacts[flow_index].repo,
203 "contains",
204 &artifacts[flow_index],
205 &artifacts[record.endpoint_index],
206 "flow summarizes canonical frontend HTTP chain",
207 1.0,
208 ));
209 }
210
211 Ok(())
212}
213
214fn flow_candidates_for_endpoint(
215 artifacts: &[ArtifactDoc],
216 transport_by_name: &BTreeMap<String, usize>,
217 wrapper_indices: &[usize],
218) -> Vec<FlowCandidate> {
219 let mut candidates = Vec::new();
220 for wrapper_index in wrapper_indices {
221 let wrapper = &artifacts[*wrapper_index];
222 let transport_index = wrapper
223 .data
224 .get("transport_name")
225 .and_then(Value::as_str)
226 .and_then(|name| transport_by_name.get(name).copied());
227 let callers = wrapper
228 .name
229 .as_deref()
230 .map(|name| resolved_ui_callers(artifacts, name, &mut BTreeSet::new()))
231 .unwrap_or_default();
232
233 if callers.is_empty() {
234 let mut source_paths = BTreeSet::new();
235 if let Some(path) = wrapper.source_path.as_ref() {
236 source_paths.insert(path.clone());
237 }
238 let related_tests = wrapper.related_tests.iter().cloned().collect();
239 candidates.push(FlowCandidate {
240 component_name: None,
241 component_path: None,
242 line_start: wrapper.line_start,
243 wrapper_index: *wrapper_index,
244 transport_index,
245 source_paths,
246 related_tests,
247 });
248 continue;
249 }
250
251 for caller in callers {
252 let mut source_paths = BTreeSet::new();
253 let mut related_tests = BTreeSet::new();
254 source_paths.extend(caller.source_paths.clone());
255 if let Some(path) = wrapper.source_path.as_ref() {
256 source_paths.insert(path.clone());
257 }
258 if let Some(index) = transport_index {
259 if let Some(path) = artifacts[index].source_path.as_ref() {
260 source_paths.insert(path.clone());
261 }
262 for test in &artifacts[index].related_tests {
263 related_tests.insert(test.clone());
264 }
265 }
266 for test in caller
267 .related_tests
268 .iter()
269 .chain(wrapper.related_tests.iter())
270 {
271 related_tests.insert(test.clone());
272 }
273 candidates.push(FlowCandidate {
274 component_name: caller.component_name.clone(),
275 component_path: caller.component_path.clone(),
276 line_start: caller.line_start.or(wrapper.line_start),
277 wrapper_index: *wrapper_index,
278 transport_index,
279 source_paths,
280 related_tests,
281 });
282 }
283 }
284
285 dedupe_candidates(candidates)
286}
287
288fn canonical_candidate(
289 candidates: &[FlowCandidate],
290 artifacts: &[ArtifactDoc],
291) -> Option<FlowCandidate> {
292 candidates
293 .iter()
294 .max_by_key(|candidate| {
295 let mut score = 0_i32;
296 if candidate.component_name.is_some() {
297 score += 100;
298 }
299 if candidate
300 .component_path
301 .as_deref()
302 .is_some_and(|path| path.contains("/app/"))
303 {
304 score += 20;
305 }
306 if candidate
307 .component_name
308 .as_deref()
309 .is_some_and(|name| name.ends_with("Modal"))
310 {
311 score += 12;
312 }
313 if candidate
314 .component_name
315 .as_deref()
316 .is_some_and(|name| name.ends_with("Page"))
317 {
318 score += 10;
319 }
320 if !candidate.related_tests.is_empty() {
321 score += 5;
322 }
323 score -= candidate.source_paths.len() as i32;
324 (
325 score,
326 candidate.component_path.clone().unwrap_or_default(),
327 artifacts[candidate.wrapper_index]
328 .source_path
329 .clone()
330 .unwrap_or_default(),
331 )
332 })
333 .cloned()
334}
335
336fn flow_artifact(
337 artifacts: &[ArtifactDoc],
338 endpoint: &EndpointRecord,
339 candidates: &[FlowCandidate],
340 best: &FlowCandidate,
341) -> ArtifactDoc {
342 let wrapper = &artifacts[best.wrapper_index];
343 let transport = best.transport_index.map(|index| &artifacts[index]);
344 let source_path = best
345 .component_path
346 .clone()
347 .or_else(|| wrapper.source_path.clone());
348 let line_start = best.line_start.or(wrapper.line_start).unwrap_or(1);
349
350 let mut alternate_components = BTreeSet::new();
351 let mut related_tests = BTreeSet::new();
352 let mut source_paths = BTreeSet::new();
353 for candidate in candidates {
354 if let Some(component) = candidate.component_name.as_ref() {
355 if Some(component) != best.component_name.as_ref() {
356 alternate_components.insert(component.clone());
357 }
358 }
359 for test in &candidate.related_tests {
360 related_tests.insert(test.clone());
361 }
362 for path in &candidate.source_paths {
363 source_paths.insert(path.clone());
364 }
365 }
366
367 let mut doc = ArtifactDoc {
368 id: document_id(
369 &wrapper.repo,
370 "frontend_http_flow",
371 source_path.as_deref(),
372 Some(line_start),
373 Some(&endpoint.display_name()),
374 ),
375 repo: wrapper.repo.clone(),
376 kind: "frontend_http_flow".to_owned(),
377 side: Some("frontend".to_owned()),
378 language: wrapper.language.clone(),
379 name: Some(endpoint.normalized_path.clone()),
380 display_name: Some(endpoint.display_name()),
381 source_path,
382 line_start: Some(line_start),
383 line_end: Some(line_start),
384 column_start: None,
385 column_end: None,
386 package_name: None,
387 comments: Vec::new(),
388 tags: vec!["http flow".to_owned()],
389 related_symbols: [
390 best.component_name.clone(),
391 wrapper.name.clone(),
392 transport.and_then(|artifact| artifact.name.clone()),
393 ]
394 .into_iter()
395 .flatten()
396 .collect(),
397 related_tests: related_tests.into_iter().collect(),
398 risk_level: "low".to_owned(),
399 risk_reasons: Vec::new(),
400 contains_phi: false,
401 has_related_tests: false,
402 updated_at: chrono::Utc::now().to_rfc3339(),
403 data: BTreeMap::new().into_iter().collect(),
404 };
405
406 doc.data.insert(
407 "http_method".to_owned(),
408 Value::String(endpoint.method.clone()),
409 );
410 doc.data.insert(
411 "normalized_path".to_owned(),
412 Value::String(endpoint.normalized_path.clone()),
413 );
414 doc.data.insert(
415 "path_aliases".to_owned(),
416 Value::Array(
417 path_aliases(&endpoint.normalized_path)
418 .into_iter()
419 .map(Value::String)
420 .collect(),
421 ),
422 );
423 doc.data.insert(
424 "primary_component".to_owned(),
425 best.component_name
426 .clone()
427 .map(Value::String)
428 .unwrap_or(Value::Null),
429 );
430 doc.data.insert(
431 "primary_component_path".to_owned(),
432 best.component_path
433 .clone()
434 .map(Value::String)
435 .unwrap_or(Value::Null),
436 );
437 doc.data.insert(
438 "primary_wrapper".to_owned(),
439 wrapper
440 .name
441 .clone()
442 .map(Value::String)
443 .unwrap_or(Value::Null),
444 );
445 doc.data.insert(
446 "primary_wrapper_path".to_owned(),
447 wrapper
448 .source_path
449 .clone()
450 .map(Value::String)
451 .unwrap_or(Value::Null),
452 );
453 doc.data.insert(
454 "primary_transport".to_owned(),
455 transport
456 .and_then(|artifact| artifact.name.clone())
457 .map(Value::String)
458 .unwrap_or(Value::Null),
459 );
460 doc.data.insert(
461 "primary_transport_path".to_owned(),
462 transport
463 .and_then(|artifact| artifact.source_path.clone())
464 .map(Value::String)
465 .unwrap_or(Value::Null),
466 );
467 doc.data
468 .insert("caller_count".to_owned(), json!(candidates.len()));
469 doc.data.insert(
470 "alternate_components".to_owned(),
471 Value::Array(
472 alternate_components
473 .into_iter()
474 .map(Value::String)
475 .collect(),
476 ),
477 );
478 doc.data.insert(
479 "source_paths".to_owned(),
480 Value::Array(source_paths.into_iter().map(Value::String).collect()),
481 );
482 doc.data.insert(
483 "primary_flow".to_owned(),
484 json!([
485 {
486 "kind": "frontend_component",
487 "name": best.component_name,
488 "path": best.component_path,
489 "line": best.line_start,
490 },
491 {
492 "kind": "frontend_api_wrapper",
493 "name": wrapper.name,
494 "path": wrapper.source_path,
495 "line": wrapper.line_start,
496 },
497 {
498 "kind": "frontend_transport",
499 "name": transport.and_then(|artifact| artifact.name.clone()),
500 "path": transport.and_then(|artifact| artifact.source_path.clone()),
501 "line": transport.and_then(|artifact| artifact.line_start),
502 },
503 {
504 "kind": "frontend_http_endpoint",
505 "method": endpoint.method,
506 "path": endpoint.normalized_path,
507 }
508 ]),
509 );
510
511 apply_artifact_security(&mut doc);
512 doc
513}
514
515fn endpoint_artifact(
516 repo: &str,
517 method: &str,
518 normalized_path: &str,
519 source_path: Option<&str>,
520 line_start: Option<u32>,
521) -> ArtifactDoc {
522 let mut doc = ArtifactDoc {
523 id: document_id(
524 repo,
525 "frontend_http_endpoint",
526 source_path,
527 line_start,
528 Some(&format!("{method} {normalized_path}")),
529 ),
530 repo: repo.to_owned(),
531 kind: "frontend_http_endpoint".to_owned(),
532 side: Some("frontend".to_owned()),
533 language: Some("ts".to_owned()),
534 name: Some(normalized_path.to_owned()),
535 display_name: Some(format!("{method} {normalized_path}")),
536 source_path: source_path.map(str::to_owned),
537 line_start,
538 line_end: line_start,
539 column_start: None,
540 column_end: None,
541 package_name: None,
542 comments: Vec::new(),
543 tags: vec!["http endpoint".to_owned()],
544 related_symbols: Vec::new(),
545 related_tests: Vec::new(),
546 risk_level: "low".to_owned(),
547 risk_reasons: Vec::new(),
548 contains_phi: false,
549 has_related_tests: false,
550 updated_at: chrono::Utc::now().to_rfc3339(),
551 data: BTreeMap::new().into_iter().collect(),
552 };
553 doc.data
554 .insert("http_method".to_owned(), Value::String(method.to_owned()));
555 doc.data.insert(
556 "normalized_path".to_owned(),
557 Value::String(normalized_path.to_owned()),
558 );
559 doc.data.insert(
560 "path_aliases".to_owned(),
561 Value::Array(
562 path_aliases(normalized_path)
563 .into_iter()
564 .map(Value::String)
565 .collect(),
566 ),
567 );
568 doc.data.insert(
569 "endpoint_key".to_owned(),
570 Value::String(format!("{method} {normalized_path}")),
571 );
572 apply_artifact_security(&mut doc);
573 doc
574}
575
576fn endpoint_key(method: &str, normalized_path: &str) -> String {
577 format!("{method} {normalized_path}")
578}
579
580fn path_aliases(normalized_path: &str) -> Vec<String> {
581 let segments: Vec<&str> = normalized_path
582 .trim_start_matches('/')
583 .split('/')
584 .filter(|segment| !segment.is_empty())
585 .collect();
586 let mut aliases = BTreeSet::new();
587 if segments.len() < 2 {
588 return aliases.into_iter().collect();
589 }
590 for start in 1..segments.len().saturating_sub(1) {
591 let alias = format!("/{}", segments[start..].join("/"));
592 aliases.insert(alias);
593 }
594 aliases.into_iter().collect()
595}
596
597fn resolved_ui_callers(
598 artifacts: &[ArtifactDoc],
599 hook_name: &str,
600 visited: &mut BTreeSet<String>,
601) -> Vec<CallerEvidence> {
602 if !visited.insert(hook_name.to_owned()) {
603 return Vec::new();
604 }
605
606 let uses: Vec<&ArtifactDoc> = artifacts
607 .iter()
608 .filter(|artifact| {
609 artifact.kind == "frontend_hook_use"
610 && artifact.data.get("hook_def_name").and_then(Value::as_str) == Some(hook_name)
611 })
612 .collect();
613
614 let mut callers = Vec::new();
615 for caller in uses {
616 let component_name = caller
617 .data
618 .get("component")
619 .and_then(Value::as_str)
620 .map(str::to_owned)
621 .or_else(|| infer_component_name(artifacts, caller.source_path.as_deref()));
622
623 if let Some(name) = component_name
624 .as_deref()
625 .filter(|name| name.starts_with("use"))
626 {
627 let nested = resolved_ui_callers(artifacts, name, visited);
628 if nested.is_empty() {
629 callers.push(caller_evidence(component_name, caller));
630 } else {
631 for mut evidence in nested {
632 if let Some(path) = caller.source_path.as_ref() {
633 evidence.source_paths.insert(path.clone());
634 }
635 for test in &caller.related_tests {
636 evidence.related_tests.insert(test.clone());
637 }
638 callers.push(evidence);
639 }
640 }
641 } else {
642 callers.push(caller_evidence(component_name, caller));
643 }
644 }
645
646 visited.remove(hook_name);
647 dedupe_callers(callers)
648}
649
650fn caller_evidence(component_name: Option<String>, caller: &ArtifactDoc) -> CallerEvidence {
651 let mut source_paths = BTreeSet::new();
652 if let Some(path) = caller.source_path.as_ref() {
653 source_paths.insert(path.clone());
654 }
655 CallerEvidence {
656 component_name,
657 component_path: caller.source_path.clone(),
658 line_start: caller.line_start,
659 source_paths,
660 related_tests: caller.related_tests.iter().cloned().collect(),
661 }
662}
663
664fn infer_component_name(artifacts: &[ArtifactDoc], source_path: Option<&str>) -> Option<String> {
665 let source_path = source_path?;
666 let matches: BTreeSet<String> = artifacts
667 .iter()
668 .filter(|artifact| {
669 artifact.kind == "frontend_component"
670 && artifact.source_path.as_deref() == Some(source_path)
671 })
672 .filter_map(|artifact| artifact.name.clone())
673 .collect();
674 if matches.len() == 1 {
675 matches.into_iter().next()
676 } else {
677 None
678 }
679}
680
681fn dedupe_callers(callers: Vec<CallerEvidence>) -> Vec<CallerEvidence> {
682 let mut by_key: BTreeMap<(Option<String>, Option<String>), CallerEvidence> = BTreeMap::new();
683 for caller in callers {
684 let key = (caller.component_name.clone(), caller.component_path.clone());
685 by_key
686 .entry(key)
687 .and_modify(|existing| {
688 existing.source_paths.extend(caller.source_paths.clone());
689 existing.related_tests.extend(caller.related_tests.clone());
690 if existing.line_start.is_none() {
691 existing.line_start = caller.line_start;
692 }
693 })
694 .or_insert(caller);
695 }
696 by_key.into_values().collect()
697}
698
699fn dedupe_candidates(candidates: Vec<FlowCandidate>) -> Vec<FlowCandidate> {
700 let mut by_key: BTreeMap<(usize, Option<String>, Option<String>), FlowCandidate> =
701 BTreeMap::new();
702 for candidate in candidates {
703 let key = (
704 candidate.wrapper_index,
705 candidate.component_name.clone(),
706 candidate.component_path.clone(),
707 );
708 by_key
709 .entry(key)
710 .and_modify(|existing| {
711 existing.source_paths.extend(candidate.source_paths.clone());
712 existing
713 .related_tests
714 .extend(candidate.related_tests.clone());
715 if existing.line_start.is_none() {
716 existing.line_start = candidate.line_start;
717 }
718 })
719 .or_insert(candidate);
720 }
721 by_key.into_values().collect()
722}
723
724impl EndpointRecord {
725 fn display_name(&self) -> String {
726 endpoint_key(&self.method, &self.normalized_path)
727 }
728}
729
730fn edge(
731 repo: &str,
732 edge_type: &str,
733 from: &ArtifactDoc,
734 to: &ArtifactDoc,
735 reason: &str,
736 confidence: f32,
737) -> EdgeDoc {
738 EdgeDoc {
739 id: document_id(
740 repo,
741 "edge",
742 from.source_path.as_deref(),
743 from.line_start,
744 Some(&format!("{edge_type}:{}:{}", from.id, to.id)),
745 ),
746 repo: repo.to_owned(),
747 kind: "edge".to_owned(),
748 edge_type: edge_type.to_owned(),
749 from_id: from.id.clone(),
750 from_kind: from.kind.clone(),
751 from_name: from.name.clone(),
752 to_id: to.id.clone(),
753 to_kind: to.kind.clone(),
754 to_name: to.name.clone(),
755 confidence,
756 reason: reason.to_owned(),
757 source_path: from.source_path.clone(),
758 line_start: from.line_start,
759 risk_level: from.risk_level.clone(),
760 updated_at: chrono::Utc::now().to_rfc3339(),
761 }
762}