Skip to main content

source_map_tauri/linker/
http_flows.rs

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}