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 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}