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