Skip to main content

relay_knowledge/application/code_repository/
views.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4    api::{
5        ApiError, ApiMetadata, CodeRepositoryFreshnessDiagnostics, CodeRepositoryFreshnessInput,
6        CodebaseViewResponse, RequestContext,
7    },
8    application::service::RelayKnowledgeService,
9    domain::{
10        CodeImportRecord, CodeRepositorySelector, CodeRepositoryStatus, CodeRouteRecord,
11        CodebaseViewCall, CodebaseViewKind, CodebaseViewRequest, CodebaseViewSnapshot,
12        FreshnessPolicy,
13    },
14};
15
16use super::support::{
17    active_index_matches_request, code_status_checkpoint, indexed_commit_for_selector,
18    indexed_source_scope, latest_compatible_code_scope_status, missing_indexed_source_scope_error,
19    required_code_repository, resolved_code_scope_status, storage_api_error,
20};
21use super::worktree_freshness::ensure_worktree_overlay_matches_current_worktree;
22use views_builder::{DerivedView, SectionRefs, ViewBuilder};
23use views_dependency_tour::derive_dependency_tour;
24use views_rules::{
25    affected_candidate_matches_changed_path, architecture_layer, domain_confidence, domain_token,
26    is_test_config_or_doc, layer_confidence, module_key, normalized_view_paths, path_domain,
27    route_domain,
28};
29
30#[path = "views_builder.rs"]
31mod views_builder;
32#[path = "views_dependency_tour.rs"]
33mod views_dependency_tour;
34#[path = "views_rules.rs"]
35mod views_rules;
36
37const SNAPSHOT_LIMIT_MULTIPLIER: usize = 20;
38const SNAPSHOT_LIMIT_MAX: usize = 2_000;
39const PROCESS_FLOW_CALL_LIMIT: usize = 8;
40
41impl RelayKnowledgeService {
42    /// Builds a deterministic, evidence-backed repository understanding view.
43    pub async fn codebase_view(
44        &self,
45        request: CodebaseViewRequest,
46        context: RequestContext,
47    ) -> Result<CodebaseViewResponse, ApiError> {
48        let store = self.store().await.map_err(storage_api_error)?;
49        let status = required_code_repository(&store, &request.repository.repository).await?;
50        let requested_ref = request.repository.ref_selector.clone();
51        let mut request = view_request_at_indexed_ref(request, &status).await?;
52        if requested_ref == "worktree" {
53            ensure_worktree_overlay_matches_current_worktree(&store, &status, &request.repository)
54                .await?;
55        }
56        let requested_resolved_ref = request.repository.ref_selector.clone();
57        let freshness_target = request.repository.clone();
58        let mut stale_reason = None;
59        let scoped_status = match resolved_code_scope_status(&store, &status, &request.repository)
60            .await
61        {
62            Ok(scoped_status) => scoped_status,
63            Err(error) if request.freshness_policy == FreshnessPolicy::AllowStale => {
64                if !active_index_matches_request(&store, &status, &request.repository).await? {
65                    return Err(error);
66                }
67                let Some(stale_status) =
68                    latest_compatible_code_scope_status(&store, &request.repository).await?
69                else {
70                    return Err(error);
71                };
72                let Some(last_indexed_commit) = stale_status.last_indexed_commit.clone() else {
73                    return Err(error);
74                };
75                request.repository.ref_selector = last_indexed_commit;
76                stale_reason = Some(
77                    "requested ref is not indexed yet; served last completed code index".to_owned(),
78                );
79                stale_status
80            }
81            Err(error) => return Err(error),
82        };
83        if request.freshness_policy == FreshnessPolicy::WaitUntilFresh && scoped_status.stale {
84            return Err(ApiError::invalid_argument(format!(
85                "code repository '{}' scope '{}' is stale; run repo index before deriving codebase views with wait_until_fresh",
86                scoped_status.alias,
87                scoped_status
88                    .last_indexed_scope_id
89                    .as_deref()
90                    .unwrap_or("unscoped")
91            )));
92        }
93        let graph_version = store
94            .current_graph_version()
95            .await
96            .map_err(storage_api_error)?;
97        let source_scope = indexed_source_scope(&scoped_status)
98            .ok_or_else(|| missing_indexed_source_scope_error(&scoped_status))?;
99        let row_limit = request
100            .limit
101            .saturating_mul(SNAPSHOT_LIMIT_MULTIPLIER)
102            .min(SNAPSHOT_LIMIT_MAX);
103        let snapshot = store
104            .codebase_view_snapshot(source_scope, request.clone(), row_limit)
105            .await
106            .map_err(storage_api_error)?;
107        let derived = derive_view(&request, snapshot, row_limit);
108        let direct_source_read_paths = view_source_read_paths(&request, &derived);
109        let degraded_reason = scoped_status
110            .degraded_reason
111            .clone()
112            .or_else(|| stale_reason.clone());
113        let scope_stale = served_scope_is_stale(&scoped_status, &stale_reason);
114        let mut metadata = ApiMetadata::graph_only(&context, graph_version);
115        if scope_stale {
116            metadata.stale = true;
117        }
118        let mut scope = crate::api::CodeRepositoryScopeMetadata::from_status(
119            &scoped_status,
120            &request.repository,
121            requested_ref.clone(),
122        );
123        if scope_stale {
124            scope.stale = true;
125        }
126        let freshness = view_freshness(ViewFreshnessInput {
127            store: &store,
128            base_status: &status,
129            scoped_status: &scoped_status,
130            request: &request,
131            requested_ref,
132            requested_resolved_ref,
133            freshness_target,
134            stale_reason,
135            degraded_reason: degraded_reason.clone(),
136            graph_version: graph_version.get(),
137            direct_source_read_paths,
138        })
139        .await?;
140
141        Ok(CodebaseViewResponse {
142            metadata,
143            scope,
144            freshness,
145            request,
146            graph_version: graph_version.get(),
147            nodes: derived.nodes,
148            edges: derived.edges,
149            sections: derived.sections,
150            evidence: derived.evidence,
151            budget: derived.budget,
152            diagnostics: derived.diagnostics,
153            degraded_reason,
154        })
155    }
156}
157
158fn derive_view(
159    request: &CodebaseViewRequest,
160    snapshot: CodebaseViewSnapshot,
161    row_limit: usize,
162) -> DerivedView {
163    let mut builder = ViewBuilder::new(request.limit, row_limit, snapshot.truncated);
164    match request.view_kind {
165        CodebaseViewKind::ArchitectureLayers => derive_architecture_layers(&mut builder, &snapshot),
166        CodebaseViewKind::BusinessDomains => derive_business_domains(&mut builder, &snapshot),
167        CodebaseViewKind::DependencyTour => derive_dependency_tour(&mut builder, &snapshot),
168        CodebaseViewKind::ProcessFlow => derive_process_flow(&mut builder, &snapshot),
169        CodebaseViewKind::AffectedScope => derive_affected_scope(&mut builder, request, &snapshot),
170    }
171    builder.finish()
172}
173
174fn derive_architecture_layers(builder: &mut ViewBuilder, snapshot: &CodebaseViewSnapshot) {
175    let indexed_paths = snapshot
176        .files
177        .iter()
178        .map(|file| file.path.as_str())
179        .collect::<BTreeSet<_>>();
180    let mut layer_candidates = BTreeMap::<String, (Vec<&str>, Vec<String>)>::new();
181    for file in &snapshot.files {
182        let layer = architecture_layer(&file.path);
183        let evidence_id = builder.evidence(
184            "file",
185            &file.path,
186            None,
187            None,
188            None,
189            format!("{} file in {layer} layer", file.language_id),
190        );
191        let (files, evidence_ids) = layer_candidates.entry(layer.to_owned()).or_default();
192        files.push(file.path.as_str());
193        evidence_ids.push(evidence_id);
194    }
195    let mut ordered_layers = layer_candidates.into_iter().collect::<Vec<_>>();
196    ordered_layers
197        .sort_by_cached_key(|(layer, (files, _))| (std::cmp::Reverse(files.len()), layer.clone()));
198    let mut layer_files = BTreeMap::<String, Vec<&str>>::new();
199    let mut layer_evidence = BTreeMap::<String, Vec<String>>::new();
200    if ordered_layers.len() > builder.limit {
201        builder.mark_node_budget_truncated();
202    }
203    for (layer, (files, evidence_ids)) in ordered_layers.into_iter().take(builder.limit) {
204        let Some(node_id) = builder.node(
205            format!("layer:{layer}"),
206            layer.clone(),
207            "architecture_layer",
208            None,
209            layer_confidence(&layer),
210            evidence_ids.first().cloned(),
211        ) else {
212            continue;
213        };
214        layer_files.insert(node_id.clone(), files);
215        layer_evidence.insert(node_id, evidence_ids);
216    }
217    for import in &snapshot.imports {
218        if let Some(target_path) = resolved_indexed_import_target(import, &indexed_paths) {
219            let source = format!("layer:{}", architecture_layer(&import.path));
220            let target = format!("layer:{}", architecture_layer(target_path));
221            let evidence_id = builder.evidence(
222                "import",
223                &import.path,
224                Some(import.module.clone()),
225                Some(import.line_range.clone()),
226                Some(import.resolution_state.clone()),
227                "import edge between architecture layers",
228            );
229            let source_id = builder.node(
230                source.clone(),
231                source.trim_start_matches("layer:").to_owned(),
232                "architecture_layer",
233                None,
234                0.74,
235                Some(evidence_id.clone()),
236            );
237            let target_id = builder.node(
238                target.clone(),
239                target.trim_start_matches("layer:").to_owned(),
240                "architecture_layer",
241                None,
242                0.74,
243                Some(evidence_id.clone()),
244            );
245            if let (Some(source_id), Some(target_id)) = (source_id, target_id) {
246                builder.edge(&source_id, &target_id, "imports", 0.72, Some(evidence_id));
247            }
248        }
249    }
250    for call in &snapshot.calls {
251        if let Some(target_path) = call.callee_path.as_deref() {
252            let source = format!("layer:{}", architecture_layer(&call.call.path));
253            let target = format!("layer:{}", architecture_layer(target_path));
254            let evidence_id = builder.evidence(
255                "call",
256                &call.call.path,
257                call.call.caller_name.clone(),
258                Some(call.call.line_range.clone()),
259                Some(call.call.resolution_state.clone()),
260                format!("call to {}", call.call.callee_name),
261            );
262            let source_id = builder.node(
263                source.clone(),
264                source.trim_start_matches("layer:").to_owned(),
265                "architecture_layer",
266                None,
267                0.74,
268                Some(evidence_id.clone()),
269            );
270            let target_id = builder.node(
271                target.clone(),
272                target.trim_start_matches("layer:").to_owned(),
273                "architecture_layer",
274                None,
275                0.74,
276                Some(evidence_id.clone()),
277            );
278            if let (Some(source_id), Some(target_id)) = (source_id, target_id) {
279                builder.edge(&source_id, &target_id, "calls", 0.76, Some(evidence_id));
280            }
281        }
282    }
283    let mut ordered_layers = layer_files.into_iter().collect::<Vec<_>>();
284    ordered_layers
285        .sort_by(|left, right| right.1.len().cmp(&left.1.len()).then(left.0.cmp(&right.0)));
286    for (node_id, files) in ordered_layers.into_iter().take(builder.limit) {
287        let layer = node_id.trim_start_matches("layer:");
288        let evidence_ids = layer_evidence.remove(&node_id).unwrap_or_default();
289        builder.section(
290            format!("section:{node_id}"),
291            format!("{layer} layer"),
292            format!(
293                "{layer} contains {} indexed file(s) and is derived from path and graph boundary evidence.",
294                files.len()
295            ),
296            layer_confidence(layer),
297            SectionRefs {
298                node_ids: vec![node_id],
299                evidence_ids,
300                ..SectionRefs::default()
301            },
302        );
303    }
304}
305
306fn derive_business_domains(builder: &mut ViewBuilder, snapshot: &CodebaseViewSnapshot) {
307    let mut domains = BTreeMap::<String, Vec<String>>::new();
308    for route in &snapshot.routes {
309        if let Some(domain) = route_domain(&route.url) {
310            let evidence_id = builder.evidence(
311                "route",
312                &route.path,
313                Some(route.handler_name.clone()),
314                Some(route.line_range.clone()),
315                Some(route.http_method.clone()),
316                format!("{} {}", route.http_method, route.url),
317            );
318            domains.entry(domain).or_default().push(evidence_id);
319        }
320    }
321    for flag in &snapshot.feature_flags {
322        if let Some(domain) = domain_token(&flag.name) {
323            let evidence_id = builder.evidence(
324                "feature_flag",
325                &flag.path,
326                Some(flag.name.clone()),
327                Some(flag.line_range.clone()),
328                Some(flag.edge_kind.clone()),
329                format!("feature flag {}", flag.source_key),
330            );
331            domains.entry(domain).or_default().push(evidence_id);
332        }
333    }
334    for file in &snapshot.files {
335        if let Some(domain) = path_domain(&file.path) {
336            let evidence_id = builder.evidence(
337                "path",
338                &file.path,
339                None,
340                None,
341                None,
342                "domain-like path segment",
343            );
344            domains.entry(domain).or_default().push(evidence_id);
345        }
346    }
347    let mut ordered = domains.into_iter().collect::<Vec<_>>();
348    ordered.sort_by(|left, right| right.1.len().cmp(&left.1.len()).then(left.0.cmp(&right.0)));
349    if ordered.len() > builder.limit {
350        builder.mark_node_budget_truncated();
351    }
352    for (domain, evidence_ids) in ordered.into_iter().take(builder.limit) {
353        let node_id = builder.node(
354            format!("domain:{domain}"),
355            domain.clone(),
356            "business_domain",
357            None,
358            domain_confidence(evidence_ids.len()),
359            evidence_ids.first().cloned(),
360        );
361        builder.section(
362            format!("section:domain:{domain}"),
363            format!("{domain} domain"),
364            format!(
365                "{domain} is a candidate business domain from {} route, feature flag, or path signal(s).",
366                evidence_ids.len()
367            ),
368            domain_confidence(evidence_ids.len()),
369            SectionRefs {
370                node_ids: node_id.into_iter().collect(),
371                evidence_ids,
372                ..SectionRefs::default()
373            },
374        );
375    }
376}
377
378fn resolved_indexed_import_target<'a>(
379    import: &'a CodeImportRecord,
380    indexed_paths: &BTreeSet<&str>,
381) -> Option<&'a str> {
382    let target_path = import.target_hint.as_deref()?;
383    (import.resolution_state == "resolved" && indexed_paths.contains(target_path))
384        .then_some(target_path)
385}
386
387fn derive_process_flow(builder: &mut ViewBuilder, snapshot: &CodebaseViewSnapshot) {
388    for route in snapshot.routes.iter().take(builder.limit) {
389        let route_node_key = format!("route:{}", route.route_id);
390        let handler_node_key = route_handler_node_id(route);
391        let required_nodes =
392            usize::from(builder.existing_node_id(route_node_key.clone()).is_none())
393                + usize::from(builder.existing_node_id(handler_node_key.clone()).is_none());
394        let route_evidence = builder.evidence(
395            "route",
396            &route.path,
397            Some(route.handler_name.clone()),
398            Some(route.line_range.clone()),
399            Some(route.http_method.clone()),
400            format!("{} {}", route.http_method, route.url),
401        );
402        if !builder.can_insert_nodes(required_nodes) {
403            builder.mark_node_budget_truncated();
404            let route_id = builder.node(
405                route_node_key,
406                format!("{} {}", route.http_method.to_uppercase(), route.url),
407                "route",
408                Some(route.path.clone()),
409                0.86,
410                Some(route_evidence.clone()),
411            );
412            if let Some(route_id) = route_id {
413                builder.section(
414                    format!("section:route:{}", route.route_id),
415                    format!("{} {}", route.http_method.to_uppercase(), route.url),
416                    format!(
417                        "Request flow starts at route {} {}; handler details were omitted by the node limit.",
418                        route.http_method.to_uppercase(),
419                        route.url
420                    ),
421                    0.72,
422                    SectionRefs {
423                        node_ids: vec![route_id],
424                        evidence_ids: vec![route_evidence],
425                        diagnostics: vec![
426                            "process-flow handler details truncated by node limit".to_owned(),
427                        ],
428                        ..SectionRefs::default()
429                    },
430                );
431            }
432            break;
433        }
434        let route_id = builder.node(
435            route_node_key,
436            format!("{} {}", route.http_method.to_uppercase(), route.url),
437            "route",
438            Some(route.path.clone()),
439            0.86,
440            Some(route_evidence.clone()),
441        );
442        let Some(route_id) = route_id else {
443            break;
444        };
445        let matching_calls = snapshot
446            .calls
447            .iter()
448            .filter(|call| call_belongs_to_route_flow(call, route))
449            .collect::<Vec<_>>();
450        let handler_path = route_handler_path(route, snapshot, &matching_calls);
451        let handler_id = builder.node(
452            handler_node_key,
453            route.handler_name.clone(),
454            "handler",
455            Some(handler_path),
456            0.82,
457            Some(route_evidence.clone()),
458        );
459        let Some(handler_id) = handler_id else {
460            break;
461        };
462        let mut edge_ids = Vec::new();
463        if let Some(edge_id) = builder.edge(
464            &route_id,
465            &handler_id,
466            "handled_by",
467            0.86,
468            Some(route_evidence.clone()),
469        ) {
470            edge_ids.push(edge_id);
471        }
472        let mut node_ids = vec![route_id, handler_id.clone()];
473        let mut evidence_ids = vec![route_evidence];
474        let mut diagnostics = Vec::new();
475        if matching_calls.len() > PROCESS_FLOW_CALL_LIMIT {
476            builder.mark_edge_budget_truncated();
477            diagnostics.push(format!(
478                "route handler calls truncated to {PROCESS_FLOW_CALL_LIMIT} matching calls"
479            ));
480        }
481        for call in matching_calls.into_iter().take(PROCESS_FLOW_CALL_LIMIT) {
482            let call_evidence = builder.evidence(
483                "call",
484                &call.call.path,
485                call.call.caller_name.clone(),
486                Some(call.call.line_range.clone()),
487                Some(call.call.resolution_state.clone()),
488                format!("handler call to {}", call.call.callee_name),
489            );
490            let callee_id = builder.node(
491                call_target_node_id(call),
492                call.call.callee_name.clone(),
493                "call_target",
494                call.callee_path.clone(),
495                0.68,
496                Some(call_evidence.clone()),
497            );
498            if let Some(callee_id) = callee_id {
499                if let Some(edge_id) = builder.edge(
500                    &handler_id,
501                    &callee_id,
502                    "calls",
503                    0.68,
504                    Some(call_evidence.clone()),
505                ) {
506                    edge_ids.push(edge_id);
507                    node_ids.push(callee_id);
508                }
509            }
510            evidence_ids.push(call_evidence);
511        }
512        builder.section(
513            format!("section:route:{}", route.route_id),
514            format!("{} {}", route.http_method.to_uppercase(), route.url),
515            format!(
516                "Request flow starts at route {} {} and reaches handler {}.",
517                route.http_method.to_uppercase(),
518                route.url,
519                route.handler_name
520            ),
521            0.78,
522            SectionRefs {
523                node_ids,
524                edge_ids,
525                evidence_ids,
526                diagnostics,
527            },
528        );
529    }
530}
531
532fn call_target_node_id(call: &CodebaseViewCall) -> String {
533    if let Some(symbol_id) = call.call.callee_symbol_snapshot_id.as_deref() {
534        return format!("call_target:symbol:{symbol_id}");
535    }
536    if let Some(path) = call.callee_path.as_deref() {
537        return format!("call_target:path:{path}:{}", call.call.callee_name);
538    }
539    format!("call_target:{}:{}", call.call.path, call.call.callee_name)
540}
541
542fn route_handler_node_id(route: &CodeRouteRecord) -> String {
543    route
544        .handler_symbol_snapshot_id
545        .as_ref()
546        .map(|symbol_id| format!("handler:symbol:{symbol_id}"))
547        .unwrap_or_else(|| format!("handler:{}:{}", route.path, route.route_id))
548}
549
550fn route_handler_path(
551    route: &CodeRouteRecord,
552    snapshot: &CodebaseViewSnapshot,
553    matching_calls: &[&CodebaseViewCall],
554) -> String {
555    if let Some(handler_symbol_id) = route.handler_symbol_snapshot_id.as_deref() {
556        if let Some(symbol) = snapshot
557            .symbols
558            .iter()
559            .find(|symbol| symbol.symbol_snapshot_id == handler_symbol_id)
560        {
561            return symbol.path.clone();
562        }
563        if let Some(call) = matching_calls
564            .iter()
565            .find(|call| call.call.caller_symbol_snapshot_id.as_deref() == Some(handler_symbol_id))
566        {
567            return call.call.path.clone();
568        }
569    }
570    matching_calls
571        .iter()
572        .find(|call| call.call.path != route.path)
573        .map(|call| call.call.path.clone())
574        .unwrap_or_else(|| route.path.clone())
575}
576
577fn call_matches_route_handler(call: &CodebaseViewCall, route: &CodeRouteRecord) -> bool {
578    if let (Some(caller_symbol_id), Some(handler_symbol_id)) = (
579        call.call.caller_symbol_snapshot_id.as_deref(),
580        route.handler_symbol_snapshot_id.as_deref(),
581    ) {
582        return caller_symbol_id == handler_symbol_id;
583    }
584    let Some(caller_name) = call.call.caller_name.as_deref() else {
585        return false;
586    };
587    same_symbol_leaf(caller_name, &route.handler_name)
588}
589
590fn call_belongs_to_route_flow(call: &CodebaseViewCall, route: &CodeRouteRecord) -> bool {
591    if call.call.path == route.path {
592        return call_matches_route_handler(call, route);
593    }
594    if let (Some(caller_symbol_id), Some(handler_symbol_id)) = (
595        call.call.caller_symbol_snapshot_id.as_deref(),
596        route.handler_symbol_snapshot_id.as_deref(),
597    ) {
598        return caller_symbol_id == handler_symbol_id;
599    }
600    let Some(caller_name) = call.call.caller_name.as_deref() else {
601        return false;
602    };
603    names_are_qualified(caller_name)
604        && names_are_qualified(&route.handler_name)
605        && symbol_leaf(caller_name) == symbol_leaf(&route.handler_name)
606}
607
608fn same_symbol_leaf(left: &str, right: &str) -> bool {
609    left == right
610        || symbol_leaf(left) == right
611        || symbol_leaf(right) == left
612        || symbol_leaf(left) == symbol_leaf(right)
613}
614
615fn symbol_leaf(name: &str) -> &str {
616    name.rsplit([':', '.', '#', '/'])
617        .find(|part| !part.is_empty())
618        .unwrap_or(name)
619}
620
621fn names_are_qualified(name: &str) -> bool {
622    name.contains([':', '.', '#', '/'])
623}
624
625fn derive_affected_scope(
626    builder: &mut ViewBuilder,
627    request: &CodebaseViewRequest,
628    snapshot: &CodebaseViewSnapshot,
629) {
630    if request.changed_paths.is_empty() {
631        let diagnostic =
632            "affected_scope requires one or more --changed-path values in deterministic v1"
633                .to_owned();
634        builder.diagnostic(diagnostic.clone());
635        builder.section(
636            "section:affected_scope:missing_changes".to_owned(),
637            "Affected scope needs changed paths".to_owned(),
638            "No affected scope was derived because changed paths were not provided.".to_owned(),
639            0.0,
640            SectionRefs {
641                diagnostics: vec![diagnostic],
642                ..SectionRefs::default()
643            },
644        );
645        return;
646    }
647    let changed_paths = normalized_view_paths(&request.changed_paths);
648    if changed_paths.is_empty() {
649        let diagnostic =
650            "affected_scope requires one or more --changed-path values in deterministic v1"
651                .to_owned();
652        builder.diagnostic(diagnostic.clone());
653        builder.section(
654            "section:affected_scope:missing_changes".to_owned(),
655            "Affected scope needs changed paths".to_owned(),
656            "No affected scope was derived because changed paths were not provided.".to_owned(),
657            0.0,
658            SectionRefs {
659                diagnostics: vec![diagnostic],
660                ..SectionRefs::default()
661            },
662        );
663        return;
664    }
665    let changed = changed_paths.iter().cloned().collect::<BTreeSet<_>>();
666    let affected_calls = snapshot
667        .calls
668        .iter()
669        .filter(|call| affected_call_matches_changed_paths(call, &changed, &changed_paths))
670        .collect::<Vec<_>>();
671    let verification_candidates = snapshot
672        .files
673        .iter()
674        .filter(|file| is_test_config_or_doc(&file.path))
675        .filter(|file| {
676            changed_paths.iter().any(|changed_path| {
677                affected_candidate_matches_changed_path(changed_path, &file.path)
678            })
679        })
680        .collect::<Vec<_>>();
681    let has_derived_scope = !affected_calls.is_empty() || !verification_candidates.is_empty();
682    let changed_file_node_limit =
683        affected_changed_file_node_limit(builder.limit, changed_paths.len(), has_derived_scope);
684    let mut node_ids = Vec::new();
685    let mut edge_ids = Vec::new();
686    let mut evidence_ids = Vec::new();
687    let mut diagnostics = Vec::new();
688    for (index, path) in changed_paths.iter().enumerate() {
689        let evidence_id = builder.evidence("changed_path", path, None, None, None, "changed input");
690        if index < changed_file_node_limit {
691            let node_id = builder.node(
692                format!("file:{path}"),
693                path.clone(),
694                "changed_file",
695                Some(path.clone()),
696                0.90,
697                Some(evidence_id.clone()),
698            );
699            if let Some(node_id) = node_id {
700                node_ids.push(node_id);
701            }
702        }
703        evidence_ids.push(evidence_id);
704    }
705    if changed_file_node_limit < changed_paths.len() {
706        builder.mark_node_budget_truncated();
707        diagnostics.push(format!(
708            "changed file nodes summarized from {} paths to preserve affected modules and verification candidates",
709            changed_paths.len()
710        ));
711    }
712    for call in affected_calls {
713        let target_path = call
714            .callee_path
715            .clone()
716            .unwrap_or_else(|| call.call.path.clone());
717        let evidence_id = builder.evidence(
718            "call",
719            &call.call.path,
720            call.call.caller_name.clone(),
721            Some(call.call.line_range.clone()),
722            Some(call.call.resolution_state.clone()),
723            format!("affected call to {}", call.call.callee_name),
724        );
725        let source_id = builder.node(
726            format!("module:{}", module_key(&call.call.path)),
727            module_key(&call.call.path),
728            "affected_module",
729            Some(call.call.path.clone()),
730            0.70,
731            Some(evidence_id.clone()),
732        );
733        let target_id = builder.node(
734            format!("module:{}", module_key(&target_path)),
735            module_key(&target_path),
736            "affected_module",
737            Some(target_path),
738            0.70,
739            Some(evidence_id.clone()),
740        );
741        if let (Some(source_id), Some(target_id)) = (&source_id, &target_id) {
742            if let Some(edge_id) = builder.edge(
743                source_id,
744                target_id,
745                "affected_call",
746                0.70,
747                Some(evidence_id),
748            ) {
749                edge_ids.push(edge_id);
750            }
751        }
752        node_ids.extend([source_id, target_id].into_iter().flatten());
753    }
754    for file in verification_candidates.into_iter().take(builder.limit) {
755        let evidence_id = builder.evidence(
756            "candidate",
757            &file.path,
758            None,
759            None,
760            None,
761            "test, configuration, or documentation candidate in changed module",
762        );
763        let node_id = builder.node(
764            format!("candidate:{}", file.path),
765            file.path.clone(),
766            "verification_candidate",
767            Some(file.path.clone()),
768            0.62,
769            Some(evidence_id.clone()),
770        );
771        if let Some(node_id) = node_id {
772            node_ids.push(node_id);
773        }
774        evidence_ids.push(evidence_id);
775    }
776    node_ids.sort();
777    node_ids.dedup();
778    builder.section(
779        "section:affected_scope".to_owned(),
780        "Affected scope".to_owned(),
781        format!(
782            "Affected scope was derived from {} changed path(s), call edges, and nearby verification candidates.",
783            changed_paths.len()
784        ),
785        0.68,
786        SectionRefs {
787            node_ids,
788            edge_ids,
789            evidence_ids,
790            diagnostics,
791        },
792    );
793}
794
795fn affected_call_matches_changed_paths(
796    call: &CodebaseViewCall,
797    changed: &BTreeSet<String>,
798    changed_paths: &[String],
799) -> bool {
800    changed.contains(&call.call.path)
801        || path_matches_changed_prefix(&call.call.path, changed_paths)
802        || call.callee_path.as_ref().is_some_and(|path| {
803            changed.contains(path) || path_matches_changed_prefix(path, changed_paths)
804        })
805}
806
807fn affected_changed_file_node_limit(
808    node_limit: usize,
809    changed_path_count: usize,
810    has_derived_scope: bool,
811) -> usize {
812    if !has_derived_scope {
813        return changed_path_count.min(node_limit);
814    }
815    changed_path_count.min((node_limit / 3).max(1))
816}
817
818fn path_matches_changed_prefix(path: &str, changed_paths: &[String]) -> bool {
819    changed_paths.iter().any(|changed_path| {
820        path.strip_prefix(changed_path)
821            .is_some_and(|tail| tail.starts_with('/'))
822    })
823}
824
825async fn view_request_at_indexed_ref(
826    mut request: CodebaseViewRequest,
827    status: &CodeRepositoryStatus,
828) -> Result<CodebaseViewRequest, ApiError> {
829    request.repository.ref_selector = indexed_commit_for_selector(
830        status,
831        &request.repository,
832        request.repository.ref_selector.clone(),
833    )
834    .await?;
835
836    Ok(request)
837}
838
839struct ViewFreshnessInput<'a> {
840    store: &'a std::sync::Arc<dyn crate::storage::KnowledgeStore>,
841    base_status: &'a CodeRepositoryStatus,
842    scoped_status: &'a CodeRepositoryStatus,
843    request: &'a CodebaseViewRequest,
844    requested_ref: String,
845    requested_resolved_ref: String,
846    freshness_target: CodeRepositorySelector,
847    stale_reason: Option<String>,
848    degraded_reason: Option<String>,
849    graph_version: u64,
850    direct_source_read_paths: Vec<String>,
851}
852
853async fn view_freshness(
854    input: ViewFreshnessInput<'_>,
855) -> Result<CodeRepositoryFreshnessDiagnostics, ApiError> {
856    let active_task = input
857        .store
858        .active_code_index_task(input.base_status.repository_id.clone())
859        .await
860        .map_err(storage_api_error)?;
861    let queue = input
862        .store
863        .code_index_task_queue_status()
864        .await
865        .map_err(storage_api_error)?;
866    let active_matches_request =
867        active_index_matches_request(input.store, input.base_status, &input.freshness_target)
868            .await?;
869    let pending = crate::api::CodeRepositoryPendingIndexWork::from_task_and_queue(
870        active_task.as_ref(),
871        active_matches_request,
872        queue,
873    );
874    let checkpoint = if active_matches_request {
875        code_status_checkpoint(input.store, input.scoped_status, active_task.as_ref()).await?
876    } else if let Some(scope) = input.scoped_status.last_indexed_scope_id.clone() {
877        input
878            .store
879            .code_index_checkpoint(scope)
880            .await
881            .map_err(storage_api_error)?
882    } else {
883        None
884    };
885    let cursor = checkpoint
886        .as_ref()
887        .map(crate::api::CodeRepositoryFreshnessCursor::from_checkpoint);
888    let served_ref = input
889        .scoped_status
890        .last_indexed_commit
891        .clone()
892        .unwrap_or_else(|| input.request.repository.ref_selector.clone());
893
894    Ok(CodeRepositoryFreshnessDiagnostics::code_query(
895        CodeRepositoryFreshnessInput {
896            graph_version: input.graph_version,
897            freshness_policy: input.request.freshness_policy,
898            source_scope: indexed_source_scope(input.scoped_status),
899            requested_ref: input.requested_ref,
900            requested_resolved_ref: input.requested_resolved_ref,
901            served_ref,
902            scope_stale: served_scope_is_stale(input.scoped_status, &input.stale_reason),
903            stale_reason: input.stale_reason,
904            degraded_reason: input.degraded_reason,
905            pending,
906            cursor,
907            direct_source_read_paths: input.direct_source_read_paths,
908        },
909    ))
910}
911
912fn view_source_read_paths(request: &CodebaseViewRequest, derived: &DerivedView) -> Vec<String> {
913    let mut paths = BTreeSet::new();
914    if request.view_kind == CodebaseViewKind::AffectedScope {
915        paths.extend(normalized_view_paths(&request.changed_paths));
916    }
917    paths.extend(
918        derived
919            .evidence
920            .iter()
921            .map(|evidence| evidence.path.clone())
922            .filter(|path| !path.is_empty()),
923    );
924    paths.extend(
925        derived
926            .nodes
927            .iter()
928            .filter_map(|node| node.path.clone())
929            .filter(|path| !path.is_empty()),
930    );
931    paths.into_iter().collect()
932}
933
934fn served_scope_is_stale(status: &CodeRepositoryStatus, stale_reason: &Option<String>) -> bool {
935    status.stale || stale_reason.is_some()
936}
937
938#[cfg(test)]
939#[path = "views_affected_scope_tests.rs"]
940mod affected_scope_tests;
941#[cfg(test)]
942#[path = "views_dependency_tour_tests.rs"]
943mod dependency_tour_tests;
944#[cfg(test)]
945#[path = "views_tests.rs"]
946mod tests;