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 EndpointRecord {
25 method: String,
26 normalized_path: String,
27 wrapper_indices: Vec<usize>,
28 endpoint_index: usize,
29}
30
31pub fn augment_frontend_http_flows(
32 artifacts: &mut Vec<ArtifactDoc>,
33 edges: &mut Vec<EdgeDoc>,
34 _warnings: &mut Vec<WarningDoc>,
35) -> Result<()> {
36 let mut transport_by_name: BTreeMap<String, usize> = BTreeMap::new();
37 let mut wrappers_by_name: BTreeMap<String, Vec<usize>> = BTreeMap::new();
38 let mut endpoint_records: BTreeMap<String, EndpointRecord> = BTreeMap::new();
39
40 for (index, artifact) in artifacts.iter().enumerate() {
41 if artifact.kind == "frontend_transport" {
42 if let Some(name) = &artifact.name {
43 transport_by_name.insert(name.clone(), index);
44 }
45 }
46 if artifact.kind == "frontend_api_wrapper" {
47 if let Some(name) = &artifact.name {
48 wrappers_by_name
49 .entry(name.clone())
50 .or_default()
51 .push(index);
52 }
53 }
54 }
55
56 let wrapper_indices: Vec<usize> = artifacts
57 .iter()
58 .enumerate()
59 .filter_map(|(index, artifact)| (artifact.kind == "frontend_api_wrapper").then_some(index))
60 .collect();
61
62 for wrapper_index in wrapper_indices {
63 let Some(method) = artifacts[wrapper_index]
64 .data
65 .get("http_method")
66 .and_then(Value::as_str)
67 .map(str::to_owned)
68 else {
69 continue;
70 };
71 let Some(normalized_path) = artifacts[wrapper_index]
72 .data
73 .get("normalized_path")
74 .and_then(Value::as_str)
75 .map(str::to_owned)
76 else {
77 continue;
78 };
79 let key = endpoint_key(&method, &normalized_path);
80 if let Some(record) = endpoint_records.get_mut(&key) {
81 record.wrapper_indices.push(wrapper_index);
82 continue;
83 }
84
85 let endpoint = endpoint_artifact(
86 &artifacts[wrapper_index].repo,
87 &method,
88 &normalized_path,
89 artifacts[wrapper_index].source_path.as_deref(),
90 artifacts[wrapper_index].line_start,
91 );
92 let endpoint_index = artifacts.len();
93 artifacts.push(endpoint);
94 endpoint_records.insert(
95 key,
96 EndpointRecord {
97 method,
98 normalized_path,
99 wrapper_indices: vec![wrapper_index],
100 endpoint_index,
101 },
102 );
103 }
104
105 let hook_use_indices: Vec<usize> = artifacts
106 .iter()
107 .enumerate()
108 .filter_map(|(index, artifact)| (artifact.kind == "frontend_hook_use").then_some(index))
109 .collect();
110
111 for hook_use_index in hook_use_indices {
112 let Some(wrapper_name) = artifacts[hook_use_index]
113 .data
114 .get("hook_def_name")
115 .and_then(Value::as_str)
116 else {
117 continue;
118 };
119 let Some(wrapper_indices) = wrappers_by_name.get(wrapper_name).cloned() else {
120 continue;
121 };
122 for wrapper_index in wrapper_indices {
123 edges.push(edge(
124 &artifacts[hook_use_index].repo,
125 "calls_api_wrapper",
126 &artifacts[hook_use_index],
127 &artifacts[wrapper_index],
128 "hook callsite name matches API wrapper definition",
129 0.97,
130 ));
131 }
132 }
133
134 let endpoint_records_list: Vec<EndpointRecord> = endpoint_records.values().cloned().collect();
135 for record in endpoint_records_list {
136 for wrapper_index in &record.wrapper_indices {
137 if let Some(transport_name) = artifacts[*wrapper_index]
138 .data
139 .get("transport_name")
140 .and_then(Value::as_str)
141 {
142 if let Some(transport_index) = transport_by_name.get(transport_name).copied() {
143 edges.push(edge(
144 &artifacts[*wrapper_index].repo,
145 "uses_transport",
146 &artifacts[*wrapper_index],
147 &artifacts[transport_index],
148 "wrapper transport name matches transport definition",
149 0.98,
150 ));
151 edges.push(edge(
152 &artifacts[transport_index].repo,
153 "calls_http_route",
154 &artifacts[transport_index],
155 &artifacts[record.endpoint_index],
156 "transport method and wrapper path resolve to endpoint",
157 0.98,
158 ));
159 } else {
160 edges.push(edge(
161 &artifacts[*wrapper_index].repo,
162 "calls_http_route",
163 &artifacts[*wrapper_index],
164 &artifacts[record.endpoint_index],
165 "wrapper directly resolves endpoint",
166 0.92,
167 ));
168 }
169 } else {
170 edges.push(edge(
171 &artifacts[*wrapper_index].repo,
172 "calls_http_route",
173 &artifacts[*wrapper_index],
174 &artifacts[record.endpoint_index],
175 "wrapper directly resolves endpoint",
176 0.9,
177 ));
178 }
179 }
180
181 let candidates =
182 flow_candidates_for_endpoint(artifacts, &transport_by_name, &record.wrapper_indices);
183 if candidates.is_empty() {
184 continue;
185 }
186 let Some(best) = canonical_candidate(&candidates, artifacts) else {
187 continue;
188 };
189 let flow = flow_artifact(artifacts, &record, &candidates, &best);
190 let flow_index = artifacts.len();
191 artifacts.push(flow);
192 edges.push(edge(
193 &artifacts[flow_index].repo,
194 "contains",
195 &artifacts[flow_index],
196 &artifacts[record.endpoint_index],
197 "flow summarizes canonical frontend HTTP chain",
198 1.0,
199 ));
200 }
201
202 Ok(())
203}
204
205fn flow_candidates_for_endpoint(
206 artifacts: &[ArtifactDoc],
207 transport_by_name: &BTreeMap<String, usize>,
208 wrapper_indices: &[usize],
209) -> Vec<FlowCandidate> {
210 let mut candidates = Vec::new();
211 for wrapper_index in wrapper_indices {
212 let wrapper = &artifacts[*wrapper_index];
213 let transport_index = wrapper
214 .data
215 .get("transport_name")
216 .and_then(Value::as_str)
217 .and_then(|name| transport_by_name.get(name).copied());
218 let callers: Vec<&ArtifactDoc> = artifacts
219 .iter()
220 .filter(|artifact| {
221 artifact.kind == "frontend_hook_use"
222 && artifact.data.get("hook_def_name").and_then(Value::as_str)
223 == wrapper.name.as_deref()
224 })
225 .collect();
226
227 if callers.is_empty() {
228 let mut source_paths = BTreeSet::new();
229 if let Some(path) = wrapper.source_path.as_ref() {
230 source_paths.insert(path.clone());
231 }
232 let related_tests = wrapper.related_tests.iter().cloned().collect();
233 candidates.push(FlowCandidate {
234 component_name: None,
235 component_path: None,
236 line_start: wrapper.line_start,
237 wrapper_index: *wrapper_index,
238 transport_index,
239 source_paths,
240 related_tests,
241 });
242 continue;
243 }
244
245 for caller in callers {
246 let mut source_paths = BTreeSet::new();
247 let mut related_tests = BTreeSet::new();
248 if let Some(path) = caller.source_path.as_ref() {
249 source_paths.insert(path.clone());
250 }
251 if let Some(path) = wrapper.source_path.as_ref() {
252 source_paths.insert(path.clone());
253 }
254 if let Some(index) = transport_index {
255 if let Some(path) = artifacts[index].source_path.as_ref() {
256 source_paths.insert(path.clone());
257 }
258 for test in &artifacts[index].related_tests {
259 related_tests.insert(test.clone());
260 }
261 }
262 for test in caller
263 .related_tests
264 .iter()
265 .chain(wrapper.related_tests.iter())
266 {
267 related_tests.insert(test.clone());
268 }
269 candidates.push(FlowCandidate {
270 component_name: caller
271 .data
272 .get("component")
273 .and_then(Value::as_str)
274 .map(str::to_owned),
275 component_path: caller.source_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 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 "primary_component".to_owned(),
416 best.component_name
417 .clone()
418 .map(Value::String)
419 .unwrap_or(Value::Null),
420 );
421 doc.data.insert(
422 "primary_component_path".to_owned(),
423 best.component_path
424 .clone()
425 .map(Value::String)
426 .unwrap_or(Value::Null),
427 );
428 doc.data.insert(
429 "primary_wrapper".to_owned(),
430 wrapper
431 .name
432 .clone()
433 .map(Value::String)
434 .unwrap_or(Value::Null),
435 );
436 doc.data.insert(
437 "primary_wrapper_path".to_owned(),
438 wrapper
439 .source_path
440 .clone()
441 .map(Value::String)
442 .unwrap_or(Value::Null),
443 );
444 doc.data.insert(
445 "primary_transport".to_owned(),
446 transport
447 .and_then(|artifact| artifact.name.clone())
448 .map(Value::String)
449 .unwrap_or(Value::Null),
450 );
451 doc.data.insert(
452 "primary_transport_path".to_owned(),
453 transport
454 .and_then(|artifact| artifact.source_path.clone())
455 .map(Value::String)
456 .unwrap_or(Value::Null),
457 );
458 doc.data
459 .insert("caller_count".to_owned(), json!(candidates.len()));
460 doc.data.insert(
461 "alternate_components".to_owned(),
462 Value::Array(
463 alternate_components
464 .into_iter()
465 .map(Value::String)
466 .collect(),
467 ),
468 );
469 doc.data.insert(
470 "source_paths".to_owned(),
471 Value::Array(source_paths.into_iter().map(Value::String).collect()),
472 );
473 doc.data.insert(
474 "primary_flow".to_owned(),
475 json!([
476 {
477 "kind": "frontend_component",
478 "name": best.component_name,
479 "path": best.component_path,
480 "line": best.line_start,
481 },
482 {
483 "kind": "frontend_api_wrapper",
484 "name": wrapper.name,
485 "path": wrapper.source_path,
486 "line": wrapper.line_start,
487 },
488 {
489 "kind": "frontend_transport",
490 "name": transport.and_then(|artifact| artifact.name.clone()),
491 "path": transport.and_then(|artifact| artifact.source_path.clone()),
492 "line": transport.and_then(|artifact| artifact.line_start),
493 },
494 {
495 "kind": "frontend_http_endpoint",
496 "method": endpoint.method,
497 "path": endpoint.normalized_path,
498 }
499 ]),
500 );
501
502 apply_artifact_security(&mut doc);
503 doc
504}
505
506fn endpoint_artifact(
507 repo: &str,
508 method: &str,
509 normalized_path: &str,
510 source_path: Option<&str>,
511 line_start: Option<u32>,
512) -> ArtifactDoc {
513 let mut doc = ArtifactDoc {
514 id: document_id(
515 repo,
516 "frontend_http_endpoint",
517 source_path,
518 line_start,
519 Some(&format!("{method} {normalized_path}")),
520 ),
521 repo: repo.to_owned(),
522 kind: "frontend_http_endpoint".to_owned(),
523 side: Some("frontend".to_owned()),
524 language: Some("ts".to_owned()),
525 name: Some(normalized_path.to_owned()),
526 display_name: Some(format!("{method} {normalized_path}")),
527 source_path: source_path.map(str::to_owned),
528 line_start,
529 line_end: line_start,
530 column_start: None,
531 column_end: None,
532 package_name: None,
533 comments: Vec::new(),
534 tags: vec!["http endpoint".to_owned()],
535 related_symbols: Vec::new(),
536 related_tests: Vec::new(),
537 risk_level: "low".to_owned(),
538 risk_reasons: Vec::new(),
539 contains_phi: false,
540 has_related_tests: false,
541 updated_at: chrono::Utc::now().to_rfc3339(),
542 data: BTreeMap::new().into_iter().collect(),
543 };
544 doc.data
545 .insert("http_method".to_owned(), Value::String(method.to_owned()));
546 doc.data.insert(
547 "normalized_path".to_owned(),
548 Value::String(normalized_path.to_owned()),
549 );
550 doc.data.insert(
551 "endpoint_key".to_owned(),
552 Value::String(format!("{method} {normalized_path}")),
553 );
554 apply_artifact_security(&mut doc);
555 doc
556}
557
558fn endpoint_key(method: &str, normalized_path: &str) -> String {
559 format!("{method} {normalized_path}")
560}
561
562impl EndpointRecord {
563 fn display_name(&self) -> String {
564 endpoint_key(&self.method, &self.normalized_path)
565 }
566}
567
568fn edge(
569 repo: &str,
570 edge_type: &str,
571 from: &ArtifactDoc,
572 to: &ArtifactDoc,
573 reason: &str,
574 confidence: f32,
575) -> EdgeDoc {
576 EdgeDoc {
577 id: document_id(
578 repo,
579 "edge",
580 from.source_path.as_deref(),
581 from.line_start,
582 Some(&format!("{edge_type}:{}:{}", from.id, to.id)),
583 ),
584 repo: repo.to_owned(),
585 kind: "edge".to_owned(),
586 edge_type: edge_type.to_owned(),
587 from_id: from.id.clone(),
588 from_kind: from.kind.clone(),
589 from_name: from.name.clone(),
590 to_id: to.id.clone(),
591 to_kind: to.kind.clone(),
592 to_name: to.name.clone(),
593 confidence,
594 reason: reason.to_owned(),
595 source_path: from.source_path.clone(),
596 line_start: from.line_start,
597 risk_level: from.risk_level.clone(),
598 updated_at: chrono::Utc::now().to_rfc3339(),
599 }
600}