1use crate::rank::terms;
9use crate::structural_source::file_handle;
10use crate::{ContextCompiler, ContextPack};
11use scc_core::kinds;
12use scc_core::{
13 entity_id, estimate_tokens, path_matches_locus, route_query, truncate_to_budget, Provenance,
14 Severity,
15};
16use scc_graph::TrustedGraphView;
17use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
18
19#[derive(Debug, Clone)]
20pub(crate) struct Section {
22 pub(crate) title: String,
23 pub(crate) body: String,
24 pub(crate) priority: u8,
26}
27
28impl Section {
29 pub(crate) fn new(title: &str, body: String, priority: u8) -> Section {
30 Section {
31 title: title.to_string(),
32 body,
33 priority,
34 }
35 }
36}
37
38#[derive(Debug, Clone, Default)]
45struct RenderOutcome {
46 original_tokens: usize,
47 dropped_sections: Vec<String>,
48 hard_truncated: bool,
49 exceeded_soft_budget: bool,
50}
51
52fn render(
54 sections: Vec<Section>,
55 budget: usize,
56 warnings: Vec<String>,
57 hard: bool,
58) -> (String, RenderOutcome) {
59 let mut sections = sections;
60 let mut outcome = RenderOutcome {
61 original_tokens: estimate_tokens(&assemble(§ions)),
62 ..Default::default()
63 };
64 let mut warn_block = String::new();
68 for w in &warnings {
69 warn_block.push_str(&format!("\n⚠ WARNING: {w}\n"));
70 }
71 if estimate_tokens(&warn_block) > budget {
72 warn_block = format!(
73 "\n⚠ WARNINGS: {} warning(s) omitted over {}-token budget\n",
74 warnings.len(),
75 budget
76 );
77 }
78 if !hard {
79 let mut content = assemble(§ions);
82 while estimate_tokens(&content) > budget {
83 let min_priority = sections.iter().map(|s| s.priority).min().unwrap_or(10);
84 if min_priority >= 9 {
85 break;
86 }
87 let idx = sections
88 .iter()
89 .position(|s| s.priority == min_priority)
90 .unwrap();
91 let dropped = sections.remove(idx).title;
92 if !outcome.dropped_sections.contains(&dropped) {
93 outcome.dropped_sections.push(dropped);
94 }
95 content = assemble(§ions);
96 if sections.is_empty() {
97 break;
98 }
99 }
100 for w in warnings {
101 content.push_str(&format!("\n⚠ WARNING: {w}\n"));
102 }
103 outcome.hard_truncated = false;
104 outcome.exceeded_soft_budget = estimate_tokens(&content) > budget;
105 return (content, outcome);
106 }
107 let content_budget = budget.saturating_sub(estimate_tokens(&warn_block));
108 if content_budget < 16 {
111 let receipt = format!("# PACK OMITTED\npack omitted: budget {budget} below the 16-token floor\n");
112 outcome.hard_truncated = true;
113 if estimate_tokens(&receipt) + estimate_tokens(&warn_block) > budget {
114 return (format!("# PACK OMITTED\nbudget {budget}\n"), outcome);
115 }
116 return (receipt + &warn_block, outcome);
117 }
118 let mut content = assemble(§ions);
122 let mut tokens = estimate_tokens(&content);
123 while tokens > content_budget {
124 let min_priority = sections.iter().map(|s| s.priority).min().unwrap_or(10);
125 if min_priority >= 9 {
126 break; }
128 let idx = sections
129 .iter()
130 .position(|s| s.priority == min_priority)
131 .unwrap();
132 let dropped = sections.remove(idx).title;
133 if !outcome.dropped_sections.contains(&dropped) {
134 outcome.dropped_sections.push(dropped);
135 }
136 content = assemble(§ions);
137 tokens = estimate_tokens(&content);
138 if sections.is_empty() {
139 break;
140 }
141 }
142 if tokens > content_budget && !sections.is_empty() {
148 outcome.hard_truncated = true;
149 loop {
150 let cur = estimate_tokens(&assemble(§ions));
151 if cur <= content_budget {
152 break;
153 }
154 let mut cand: Option<usize> = None;
155 for (i, s) in sections.iter().enumerate() {
156 if s.body.lines().count() > 1
160 && !s.body.contains("[section truncated to fit")
161 && cand.map(|j| s.priority < sections[j].priority).unwrap_or(true)
162 {
163 cand = Some(i);
164 }
165 }
166 let Some(i) = cand else { break };
167 let n = sections[i].body.lines().count();
168 let keep = (n / 2).max(1);
169 let kept: Vec<&str> = sections[i].body.lines().take(keep).collect();
170 sections[i].body = format!(
171 "{}\n… [section truncated to fit {content_budget}-token budget]",
172 kept.join("\n")
173 );
174 let tag = format!("truncated:{}", sections[i].title);
175 if !outcome.dropped_sections.contains(&tag) {
176 outcome.dropped_sections.push(tag);
177 }
178 }
179 while sections.len() > 1 && estimate_tokens(&assemble(§ions)) > content_budget {
180 let min_priority = sections.iter().map(|s| s.priority).min().unwrap_or(10);
181 let idx = sections
182 .iter()
183 .position(|s| s.priority == min_priority)
184 .unwrap();
185 let dropped = sections.remove(idx).title;
186 if !outcome.dropped_sections.contains(&dropped) {
187 outcome.dropped_sections.push(dropped);
188 }
189 }
190 if estimate_tokens(&assemble(§ions)) > content_budget {
191 let title = sections[0].title.clone();
195 let prio = sections[0].priority;
196 let total_lines = sections[0].body.lines().count();
197 let marker = format!(
198 "\n… [pack hard cap: {title} truncated to fit {budget}-token budget]"
199 );
200 let mut lo = 0usize;
201 let mut hi = total_lines;
202 while lo < hi {
203 let mid = (lo + hi).div_ceil(2);
204 let body: String = sections[0].body.lines().take(mid).collect::<Vec<_>>().join("\n") + ▮
205 let probe = vec![Section::new(&title, body, prio)];
206 if estimate_tokens(&assemble(&probe)) <= content_budget {
207 lo = mid;
208 } else {
209 hi = mid.saturating_sub(1);
210 }
211 }
212 let body: String = sections[0].body.lines().take(lo).collect::<Vec<_>>().join("\n") + ▮
213 sections[0].body = body;
214 content = assemble(§ions);
215 let tag = format!("truncated:{}", title);
216 if !outcome.dropped_sections.contains(&tag) {
217 outcome.dropped_sections.push(tag);
218 }
219 if estimate_tokens(&content) > content_budget {
220 content = format!("# PACK OMITTED\npack omitted: single section exceeds {budget}-token budget\n");
223 content.push_str(&warn_block);
224 outcome.exceeded_soft_budget = estimate_tokens(&content) > budget;
225 return (content, outcome);
226 }
227 }
228 }
229 content = assemble(§ions);
230 content.push_str(&warn_block);
231 outcome.exceeded_soft_budget = estimate_tokens(&content) > budget;
232 (content, outcome)
233}
234pub(crate) fn finish(
238 pack: &mut ContextPack,
239 sections: Vec<Section>,
240 budget: usize,
241 warnings: Vec<String>,
242) {
243 finish_hard(pack, sections, budget, warnings);
244}
245
246pub(crate) fn finish_hard(
249 pack: &mut ContextPack,
250 sections: Vec<Section>,
251 budget: usize,
252 warnings: Vec<String>,
253) {
254 let (content, outcome) = render(sections, budget, warnings, true);
255 pack.content = content;
256 pack.budget = budget;
257 pack.tokens = estimate_tokens(&pack.content);
258 pack.original_tokens = outcome.original_tokens;
259 pack.dropped_sections = outcome.dropped_sections;
260 pack.hard_truncated = outcome.hard_truncated;
261 pack.exceeded_soft_budget = outcome.exceeded_soft_budget;
262 pack.truncated =
263 pack.hard_truncated || !pack.dropped_sections.is_empty() || pack.exceeded_soft_budget;
264}
265
266pub(crate) fn finish_soft(
271 pack: &mut ContextPack,
272 sections: Vec<Section>,
273 budget: usize,
274 warnings: Vec<String>,
275) {
276 let (content, outcome) = render(sections, budget, warnings, false);
277 pack.content = content;
278 pack.budget = budget;
279 pack.tokens = estimate_tokens(&pack.content);
280 pack.original_tokens = outcome.original_tokens;
281 pack.dropped_sections = outcome.dropped_sections;
282 pack.hard_truncated = outcome.hard_truncated;
283 pack.exceeded_soft_budget = outcome.exceeded_soft_budget;
284 pack.truncated =
285 pack.hard_truncated || !pack.dropped_sections.is_empty() || pack.exceeded_soft_budget;
286}
287
288pub(crate) fn finish_with_rollover(
292 pack: &mut ContextPack,
293 sections: Vec<Section>,
294 budget: usize,
295 warnings: Vec<String>,
296) {
297 let original = estimate_tokens(&assemble(§ions));
298 let mut buckets: [Vec<Section>; 6] = Default::default();
299 for s in sections {
300 buckets[quota_bucket(&s.title)].push(s);
301 }
302 let used: [usize; 6] = std::array::from_fn(|i| estimate_tokens(&assemble(&buckets[i])));
303 let filled = crate::budget::fill_task_context_quotas(budget, &used);
304 let mut kept: Vec<Section> = Vec::new();
305 let mut dropped: Vec<String> = Vec::new();
306 for (i, secs) in buckets.into_iter().enumerate() {
307 let assembled = assemble(&secs);
308 let grant = filled[i].granted;
309 if filled[i].truncated > 0 {
310 dropped.push(format!("quota:{}", filled[i].name));
311 let fitted = truncate_to_budget(&assembled, grant.max(1));
312 kept.push(Section::new(
313 &format!("{} (truncated)", filled[i].name),
314 fitted,
315 5,
316 ));
317 } else {
318 kept.extend(secs);
319 }
320 }
321 for w in warnings {
322 kept.push(Section::new("WARNING", format!("{w}\n"), 10));
323 }
324 pack.content = assemble(&kept);
325 pack.budget = budget;
326 pack.tokens = estimate_tokens(&pack.content);
327 pack.original_tokens = original;
328 pack.dropped_sections = dropped;
329 pack.hard_truncated = pack
330 .dropped_sections
331 .iter()
332 .any(|d| d.starts_with("quota:"));
333 pack.exceeded_soft_budget = pack.tokens > budget;
334 pack.truncated = pack.hard_truncated || pack.exceeded_soft_budget;
335}
336
337fn quota_bucket(title: &str) -> usize {
339 match title {
340 "TASK"
341 | "SYSTEM ROLE"
342 | "RELEVANT COMPONENTS"
343 | "DATA OWNERSHIP"
344 | "LOCUS"
345 | "IDENTITY"
346 | "COMPONENTS" => 0,
347 "UPSTREAM" | "DOWNSTREAM" | "CONTRACTS" => 1,
348 "IMPLEMENTATION" => 2,
349 "PRIMARY FLOW" | "SECONDARY FLOWS" | "FLOWS" => 3,
350 "TESTS" | "INVARIANTS" => 4,
351 _ => 5,
352 }
353}
354
355fn assemble(sections: &[Section]) -> String {
356 let mut out = String::new();
357 for s in sections {
358 out.push_str(&format!("# {}\n{}\n\n", s.title, s.body));
359 }
360 out.trim_end().to_string()
361}
362
363pub(crate) fn entity_name(view: &TrustedGraphView, id: &str) -> String {
364 view.name_of(id)
365}
366
367fn component_short(view: &TrustedGraphView, id: &str) -> String {
369 let name = entity_name(view, id);
370 let name = name.strip_prefix("component:").unwrap_or(&name);
371 const MAX_COMPONENT_DISPLAY: usize = 120;
374 if name.len() > MAX_COMPONENT_DISPLAY {
375 let roots = name.matches('+').count() + 1;
376 format!("{}… (+{} dirs)", &name[..100], roots.saturating_sub(1).max(1))
377 } else {
378 name.to_string()
379 }
380}
381
382fn format_evidence_tags(ctx: &ContextCompiler, entity_ids: &[String]) -> String {
384 let counts = ctx.evidence_summary(entity_ids);
385 if counts.is_empty() {
386 return String::new();
387 }
388 let mut parts: Vec<String> = counts.iter().map(|(k, v)| format!("{v} {k}")).collect();
389 parts.sort();
390 format!("[evidence: {}]", parts.join(", "))
391}
392
393pub fn overview(ctx: &ContextCompiler) -> ContextPack {
398 let mut pack = ContextPack::new("overview", &ctx.revision());
399 let mut sections: Vec<Section> = Vec::new();
400
401 let repo = ctx.store.repository();
402 let snapshot = ctx.store.latest_snapshot().ok().flatten();
403 let stats = ctx.store.stats().unwrap_or_default();
404
405 let languages: Vec<String> = {
407 let mut m: BTreeSet<String> = BTreeSet::new();
408 for (_, _, lang, _, _) in ctx.store.all_files().unwrap_or_default() {
409 if lang != "other" && lang != "unknown" {
410 m.insert(lang);
411 }
412 }
413 m.into_iter().collect()
414 };
415 let purpose = ctx
416 .store
417 .meta_get("purpose")
418 .ok()
419 .flatten()
420 .unwrap_or_default();
421 let mut ident = String::new();
422 ident.push_str(&format!("Repository: {} ({})\n", repo.name, repo.id));
423 if let Some(s) = &snapshot {
424 ident.push_str(&format!("Revision: {}\n", s.revision));
425 if let Some(b) = &s.branch {
426 ident.push_str(&format!("Branch: {}\n", b));
427 }
428 ident.push_str(&format!("Indexed at: {}\n", s.indexed_at));
429 } else {
430 ident.push_str("Index status: NOT INDEXED\n");
431 }
432 ident.push_str(&format!("Languages: {}\n", languages.join(", ")));
433 let eps: Vec<String> = ctx
434 .view
435 .entities_of_kind(kinds::SYMBOL)
436 .into_iter()
437 .filter(|e| e.attributes.contains_key("entrypoints"))
438 .map(|e| e.name.clone())
439 .take(10)
440 .collect();
441 if !eps.is_empty() {
442 ident.push_str(&format!("Entrypoints: {}\n", eps.join(", ")));
443 }
444 if !purpose.is_empty() {
445 ident.push_str(&format!(
446 "\n[SYSTEM PURPOSE — from README, DOCUMENTATION not fact]\n{purpose}\n"
447 ));
448 }
449 sections.push(Section::new("IDENTITY", ident, 10));
450
451 let mut comps = String::new();
453 for c in ctx.store.components().unwrap_or_default() {
454 let resp = c
455 .attributes
456 .get("responsibility")
457 .and_then(|v| v.as_array())
458 .and_then(|a| a.first())
459 .and_then(|r| r.get("text"))
460 .and_then(|t| t.as_str())
461 .unwrap_or("");
462 comps.push_str(&format!("- {}: {}\n", c.name, resp));
463 }
464 sections.push(Section::new("COMPONENTS", comps, 9));
465
466 let dus: Vec<String> = ctx
468 .view
469 .entities_of_kind(kinds::DEPLOYMENT_UNIT)
470 .into_iter()
471 .map(|e| {
472 let img = e
473 .attributes
474 .get("image")
475 .and_then(|v| v.as_str())
476 .unwrap_or("");
477 if img.is_empty() {
478 e.name.clone()
479 } else {
480 format!("{} ({})", e.name, img)
481 }
482 })
483 .collect();
484 let exts: Vec<String> = ctx
485 .view
486 .entities_of_kind(kinds::EXTERNAL_API)
487 .into_iter()
488 .map(|e| e.name.clone())
489 .collect();
490 let mut bound = String::new();
491 if !dus.is_empty() {
492 bound.push_str(&format!("Deployment units: {}\n", dus.join(", ")));
493 }
494 if !exts.is_empty() {
495 bound.push_str(&format!("External systems: {}\n", exts.join(", ")));
496 }
497 if bound.is_empty() {
498 bound.push_str("(none detected)\n");
499 }
500 sections.push(Section::new("BOUNDARIES", bound, 8));
501
502 let stores: Vec<String> = ctx
504 .view
505 .entities_of_kind(kinds::DATA_STORE)
506 .into_iter()
507 .map(|e| {
508 let tech = e
509 .attributes
510 .get("technology")
511 .and_then(|v| v.as_str())
512 .unwrap_or("");
513 if tech.is_empty() {
514 e.name.clone()
515 } else {
516 format!("{} ({tech})", e.name)
517 }
518 })
519 .collect();
520 sections.push(Section::new(
521 "STORES",
522 if stores.is_empty() {
523 "(none detected)".into()
524 } else {
525 format!("{}\n", stores.join(", "))
526 },
527 8,
528 ));
529
530 let flows: Vec<String> = ctx
532 .view
533 .flows()
534 .iter()
535 .map(|f| {
536 let trig = f
537 .trigger
538 .as_ref()
539 .map(|t| format!(" [{t}]"))
540 .unwrap_or_default();
541 format!("- {} ({}){trig}", f.name, flow_kind_str(f.kind))
542 })
543 .collect();
544 sections.push(Section::new(
545 "FLOWS",
546 if flows.is_empty() {
547 "(none compiled)".into()
548 } else {
549 format!("{}\n", flows.join("\n"))
550 },
551 8,
552 ));
553
554 let invs = ctx.store.invariants().unwrap_or_default();
556 let mut inv_body = String::new();
557 for inv in invs {
558 inv_body.push_str(&format!(
559 "- [{}] {}\n",
560 severity_str(inv.severity),
561 inv.statement
562 ));
563 }
564 if inv_body.is_empty() {
565 inv_body.push_str("(none declared)\n");
566 }
567 sections.push(Section::new("INVARIANTS", inv_body, 10));
568
569 let mut ev = String::new();
571 for (k, v) in &stats {
572 ev.push_str(&format!("{k}: {v}\n"));
573 }
574 sections.push(Section::new("INDEX STATUS", ev, 5));
575
576 let warnings = ctx_warnings(ctx);
577 finish(&mut pack, sections, ctx.settings.startup_tokens, warnings);
578 pack
579}
580
581fn ctx_warnings(ctx: &ContextCompiler) -> Vec<String> {
582 let mut w = Vec::new();
583 if ctx.store.snapshot_status().ok().flatten().is_none() {
584 w.push("Repository is not indexed — run `scc index`.".into());
585 }
586 if !ctx.stale_paths.is_empty() {
587 w.push(format!(
588 "Model is stale: {} changed file(s) not yet re-indexed.",
589 ctx.stale_paths.len()
590 ));
591 }
592 if let Ok(findings) = ctx.store.drift_findings(true) {
593 for (_, kind, sev, msg, _) in findings {
594 if sev == "high" || sev == "critical" {
595 w.push(format!("Drift [{kind}]: {msg}"));
596 }
597 }
598 }
599 w.truncate(6);
600 w
601}
602
603pub fn task(
608 ctx: &ContextCompiler,
609 goal: &str,
610 files: &[String],
611 symbols: &[String],
612 budget: usize,
613) -> ContextPack {
614 task_with_rankers(ctx, goal, files, symbols, budget, None, None)
615}
616
617pub fn task_with_rankers(
620 ctx: &ContextCompiler,
621 goal: &str,
622 files: &[String],
623 symbols: &[String],
624 budget: usize,
625 scorer: Option<&dyn crate::rank::SemanticScorer>,
626 reranker: Option<&dyn crate::rank::Reranker>,
627) -> ContextPack {
628 let mut pack = ContextPack::new("task", &ctx.revision());
629 let goal_terms = terms(goal);
630
631 let locus = resolve_goal_loci(ctx, goal);
632 let mut files_buf: Vec<String> = files.to_vec();
633 let mut symbols_buf: Vec<String> = symbols.to_vec();
634 if let Some(loc) = &locus {
635 for f in &loc.files {
636 if !files_buf.iter().any(|x| x == f) {
637 files_buf.push(f.clone());
638 }
639 }
640 for s in &loc.symbols {
641 if !symbols_buf.iter().any(|x| x == s) {
642 symbols_buf.push(s.clone());
643 }
644 }
645 }
646 let files = files_buf.as_slice();
647 let symbols = symbols_buf.as_slice();
648
649 let candidates = crate::rank::collect_lexical_candidates_full(
651 ctx.store, &ctx.view, goal, symbols, 24, scorer, reranker,
652 );
653 let entity_ids: Vec<String> = candidates.iter().map(|c| c.id.clone()).collect();
654
655 let mut symbol_files: HashMap<String, String> = HashMap::new();
657 for e in ctx.view.entities_of_kind(kinds::SYMBOL) {
658 if let Some(f) = e.attributes.get("file").and_then(|v| v.as_str()) {
659 symbol_files.insert(e.id.clone(), f.to_string());
660 }
661 }
662 let mut file_components: HashMap<String, BTreeSet<String>> = HashMap::new();
666 for c in ctx.store.components().unwrap_or_default() {
667 for r in ctx.view.out_pred(&c.id, scc_core::predicates::CONTAINS) {
668 file_components
669 .entry(r.object.clone())
670 .or_default()
671 .insert(c.id.clone());
672 }
673 }
674 let mut symbol_components: HashMap<String, BTreeSet<String>> = HashMap::new();
675 for (sid, f) in &symbol_files {
676 if let Some(cids) =
677 file_components.get(&entity_id(&ctx.view.graph.repo_id, kinds::FILE, f))
678 {
679 symbol_components.insert(sid.clone(), cids.clone());
680 }
681 }
682
683 let mut affected_comps: BTreeSet<String> = BTreeSet::new();
685 for c in &candidates {
686 if c.kind == kinds::SYMBOL {
687 if let Some(cids) = symbol_components.get(&c.id) {
688 affected_comps.extend(cids.iter().cloned());
689 }
690 } else if c.kind == kinds::COMPONENT {
691 affected_comps.insert(c.id.clone());
692 } else if c.kind == kinds::FILE {
693 if let Some(cids) = file_components.get(&c.id) {
694 affected_comps.extend(cids.iter().cloned());
695 }
696 } else if c.kind == kinds::ROUTE {
697 if let Some(h) = ctx
698 .view
699 .entity(&c.id)
700 .and_then(|e| e.attributes.get("handler"))
701 .and_then(|v| v.as_str())
702 {
703 if let Some(cids) = symbol_components.get(h) {
704 affected_comps.extend(cids.iter().cloned());
705 }
706 }
707 }
708 }
709 for f in files {
710 let fid = entity_id(&ctx.view.graph.repo_id, kinds::FILE, f);
711 if let Some(cids) = file_components.get(&fid) {
712 affected_comps.extend(cids.iter().cloned());
713 }
714 }
715
716 let mut affected_flows: Vec<(String, f64)> = Vec::new();
718 for f in &ctx.view.flows() {
719 let mut score = 0.0;
720 let mentions = f.steps.iter().any(|s| {
721 let hit = affected_comps.iter().any(|c| s.actor.contains(c));
722 if hit {
723 score += 2.0;
724 }
725 hit
726 });
727 let ftext = format!("{} {}", f.name, f.trigger.clone().unwrap_or_default());
729 let ft = terms(&ftext);
730 score += ft.intersection(&goal_terms).count() as f64 * 1.5;
731 if f.kind != scc_core::FlowKind::Architecture {
734 score += 0.5;
735 }
736 if f
739 .attributes
740 .get("signals_only")
741 .and_then(|v| v.as_bool())
742 == Some(true)
743 {
744 score -= 1.5;
745 }
746 if mentions || score > 0.0 {
747 affected_flows.push((f.id.clone(), score));
748 }
749 }
750 affected_flows.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
751
752 let mut upstream: BTreeSet<String> = BTreeSet::new();
754 let mut downstream: BTreeSet<String> = BTreeSet::new();
755 for cid in &affected_comps {
756 for r in ctx.view.out_pred(cid, scc_core::predicates::DEPENDS_ON) {
757 downstream.insert(r.object.clone());
758 }
759 for r in ctx.view.in_pred(cid, scc_core::predicates::DEPENDS_ON) {
760 upstream.insert(r.subject.clone());
761 }
762 }
763
764 let mut contracts: BTreeSet<String> = BTreeSet::new();
766 let affected_syms: HashSet<&String> = symbol_components
767 .iter()
768 .filter(|(_, cids)| cids.iter().any(|c| affected_comps.contains(c)))
769 .map(|(s, _)| s)
770 .collect();
771 for sid in &affected_syms {
772 for r in ctx.view.out_pred(sid, scc_core::predicates::HANDLES) {
773 contracts.insert(r.object.clone());
774 }
775 }
776
777 let mut owned_stores: BTreeSet<String> = BTreeSet::new();
779 for cid in &affected_comps {
780 for r in ctx.view.out_pred(cid, scc_core::predicates::OWNS) {
781 owned_stores.insert(r.object.clone());
782 }
783 }
784
785 let mut inv_ids: BTreeSet<String> = BTreeSet::new();
787 for inv in &ctx.view.invariants() {
788 if inv
789 .scope
790 .iter()
791 .any(|s| affected_comps.contains(s) || owned_stores.contains(s))
792 || inv.enforced_by.iter().any(|t| {
793 goal_terms
794 .iter()
795 .any(|g| t.to_ascii_lowercase().contains(g))
796 })
797 {
798 inv_ids.insert(inv.id.clone());
799 }
800 }
801
802 let tests = collect_tests_to_run(ctx, &affected_syms, &affected_comps, &owned_stores);
804
805 let mut retries: Vec<String> = Vec::new();
807 for cid in &affected_comps {
808 if let Some(c) = ctx.view.entity(cid) {
809 if let Some(rs) = c.attributes.get("retries").and_then(|v| v.as_array()) {
810 for r in rs {
811 if let Some(s) = r.as_str() {
812 retries.push(format!("{s} [in {}]", entity_name(&ctx.view, cid)));
813 }
814 }
815 }
816 }
817 }
818
819 let mut sections: Vec<Section> = Vec::new();
821
822 let files_disp = if files.is_empty() {
823 "(none)".to_string()
824 } else {
825 files.join(", ")
826 };
827 let symbols_disp = if symbols.is_empty() {
828 "(none)".to_string()
829 } else {
830 symbols.join(", ")
831 };
832 sections.push(Section::new(
833 "TASK",
834 format!("Goal: {goal}\nExplicit files: {files_disp}\nExplicit symbols: {symbols_disp}",),
835 10,
836 ));
837 if let Some(loc) = &locus {
838 sections.push(Section::new("LOCUS", loc.body.clone(), 9));
839 }
840
841 let purpose = ctx
843 .store
844 .meta_get("purpose")
845 .ok()
846 .flatten()
847 .unwrap_or_default();
848 let mut role = String::new();
849 if !purpose.is_empty() {
850 role.push_str(&format!(
851 "[SYSTEM PURPOSE — DOCUMENTATION from README]\n{purpose}\n\n"
852 ));
853 }
854 let all_comps = ctx.store.components().unwrap_or_default();
855 let top_comps: Vec<&scc_core::Entity> = all_comps
856 .iter()
857 .filter(|c| affected_comps.contains(&c.id))
858 .collect();
859 for c in top_comps {
860 let resp = c
861 .attributes
862 .get("responsibility")
863 .and_then(|v| v.as_array())
864 .and_then(|a| a.first())
865 .and_then(|r| r.get("text"))
866 .and_then(|t| t.as_str())
867 .unwrap_or("");
868 role.push_str(&format!("{}: {}\n", c.name, resp));
869 }
870 if role.is_empty() {
871 role.push_str("(no system role compiled)\n");
872 }
873 sections.push(Section::new("SYSTEM ROLE", role, 9));
874
875 let mut comp_body = String::new();
877 for cid in &affected_comps {
878 if let Some(c) = ctx.view.entity(cid) {
879 let resp = c
880 .attributes
881 .get("responsibility")
882 .and_then(|v| v.as_array())
883 .and_then(|a| a.first())
884 .and_then(|r| r.get("text"))
885 .and_then(|t| t.as_str())
886 .unwrap_or("");
887 comp_body.push_str(&format!(
888 "- {}: {}\n",
889 component_short(&ctx.view, cid),
890 resp
891 ));
892 }
893 }
894 if comp_body.is_empty() {
895 for c in candidates.iter().take(6) {
896 if c.kind == kinds::COMPONENT || c.kind == kinds::SYMBOL {
897 comp_body.push_str(&format!("- {} ({}) [{:.2}]\n", c.name, c.kind, c.score));
898 }
899 }
900 }
901 sections.push(Section::new("RELEVANT COMPONENTS", comp_body, 10));
902
903 if let Some((fid, _)) = affected_flows.first() {
905 let body = render_flow(ctx, fid, true);
906 sections.push(Section::new("PRIMARY FLOW", body, 9));
907 }
908
909 if affected_flows.len() > 1 {
911 let mut body = String::new();
912 for (fid, _) in affected_flows.iter().skip(1).take(4) {
913 if let Some(f) = ctx.view.flows().iter().find(|f| &f.id == fid) {
914 body.push_str(&format!(
915 "- {} [{}]\n",
916 f.name,
917 f.trigger.clone().unwrap_or_default()
918 ));
919 }
920 }
921 sections.push(Section::new("SECONDARY FLOWS", body, 8));
922 }
923
924 sections.push(Section::new(
926 "UPSTREAM",
927 if upstream.is_empty() {
928 "(none)".into()
929 } else {
930 format!(
931 "{}\n",
932 upstream
933 .iter()
934 .map(|c| component_short(&ctx.view, c))
935 .collect::<Vec<_>>()
936 .join(", ")
937 )
938 },
939 9,
940 ));
941 sections.push(Section::new(
942 "DOWNSTREAM",
943 if downstream.is_empty() {
944 "(none)".into()
945 } else {
946 format!(
947 "{}\n",
948 downstream
949 .iter()
950 .map(|c| component_short(&ctx.view, c))
951 .collect::<Vec<_>>()
952 .join(", ")
953 )
954 },
955 9,
956 ));
957
958 let mut data_body = String::new();
960 for store_id in &owned_stores {
961 let name = entity_name(&ctx.view, store_id);
962 let writers: Vec<String> = ctx
963 .view
964 .in_pred(store_id, scc_core::predicates::WRITES)
965 .into_iter()
966 .map(|r| {
967 symbol_components
968 .get(&r.subject)
969 .map(|cids| {
970 let mut names: Vec<String> = cids
971 .iter()
972 .map(|c| component_short(&ctx.view, c))
973 .collect();
974 names.sort();
975 names.dedup();
976 names.join(", ")
977 })
978 .filter(|s| !s.is_empty())
979 .unwrap_or_else(|| entity_name(&ctx.view, &r.subject))
980 })
981 .collect();
982 let mut writers = writers;
986 writers.sort();
987 writers.dedup();
988 let writers_disp = if writers.is_empty() {
989 "?".to_string()
990 } else if writers.len() > 3 {
991 format!("{}, +{} more", writers[..3].join(", "), writers.len() - 3)
992 } else {
993 writers.join(", ")
994 };
995 data_body.push_str(&format!("- {name}: owner(s) {writers_disp}\n"));
996 }
997 if data_body.is_empty() {
998 data_body.push_str("(no ownership compiled)\n");
999 }
1000 sections.push(Section::new("DATA OWNERSHIP", data_body, 10));
1001
1002 let mut contract_body = String::new();
1004 for rid in &contracts {
1005 if let Some(r) = ctx.view.entity(rid) {
1006 contract_body.push_str(&format!("- {}\n", r.name));
1007 }
1008 }
1009 if !contract_body.is_empty() {
1010 sections.push(Section::new("CONTRACTS", contract_body, 9));
1011 }
1012
1013 let mut inv_body = String::new();
1015 for id in &inv_ids {
1016 if let Some(inv) = ctx.view.invariants().iter().find(|i| &i.id == id) {
1017 inv_body.push_str(&format!(
1018 "- [{}] {} {}\n",
1019 severity_str(inv.severity),
1020 inv.statement,
1021 if inv.enforced_by.is_empty() {
1022 "(no enforcing test)"
1023 } else {
1024 ""
1025 }
1026 ));
1027 }
1028 }
1029 if !inv_body.is_empty() {
1030 sections.push(Section::new("INVARIANTS", inv_body, 10));
1031 }
1032
1033 if !retries.is_empty() {
1035 sections.push(Section::new(
1036 "FAILURE / RETRY",
1037 format!("{}\n", retries.join("\n")),
1038 9,
1039 ));
1040 }
1041
1042 let mut impl_body = String::new();
1044 for cid in &affected_comps {
1045 if let Some(c) = ctx.view.entity(cid) {
1046 if let Some(paths) = c
1047 .attributes
1048 .get("implementation")
1049 .and_then(|i| i.get("paths"))
1050 .and_then(|p| p.as_array())
1051 {
1052 let ps: Vec<&str> = paths.iter().filter_map(|p| p.as_str()).collect();
1053 if !ps.is_empty() {
1054 impl_body.push_str(&format!("{}: {}\n", c.name, ps.join(", ")));
1055 }
1056 }
1057 }
1058 }
1059 let mut seen_syms: std::collections::HashSet<String> = std::collections::HashSet::new();
1060 for c in candidates.iter() {
1061 if c.kind == kinds::SYMBOL && seen_syms.insert(c.id.clone()) {
1062 let file = ctx
1063 .view
1064 .entity(&c.id)
1065 .and_then(|e| e.attributes.get("file"))
1066 .and_then(|v| v.as_str())
1067 .unwrap_or("");
1068 if !file.is_empty() {
1069 impl_body.push_str(&format!("{} ({file})\n", c.name));
1070 }
1071 }
1072 if seen_syms.len() >= 12 {
1073 break;
1074 }
1075 }
1076 if !impl_body.is_empty() {
1077 sections.push(Section::new("IMPLEMENTATION", impl_body, 7));
1078 }
1079
1080 let mut test_body = String::new();
1082 for (tid, reasons) in &tests {
1083 test_body.push_str(&format_test_to_run(ctx, tid, reasons));
1084 }
1085 if !test_body.is_empty() {
1086 sections.push(Section::new("TESTS", test_body, 7));
1087 }
1088
1089 if !files.is_empty() {
1091 sections.push(Section::new(
1092 "RECENT CHANGES",
1093 format!("{}\n", files.join("\n")),
1094 6,
1095 ));
1096 }
1097
1098 let mut ids: Vec<String> = Vec::new();
1100 ids.extend(entity_ids.clone());
1101 ids.extend(affected_comps.iter().cloned());
1102 for c in &candidates {
1106 if c.kind == kinds::SYMBOL {
1107 if let Some(e) = ctx.view.entity(&c.id) {
1108 if let Some(f) = e.attributes.get("file").and_then(|v| v.as_str()) {
1109 ids.push(entity_id(&ctx.view.graph.repo_id, kinds::FILE, f));
1110 }
1111 }
1112 }
1113 }
1114 ids.extend(downstream.iter().cloned());
1115 ids.extend(tests.keys().cloned());
1116 for tid in tests.keys() {
1118 if let Some(f) = ctx
1119 .view
1120 .entity(tid)
1121 .and_then(|e| e.attributes.get("file"))
1122 .and_then(|v| v.as_str())
1123 {
1124 ids.push(entity_id(&ctx.view.graph.repo_id, kinds::FILE, f));
1125 }
1126 }
1127 ids.extend(inv_ids.iter().cloned());
1128 ids.sort();
1129 ids.dedup();
1130 let ev_summary = ctx.evidence_summary(&ids);
1131 let mut ev_body = String::new();
1132 for (k, v) in &ev_summary {
1133 ev_body.push_str(&format!("{k}: {v}\n"));
1134 }
1135 if ev_body.is_empty() {
1136 ev_body.push_str("(no evidence linked)\n");
1137 }
1138 sections.push(Section::new("EVIDENCE STATUS", ev_body, 5));
1139
1140 let analysis_quality = ctx
1141 .store
1142 .meta_get("analysis_quality")
1143 .ok()
1144 .flatten()
1145 .and_then(|s| serde_json::from_str::<scc_core::AnalysisQuality>(&s).ok());
1146 if let Some(ref q) = analysis_quality {
1147 sections.push(Section::new("ANALYSIS QUALITY", q.compact_line() + "\n", 4));
1148 }
1149
1150 let fetch = fetch_handle_list(ctx, files, &candidates);
1153 if !fetch.is_empty() {
1154 sections.push(Section::new("FETCH", fetch, 8));
1155 }
1156
1157 let exact = exact_source_tail(ctx, files, &candidates);
1161 if !exact.is_empty() {
1162 sections.push(Section::new("EXACT SOURCE", exact, 1));
1163 }
1164
1165 let warnings = ctx_warnings(ctx);
1166 let stale_note = ctx
1167 .stale_paths
1168 .iter()
1169 .map(|p| format!("stale: {p}"))
1170 .collect::<Vec<_>>();
1171 let mut all_warnings = warnings;
1172 all_warnings.extend(stale_note);
1173
1174 pack.entity_ids = ids;
1175 pack.evidence_summary = ev_summary;
1176 pack.analysis_quality = analysis_quality;
1177 match ctx.settings.pack_allocator {
1178 crate::PackAllocator::AdaptivePriority => {
1179 finish(&mut pack, sections, budget, all_warnings);
1180 }
1181 crate::PackAllocator::FixedRollover => {
1182 finish_with_rollover(&mut pack, sections, budget, all_warnings);
1183 }
1184 }
1185 pack.compression_policy = Some(compression_policy(goal));
1186 pack
1187}
1188
1189const EXACT_SOURCE_FILES: usize = 6;
1190const EXACT_SOURCE_LINES: usize = 40;
1191const FETCH_HANDLE_FILES: usize = 12;
1192
1193fn fetch_handle_list(
1197 ctx: &ContextCompiler,
1198 files: &[String],
1199 candidates: &[crate::rank::ScoredEntity],
1200) -> String {
1201 let paths = collect_task_paths(ctx, files, candidates, FETCH_HANDLE_FILES);
1202 let mut body = String::new();
1203 for path in &paths {
1204 let handle = file_handle(ctx, path);
1205 body.push_str(&format!("- {path} handle={handle}\n"));
1206 }
1207 body
1208}
1209
1210fn collect_task_paths(
1212 ctx: &ContextCompiler,
1213 files: &[String],
1214 candidates: &[crate::rank::ScoredEntity],
1215 cap: usize,
1216) -> Vec<String> {
1217 let mut paths: Vec<String> = Vec::new();
1218 let mut push = |p: &str| {
1219 if p.is_empty() {
1220 return;
1221 }
1222 if !paths.iter().any(|x| x == p) {
1223 paths.push(p.to_string());
1224 }
1225 };
1226 for f in files {
1227 if !crate::repo_path::is_repo_relative(f) {
1228 continue;
1229 }
1230 push(f);
1231 }
1232 for c in candidates {
1233 if c.kind != kinds::SYMBOL {
1234 continue;
1235 }
1236 if let Some(f) = ctx
1237 .view
1238 .entity(&c.id)
1239 .and_then(|e| e.attributes.get("file"))
1240 .and_then(|v| v.as_str())
1241 {
1242 push(f);
1243 }
1244 }
1245 paths.truncate(cap);
1246 paths
1247}
1248
1249fn exact_source_tail(
1253 ctx: &ContextCompiler,
1254 files: &[String],
1255 candidates: &[crate::rank::ScoredEntity],
1256) -> String {
1257 let paths = collect_task_paths(ctx, files, candidates, EXACT_SOURCE_FILES);
1258 let mut body = String::new();
1259 let root = &ctx.store.root;
1260 for path in &paths {
1261 let Some(text) = crate::repo_path::read_repo_text(root, path) else {
1262 continue;
1263 };
1264 let total = text.lines().count();
1265 if total == 0 {
1266 continue;
1267 }
1268 let shown = total.min(EXACT_SOURCE_LINES);
1269 let capped = u8::from(shown < total);
1270 let handle = file_handle(ctx, path);
1271 body.push_str(&format!(
1272 "# {path} handle={handle} shown={shown} total={total} capped={capped}\n"
1273 ));
1274 for (i, line) in text.lines().enumerate() {
1275 if i >= shown {
1276 break;
1277 }
1278 body.push_str(line);
1279 body.push('\n');
1280 }
1281 }
1282 body
1283}
1284
1285fn compression_policy(goal: &str) -> serde_json::Value {
1289 let g = goal.to_ascii_lowercase();
1290 let tests = ["test", "tests", "spec", "suite"]
1291 .iter()
1292 .any(|t| g.contains(t));
1293 let perf = ["perf", "performance", "latency", "slow", "benchmark"]
1294 .iter()
1295 .any(|t| g.contains(t));
1296 if tests {
1297 serde_json::json!({
1298 "preserve": ["failures", "test output", "stack traces"],
1299 "compress": ["passing output", "setup logs"],
1300 "rationale": "task touches tests"
1301 })
1302 } else if perf {
1303 serde_json::json!({
1304 "preserve": ["timings", "warnings", "errors"],
1305 "compress": ["logs"],
1306 "disable_log_compression": true,
1307 "rationale": "performance investigation"
1308 })
1309 } else {
1310 serde_json::json!({
1311 "preserve": ["errors", "failures", "warnings"],
1312 "compress": ["passing output", "repetitive logs"],
1313 "rationale": "standard"
1314 })
1315 }
1316}
1317
1318pub fn component(ctx: &ContextCompiler, id_or_name: &str, budget: usize, full: bool) -> ContextPack {
1324 let mut pack = ContextPack::new("component", &ctx.revision());
1325 let comp = ctx
1326 .store
1327 .components()
1328 .unwrap_or_default()
1329 .into_iter()
1330 .find(|c| c.id == id_or_name || c.name == id_or_name);
1331 let Some(comp) = comp else {
1332 pack.content = format!("# COMPONENT NOT FOUND\nNo component matches '{id_or_name}'.\n");
1333 pack.warnings
1334 .push(format!("unknown component: {id_or_name}"));
1335 return pack;
1336 };
1337 pack.entity_ids.push(comp.id.clone());
1338
1339 let mut sections: Vec<Section> = Vec::new();
1340
1341 let resp: Vec<String> = comp
1342 .attributes
1343 .get("responsibility")
1344 .and_then(|v| v.as_array())
1345 .map(|a| {
1346 a.iter()
1347 .map(|r| {
1348 let text = r.get("text").and_then(|t| t.as_str()).unwrap_or("");
1349 let prov = r.get("provenance").and_then(|p| p.as_str()).unwrap_or("");
1350 format!("- {text} [{prov}]")
1351 })
1352 .collect()
1353 })
1354 .unwrap_or_default();
1355 sections.push(Section::new(
1356 "RESPONSIBILITY",
1357 if resp.is_empty() {
1358 "(none)".into()
1359 } else {
1360 format!("{}\n", resp.join("\n"))
1361 },
1362 10,
1363 ));
1364
1365 let paths: Vec<String> = comp
1366 .attributes
1367 .get("implementation")
1368 .and_then(|i| i.get("paths"))
1369 .and_then(|p| p.as_array())
1370 .map(|a| {
1371 a.iter()
1372 .filter_map(|x| x.as_str().map(|s| s.to_string()))
1373 .collect()
1374 })
1375 .unwrap_or_default();
1376 let symbols: Vec<String> = comp
1377 .attributes
1378 .get("implementation")
1379 .and_then(|i| i.get("symbols"))
1380 .and_then(|p| p.as_array())
1381 .map(|a| {
1382 a.iter()
1383 .filter_map(|x| x.as_str().map(|s| s.to_string()))
1384 .collect()
1385 })
1386 .unwrap_or_default();
1387 let mut impl_body = String::new();
1388 if !paths.is_empty() {
1389 impl_body.push_str(&format!("Paths: {}\n", paths.join(", ")));
1390 }
1391 if !symbols.is_empty() {
1392 impl_body.push_str(&format!("Symbols: {}\n", symbols.join(", ")));
1393 }
1394 sections.push(Section::new("IMPLEMENTATION", impl_body, 8));
1395
1396 let owned: Vec<String> = comp
1397 .attributes
1398 .get("owns")
1399 .and_then(|v| v.as_array())
1400 .map(|a| {
1401 a.iter()
1402 .filter_map(|x| x.as_str().map(|s| entity_name(&ctx.view, s)))
1403 .collect()
1404 })
1405 .unwrap_or_default();
1406 sections.push(Section::new(
1407 "OWNS",
1408 if owned.is_empty() {
1409 "(none)".into()
1410 } else {
1411 format!("{}\n", owned.join(", "))
1412 },
1413 10,
1414 ));
1415
1416 let deps: Vec<String> = comp
1417 .attributes
1418 .get("depends_on")
1419 .and_then(|v| v.as_array())
1420 .map(|a| {
1421 a.iter()
1422 .map(|d| {
1423 let t = d.get("target").and_then(|x| x.as_str()).unwrap_or("");
1424 let n = d.get("call_count").and_then(|x| x.as_u64()).unwrap_or(0);
1425 format!("- {t} ({n} call edge(s))")
1426 })
1427 .collect()
1428 })
1429 .unwrap_or_default();
1430 sections.push(Section::new(
1431 "DEPENDS_ON",
1432 if deps.is_empty() {
1433 "(none)".into()
1434 } else {
1435 format!("{}\n", deps.join("\n"))
1436 },
1437 8,
1438 ));
1439
1440 let retries: Vec<String> = comp
1441 .attributes
1442 .get("retries")
1443 .and_then(|v| v.as_array())
1444 .map(|a| {
1445 a.iter()
1446 .filter_map(|x| x.as_str().map(|s| s.to_string()))
1447 .collect()
1448 })
1449 .unwrap_or_default();
1450 if !retries.is_empty() {
1451 sections.push(Section::new(
1452 "RETRIES",
1453 format!("{}\n", retries.join("\n")),
1454 9,
1455 ));
1456 }
1457
1458 let mut flows: Vec<String> = Vec::new();
1460 for f in &ctx.view.flows() {
1461 if f.steps.iter().any(|s| s.actor.contains(&comp.id)) {
1462 flows.push(format!(
1463 "- {} [{}]",
1464 f.name,
1465 f.trigger.clone().unwrap_or_default()
1466 ));
1467 }
1468 }
1469 if !flows.is_empty() {
1470 sections.push(Section::new("FLOWS", format!("{}\n", flows.join("\n")), 8));
1471 }
1472
1473 let mut tests: Vec<String> = Vec::new();
1475 for sym in &symbols {
1476 for e in ctx.view.entities_of_kind(kinds::SYMBOL) {
1477 if e.name == *sym {
1478 for r in ctx.view.out_pred(&e.id, scc_core::predicates::TESTED_BY) {
1479 tests.push(entity_name(&ctx.view, &r.object));
1480 }
1481 }
1482 }
1483 }
1484 tests.sort();
1485 tests.dedup();
1486 if !tests.is_empty() {
1487 sections.push(Section::new("TESTS", format!("{}\n", tests.join("\n")), 7));
1488 }
1489
1490 sections.push(Section::new(
1491 "EVIDENCE",
1492 format_evidence_tags(ctx, std::slice::from_ref(&comp.id)),
1493 5,
1494 ));
1495
1496 if full {
1497 finish_soft(&mut pack, sections, budget, ctx_warnings(ctx));
1498 } else {
1499 finish(&mut pack, sections, budget, ctx_warnings(ctx));
1500 }
1501 pack
1502}
1503
1504fn render_flow(ctx: &ContextCompiler, fid: &str, compact: bool) -> String {
1510 let flows = ctx.view.flows();
1511 let Some(f) = flows.iter().find(|f| f.id == fid) else {
1512 return format!("(flow {fid} not found)");
1513 };
1514 let mut body = String::new();
1515 if let Some(t) = &f.trigger {
1516 body.push_str(&format!("Trigger: {t}\n"));
1517 }
1518 if f.attributes.get("signals_only").and_then(|v| v.as_bool()) == Some(true) {
1521 body.push_str("(state-machine signals — NOT an authoritative lifecycle)\n");
1522 }
1523 let mut prev_actor: Option<String> = None;
1524 for s in &f.steps {
1525 let actor = component_short(&ctx.view, &s.actor);
1526 let mut line = if prev_actor.as_ref() == Some(&actor) {
1527 format!(" → {}", s.operation)
1528 } else {
1529 format!("{}. {}: {}", s.order, actor, s.operation)
1530 };
1531 if let Some(c) = &s.condition {
1532 line.push_str(&format!(" ({c})"));
1533 }
1534 if let Some(rp) = &s.retry_policy {
1535 line.push_str(&format!(" [retry: {rp}]"));
1536 }
1537 if s.r#async == Some(true) {
1538 line.push_str(" [async]");
1539 }
1540 body.push_str(&line);
1541 body.push('\n');
1542 prev_actor = Some(actor);
1543 }
1544 if compact {
1545 return body;
1547 } else {
1548 body.push_str("\nEvidence: ");
1549 let mut ev_ids: Vec<String> = Vec::new();
1550 for s in &f.steps {
1551 ev_ids.extend(s.evidence.clone());
1552 }
1553 let ev_tags = ctx.evidence_summary(&ev_ids);
1554 if ev_tags.is_empty() {
1555 body.push_str("(none)");
1556 } else {
1557 let parts: Vec<String> = ev_tags.iter().map(|(k, v)| format!("{v} {k}")).collect();
1558 body.push_str(&parts.join(", "));
1559 }
1560 body.push('\n');
1561 }
1562 body
1563}
1564
1565pub fn flow(ctx: &ContextCompiler, id_or_name: &str, budget: usize, full: bool) -> ContextPack {
1567 let mut pack = ContextPack::new("flow", &ctx.revision());
1568 let f = ctx
1569 .view
1570 .flows()
1571 .iter()
1572 .find(|f| f.id == id_or_name || f.name == id_or_name)
1573 .cloned();
1574 let Some(f) = f else {
1575 pack.content = format!("# FLOW NOT FOUND\nNo flow matches '{id_or_name}'.\n");
1576 pack.warnings.push(format!("unknown flow: {id_or_name}"));
1577 return pack;
1578 };
1579 pack.entity_ids.push(f.id.clone());
1580 let mut sections: Vec<Section> = Vec::new();
1581
1582 let steps = render_flow(ctx, &f.id, false);
1583 sections.push(Section::new("STEPS", steps, 10));
1584
1585 let mut attrs = String::new();
1586 for (k, v) in &f.attributes {
1587 attrs.push_str(&format!("{k}: {v}\n"));
1588 }
1589 if !attrs.is_empty() {
1590 sections.push(Section::new("ATTRIBUTES", attrs, 6));
1591 }
1592
1593 if full {
1594 finish_soft(&mut pack, sections, budget, ctx_warnings(ctx));
1595 } else {
1596 finish(&mut pack, sections, budget, ctx_warnings(ctx));
1597 }
1598 pack
1599}
1600
1601pub fn impact(
1607 ctx: &ContextCompiler,
1608 files: &[String],
1609 symbols: &[String],
1610 diff_base: Option<&str>,
1611 budget: usize,
1612 full: bool,
1613) -> ContextPack {
1614 let mut pack = ContextPack::new("impact", &ctx.revision());
1615 let mut files = files.to_vec();
1616 let symbols = symbols.to_vec();
1617 if let Some(base) = diff_base {
1618 if files.is_empty() && symbols.is_empty() {
1619 match scc_graph::impact::diff_files(ctx.store, Some(base)) {
1620 Ok(d) => files = d,
1621 Err(e) => pack.warnings.push(format!("git diff failed: {e}")),
1622 }
1623 }
1624 }
1625
1626 let imp = match scc_graph::impact::compute_impact(&ctx.view, ctx.store, &files, &symbols) {
1627 Ok(i) => i,
1628 Err(e) => {
1629 pack.content = format!("# IMPACT ERROR\n{e}\n");
1630 pack.warnings.push(e.to_string());
1631 return pack;
1632 }
1633 };
1634
1635 let mut sections: Vec<Section> = Vec::new();
1636
1637 if !imp.importers.is_empty() {
1638 let mut body = String::new();
1639 for i in &imp.importers {
1640 body.push_str(&format!(
1641 "{} (depth {}, {})\n",
1642 i.file,
1643 i.depth,
1644 match i.provenance {
1645 scc_core::Provenance::Resolved => "resolved",
1646 scc_core::Provenance::Extracted => "extracted",
1647 scc_core::Provenance::Observed => "observed",
1648 scc_core::Provenance::Declared => "declared",
1649 scc_core::Provenance::Inferred => "inferred",
1650 scc_core::Provenance::Stale => "stale",
1651 }
1652 ));
1653 }
1654 sections.push(Section::new("AFFECTED FILES", body, 10));
1655 }
1656
1657 sections.push(Section::new(
1658 "AFFECTED COMPONENTS",
1659 if imp.components.is_empty() {
1660 "(none)".into()
1661 } else {
1662 format!(
1663 "{}\n",
1664 imp.components
1665 .iter()
1666 .map(|c| component_short(&ctx.view, c))
1667 .collect::<Vec<_>>()
1668 .join(", ")
1669 )
1670 },
1671 10,
1672 ));
1673
1674 sections.push(Section::new(
1675 "FLOWS",
1676 if imp.flows.is_empty() {
1677 "(none)".into()
1678 } else {
1679 format!(
1680 "{}\n",
1681 imp.flows
1682 .iter()
1683 .map(|f| {
1684 ctx.view
1685 .flows()
1686 .iter()
1687 .find(|x| &x.id == f)
1688 .map(|x| x.name.clone())
1689 .unwrap_or_else(|| f.clone())
1690 })
1691 .collect::<Vec<_>>()
1692 .join(", ")
1693 )
1694 },
1695 9,
1696 ));
1697
1698 sections.push(Section::new(
1699 "UPSTREAM",
1700 if imp.upstream.is_empty() {
1701 "(none)".into()
1702 } else {
1703 format!(
1704 "{}\n",
1705 imp.upstream
1706 .iter()
1707 .map(|c| component_short(&ctx.view, c))
1708 .collect::<Vec<_>>()
1709 .join(", ")
1710 )
1711 },
1712 9,
1713 ));
1714 sections.push(Section::new(
1715 "DOWNSTREAM",
1716 if imp.downstream.is_empty() {
1717 "(none)".into()
1718 } else {
1719 format!(
1720 "{}\n",
1721 imp.downstream
1722 .iter()
1723 .map(|c| component_short(&ctx.view, c))
1724 .collect::<Vec<_>>()
1725 .join(", ")
1726 )
1727 },
1728 9,
1729 ));
1730
1731 let contracts: Vec<String> = imp
1732 .contracts
1733 .iter()
1734 .map(|c| entity_name(&ctx.view, c))
1735 .collect();
1736 sections.push(Section::new(
1737 "CONTRACTS",
1738 if contracts.is_empty() {
1739 "(none)".into()
1740 } else {
1741 format!("{}\n", contracts.join(", "))
1742 },
1743 9,
1744 ));
1745
1746 let data: Vec<String> = imp.data.iter().map(|d| entity_name(&ctx.view, d)).collect();
1747 sections.push(Section::new(
1748 "DATA",
1749 if data.is_empty() {
1750 "(none)".into()
1751 } else {
1752 format!("{}\n", data.join(", "))
1753 },
1754 9,
1755 ));
1756
1757 let invs: Vec<String> = imp
1758 .invariants
1759 .iter()
1760 .map(|i| {
1761 ctx.view
1762 .invariants()
1763 .iter()
1764 .find(|x| &x.id == i)
1765 .map(|x| format!("[{}] {}", severity_str(x.severity), x.statement))
1766 .unwrap_or_else(|| i.clone())
1767 })
1768 .collect();
1769 if !invs.is_empty() {
1770 sections.push(Section::new(
1771 "INVARIANTS",
1772 format!("{}\n", invs.join("\n")),
1773 10,
1774 ));
1775 }
1776
1777 let tests: Vec<String> = imp
1778 .tests
1779 .iter()
1780 .map(|t| entity_name(&ctx.view, t))
1781 .collect();
1782 sections.push(Section::new(
1783 "TESTS",
1784 if tests.is_empty() {
1785 "(none)".into()
1786 } else {
1787 format!("{}\n", tests.join(", "))
1788 },
1789 7,
1790 ));
1791
1792 sections.push(Section::new(
1793 "RISK",
1794 format!("{}\n", imp.risk.to_uppercase()),
1795 10,
1796 ));
1797 if !imp.forgotten_partners.is_empty() {
1798 let mut body = String::new();
1799 for p in &imp.forgotten_partners {
1800 body.push_str(&format!(
1801 "{} ({} ×{} with {}) — historical; not EXTRACTED impact\n",
1802 p.partner, p.reason, p.commits, p.file
1803 ));
1804 }
1805 sections.push(Section::new("FORGOTTEN PARTNERS", body, 6));
1806 }
1807 if !imp.notes.is_empty() {
1808 sections.push(Section::new(
1809 "NOTES",
1810 format!("{}\n", imp.notes.join("\n")),
1811 8,
1812 ));
1813 }
1814
1815 pack.entity_ids = imp.components.clone();
1816 if full {
1817 finish_soft(&mut pack, sections, budget, ctx_warnings(ctx));
1818 } else {
1819 finish(&mut pack, sections, budget, ctx_warnings(ctx));
1820 }
1821 pack
1822}
1823
1824pub fn verify(ctx: &ContextCompiler, budget: usize, full: bool) -> ContextPack {
1830 let mut pack = ContextPack::new("verify", &ctx.revision());
1831 let mut sections: Vec<Section> = Vec::new();
1832
1833 let snapshot = ctx.store.snapshot_status().ok().flatten();
1834 let mut status = String::new();
1835 if let Some((s, files)) = &snapshot {
1836 status.push_str(&format!("Revision: {}\n", s.revision));
1837 if let Some(b) = &s.branch {
1838 status.push_str(&format!("Branch: {b}\n"));
1839 }
1840 status.push_str(&format!("Indexed at: {}\n", s.indexed_at));
1841 status.push_str(&format!("Files indexed: {files}\n"));
1842 } else {
1843 status.push_str("NOT INDEXED\n");
1844 }
1845 sections.push(Section::new("SNAPSHOT", status, 10));
1846
1847 let mut fresh = String::new();
1849 if ctx.stale_paths.is_empty() {
1850 fresh.push_str("Fresh: all indexed files match the working tree.\n");
1851 } else {
1852 fresh.push_str(&format!(
1853 "STALE: {} file(s) changed since indexing:\n",
1854 ctx.stale_paths.len()
1855 ));
1856 for p in ctx.stale_paths.iter().take(20) {
1857 fresh.push_str(&format!("- {p}\n"));
1858 }
1859 }
1860 sections.push(Section::new("FRESHNESS", fresh, 10));
1861 if let Some(raw) = ctx.store.meta_get("scan_stats").ok().flatten() {
1863 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
1864 let n = |k: &str| v.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
1865 sections.push(Section::new(
1866 "FILES",
1867 format!(
1868 "discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}\n",
1869 n("discovered"),
1870 n("indexed"),
1871 n("ignored"),
1872 n("unsupported"),
1873 n("oversized"),
1874 n("unreadable")
1875 ),
1876 10,
1877 ));
1878 }
1879 }
1880
1881 let mut stale_facts = String::new();
1883 let mut stale_count = 0usize;
1884 for ev in ctx.store.all_evidence().unwrap_or_default() {
1885 if let Some(p) = &ev.path {
1886 if ctx.is_stale_path(p) {
1887 stale_count += 1;
1888 }
1889 }
1890 }
1891 stale_facts.push_str(&format!(
1892 "{} evidence record(s) reference changed files.\n",
1893 stale_count
1894 ));
1895 sections.push(Section::new("STALE FACTS", stale_facts, 10));
1896
1897 let verify_rels: Vec<&scc_core::Relationship> = ctx.view.all_rels();
1903 let mut inv = String::new();
1904 let mut dangling = 0usize;
1906 for r in &verify_rels {
1907 let known = |id: &str| -> bool {
1910 ctx.view.entity(id).is_some()
1911 || id.contains("/external_api/")
1912 || id.contains("/component/")
1913 || id.contains("/flow/")
1914 || id.contains("/invariant/")
1915 };
1916 if !known(&r.subject) {
1917 dangling += 1;
1918 if dangling <= 5 {
1919 inv.push_str(&format!(
1920 "dangling subject: {} — {}\n",
1921 r.subject, r.predicate
1922 ));
1923 }
1924 }
1925 if !known(&r.object) {
1926 dangling += 1;
1927 if dangling <= 5 {
1928 inv.push_str(&format!(
1929 "dangling object: {} — {}\n",
1930 r.predicate, r.object
1931 ));
1932 }
1933 }
1934 }
1935 inv.push_str(&format!("Dangling references: {dangling}\n"));
1936 let mut no_evidence = 0usize;
1938 for r in &verify_rels {
1939 if r.provenance == Provenance::Resolved && r.evidence.is_empty() {
1940 no_evidence += 1;
1941 }
1942 }
1943 inv.push_str(&format!("RESOLVED facts without evidence: {no_evidence}\n"));
1944 let unenforced = ctx
1946 .view
1947 .invariants()
1948 .iter()
1949 .filter(|i| i.severity == Severity::Critical && i.enforced_by.is_empty())
1950 .count();
1951 inv.push_str(&format!(
1952 "Critical invariants without enforcing tests: {unenforced}\n"
1953 ));
1954 let inferred = verify_rels
1956 .iter()
1957 .filter(|r| r.provenance == Provenance::Inferred)
1958 .count();
1959 inv.push_str(&format!("Inferred claims (labeled): {inferred}\n"));
1960 sections.push(Section::new("GRAPH INVARIANTS", inv, 10));
1961
1962 let findings = ctx.store.drift_findings(true).unwrap_or_default();
1964 let mut drift = String::new();
1965 if findings.is_empty() {
1966 drift.push_str("No drift findings.\n");
1967 }
1968 for (_, kind, sev, msg, _) in &findings {
1969 drift.push_str(&format!("- [{sev}] {kind}: {msg}\n"));
1970 }
1971 sections.push(Section::new("DRIFT", drift, 10));
1972
1973 let mut low_conf = String::new();
1975 let mut lc = 0usize;
1976 for r in &verify_rels {
1977 if r.predicate == scc_core::predicates::DEPENDS_ON && r.confidence < 0.8 {
1978 lc += 1;
1979 if lc <= 8 {
1980 low_conf.push_str(&format!(
1981 "- {} → {} ({:.2})\n",
1982 component_short(&ctx.view, &r.subject),
1983 component_short(&ctx.view, &r.object),
1984 r.confidence
1985 ));
1986 }
1987 }
1988 }
1989 if lc > 0 {
1990 low_conf.push_str(&format!("… {lc} low-confidence dependency edge(s)\n"));
1991 sections.push(Section::new("LOW-CONFIDENCE DEPENDENCIES", low_conf, 8));
1992 }
1993
1994 let crossings =
1998 scc_graph::boundaries::boundary_crossings_from_rels(ctx.view.graph, &verify_rels, false);
1999 if !crossings.is_empty() {
2000 let mut b = String::new();
2001 b.push_str(&format!("{} boundary crossing(s):\n", crossings.len()));
2002 for c in crossings.iter().take(12) {
2003 b.push_str(&format!("- {c}\n"));
2004 }
2005 sections.push(Section::new("BOUNDARIES", b, 8));
2006 }
2007
2008 let runtime_edges = ctx.store.runtime_edge_rows().unwrap_or_default();
2010 if !runtime_edges.is_empty() {
2011 let mut rt = String::new();
2012 let total: u64 = runtime_edges.iter().map(|e| e.count).sum();
2013 let errs: u64 = runtime_edges.iter().map(|e| e.errors).sum();
2014 rt.push_str(&format!(
2015 "{} observed edge(s), {} total observations, {} error(s).\n",
2016 runtime_edges.len(),
2017 total,
2018 errs
2019 ));
2020 for e in runtime_edges.iter().take(12) {
2021 rt.push_str(&format!(
2022 "- {} → {} ×{} (avg {:.1} ms, {} err)\n",
2023 e.source, e.target, e.count, e.latency_ms, e.errors
2024 ));
2025 }
2026 sections.push(Section::new("RUNTIME", rt, 8));
2027 }
2028
2029 let mut verdict = String::new();
2031 let ok = ctx.stale_paths.is_empty()
2032 && dangling == 0
2033 && no_evidence == 0
2034 && unenforced == 0
2035 && findings.is_empty();
2036 if ok {
2037 verdict.push_str("VERIFIED: model is fresh, consistent, and drift-free.\n");
2038 } else {
2039 verdict.push_str("ISSUES FOUND: review the sections above before trusting context.\n");
2040 if !ctx.stale_paths.is_empty() {
2041 verdict.push_str(" → re-index changed files (`scc index`)\n");
2042 }
2043 if unenforced > 0 {
2044 verdict.push_str(" → critical invariants need enforcing tests or declaration\n");
2045 }
2046 if dangling > 0 || no_evidence > 0 {
2047 verdict.push_str(" → graph integrity violated; re-index\n");
2048 }
2049 }
2050 sections.push(Section::new("VERDICT", verdict, 10));
2051
2052 if full {
2053 finish_soft(&mut pack, sections, budget, Vec::new());
2054 } else {
2055 finish(&mut pack, sections, budget, Vec::new());
2056 }
2057 pack
2058}
2059
2060struct LocusHits {
2062 files: Vec<String>,
2063 symbols: Vec<String>,
2064 body: String,
2065}
2066
2067fn resolve_goal_loci(ctx: &crate::ContextCompiler, goal: &str) -> Option<LocusHits> {
2070 let plan = route_query(goal);
2071 if !plan.prefer_locus || plan.loci.is_empty() {
2072 return None;
2073 }
2074 let mut files: Vec<String> = Vec::new();
2075 let mut symbols: Vec<String> = Vec::new();
2076 let mut body = String::new();
2077 for loc in &plan.loci {
2078 let mapped_file = ctx
2079 .view
2080 .entities_of_kind(kinds::FILE)
2081 .into_iter()
2082 .map(|e| e.name.clone())
2083 .find(|p| path_matches_locus(p, &loc.path));
2084 let mapped_file = mapped_file.or_else(|| {
2085 ctx.view
2086 .entities_of_kind(kinds::SYMBOL)
2087 .into_iter()
2088 .find_map(|e| {
2089 e.attributes
2090 .get("file")
2091 .and_then(|v| v.as_str())
2092 .filter(|p| path_matches_locus(p, &loc.path))
2093 .map(|p| p.to_string())
2094 })
2095 });
2096 match mapped_file {
2097 None => {
2098 body.push_str(&format!(
2099 "- {}:{} — unmapped (not fabricated)\n",
2100 loc.path, loc.line
2101 ));
2102 }
2103 Some(path) => {
2104 if !files.iter().any(|f| f == &path) {
2105 files.push(path.clone());
2106 }
2107 let enclosed = innermost_enclosing_symbol(ctx, &path, loc.line);
2108 if let Some(name) = &enclosed {
2109 if !symbols.iter().any(|s| s == name) {
2110 symbols.push(name.clone());
2111 }
2112 }
2113 match enclosed {
2114 Some(name) => {
2115 body.push_str(&format!("- {path}:{} → symbol {name}\n", loc.line))
2116 }
2117 None => body.push_str(&format!(
2118 "- {path}:{} → file (no enclosing symbol)\n",
2119 loc.line
2120 )),
2121 }
2122 }
2123 }
2124 }
2125 Some(LocusHits {
2126 files,
2127 symbols,
2128 body,
2129 })
2130}
2131
2132fn innermost_enclosing_symbol(
2134 ctx: &crate::ContextCompiler,
2135 file: &str,
2136 line: u32,
2137) -> Option<String> {
2138 let mut best: Option<(u32, String)> = None;
2139 for e in ctx.view.entities_of_kind(kinds::SYMBOL) {
2140 let Some(f) = e.attributes.get("file").and_then(|v| v.as_str()) else {
2141 continue;
2142 };
2143 if !path_matches_locus(f, file) && f != file {
2144 continue;
2145 }
2146 let start = e
2147 .attributes
2148 .get("start_line")
2149 .and_then(|v| v.as_u64())
2150 .unwrap_or(0) as u32;
2151 let end = e
2152 .attributes
2153 .get("end_line")
2154 .and_then(|v| v.as_u64())
2155 .unwrap_or(u64::from(start)) as u32;
2156 if start == 0 || line < start || line > end {
2157 continue;
2158 }
2159 let span = end.saturating_sub(start);
2160 if best.as_ref().map(|(s, _)| span < *s).unwrap_or(true) {
2161 best = Some((span, e.name.clone()));
2162 }
2163 }
2164 best.map(|(_, n)| n)
2165}
2166
2167fn collect_tests_to_run(
2170 ctx: &crate::ContextCompiler,
2171 affected_syms: &HashSet<&String>,
2172 affected_comps: &BTreeSet<String>,
2173 owned_stores: &BTreeSet<String>,
2174) -> BTreeMap<String, BTreeSet<&'static str>> {
2175 let mut tests: BTreeMap<String, BTreeSet<&'static str>> = BTreeMap::new();
2176 for sid in affected_syms {
2177 for r in ctx.view.out_pred(sid, scc_core::predicates::TESTED_BY) {
2178 tests.entry(r.object.clone()).or_default().insert("direct");
2179 }
2180 for pred in [
2181 scc_core::predicates::HANDLES,
2182 scc_core::predicates::IMPLEMENTS,
2183 ] {
2184 for r in ctx.view.out_pred(sid, pred) {
2185 let Some(target) = ctx.view.entity(&r.object) else {
2186 continue;
2187 };
2188 if target.kind != kinds::CONTRACT && target.kind != kinds::ROUTE {
2189 continue;
2190 }
2191 for t in ctx
2192 .view
2193 .out_pred(&r.object, scc_core::predicates::TESTED_BY)
2194 {
2195 tests
2196 .entry(t.object.clone())
2197 .or_default()
2198 .insert("contract");
2199 }
2200 }
2201 }
2202 }
2203 let affected_files: BTreeSet<String> = affected_syms
2204 .iter()
2205 .filter_map(|sid| {
2206 ctx.view
2207 .entity(sid.as_str())
2208 .and_then(|e| e.attributes.get("file"))
2209 .and_then(|v| v.as_str())
2210 .map(|f| f.to_string())
2211 })
2212 .collect();
2213 if !affected_files.is_empty() {
2214 for (id, _name, file, _kind, _sym) in ctx.store.tests().unwrap_or_default() {
2215 let imports = ctx.store.imports_in_file(&file).unwrap_or_default();
2216 let hits_affected = imports.iter().any(|(module, _names, _line, _typ)| {
2217 let target = resolve_module_ref(&file, module);
2218 affected_files.iter().any(|f| {
2219 *f == target
2220 || f.starts_with(&format!("{target}."))
2221 || *f == format!("{target}/__init__.py")
2222 })
2223 });
2224 if hits_affected {
2225 tests.entry(id).or_default().insert("import");
2226 }
2227 }
2228 }
2229 let mut state_ids: BTreeSet<String> = BTreeSet::new();
2230 for sid in owned_stores {
2231 if is_state_like(ctx.view.entity(sid).map(|e| e.kind.as_str())) {
2232 state_ids.insert(sid.clone());
2233 }
2234 }
2235 for cid in affected_comps {
2236 for r in ctx.view.out_pred(cid, scc_core::predicates::OWNS) {
2237 if is_state_like(ctx.view.entity(&r.object).map(|e| e.kind.as_str())) {
2238 state_ids.insert(r.object.clone());
2239 }
2240 }
2241 }
2242 if !state_ids.is_empty() {
2243 for (id, _name, _file, _kind, _sym) in ctx.store.tests().unwrap_or_default() {
2244 let reads = ctx
2245 .view
2246 .out_pred(&id, scc_core::predicates::READS)
2247 .iter()
2248 .any(|r| state_ids.contains(&r.object));
2249 let writes = ctx
2250 .view
2251 .out_pred(&id, scc_core::predicates::WRITES)
2252 .iter()
2253 .any(|r| state_ids.contains(&r.object));
2254 if reads || writes {
2255 tests.entry(id).or_default().insert("state");
2256 }
2257 }
2258 }
2259 tests
2260}
2261
2262fn is_state_like(kind: Option<&str>) -> bool {
2264 matches!(
2265 kind,
2266 Some(kinds::STATE)
2267 | Some(kinds::DATA_STORE)
2268 | Some(kinds::DATA_ENTITY)
2269 | Some(kinds::TABLE)
2270 | Some(kinds::COLLECTION)
2271 | Some(kinds::CACHE)
2272 )
2273}
2274
2275fn format_test_to_run(
2277 ctx: &crate::ContextCompiler,
2278 tid: &str,
2279 reasons: &BTreeSet<&'static str>,
2280) -> String {
2281 let name = entity_name(&ctx.view, tid);
2282 let file = ctx
2283 .view
2284 .entity(tid)
2285 .and_then(|e| e.attributes.get("file"))
2286 .and_then(|v| v.as_str())
2287 .unwrap_or("");
2288 let why = reasons.iter().copied().collect::<Vec<_>>().join(", ");
2289 if file.is_empty() {
2290 format!("- {name} — {why}\n")
2291 } else {
2292 format!("- {name} ({file}) — {why}\n")
2293 }
2294}
2295
2296fn resolve_module_ref(from_file: &str, module: &str) -> String {
2299 if !module.starts_with('.') {
2300 return module.replace('.', "/");
2301 }
2302 let dir = from_file.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
2303 let joined = if dir.is_empty() {
2304 module.to_string()
2305 } else {
2306 format!("{dir}/{module}")
2307 };
2308 let mut parts: Vec<&str> = Vec::new();
2309 for seg in joined.split('/') {
2310 match seg {
2311 "" | "." => continue,
2312 ".." => {
2313 parts.pop();
2314 }
2315 s => parts.push(s),
2316 }
2317 }
2318 parts.join("/")
2319}
2320
2321fn flow_kind_str(k: scc_core::FlowKind) -> &'static str {
2322 match k {
2323 scc_core::FlowKind::Architecture => "architecture",
2324 scc_core::FlowKind::Workflow => "workflow",
2325 scc_core::FlowKind::Sequence => "sequence",
2326 scc_core::FlowKind::Dataflow => "dataflow",
2327 scc_core::FlowKind::Lifecycle => "lifecycle",
2328 }
2329}
2330
2331fn severity_str(s: Severity) -> &'static str {
2332 match s {
2333 Severity::Info => "info",
2334 Severity::Low => "low",
2335 Severity::Medium => "medium",
2336 Severity::High => "high",
2337 Severity::Critical => "critical",
2338 }
2339}
2340
2341#[cfg(test)]
2342mod tests {
2343 use super::*;
2344 use scc_core::{Entity, Provenance, Relationship};
2345 use scc_store::Store;
2346
2347 #[test]
2348 fn task_context_files_stay_inside_the_repo() {
2350 let dir = tempfile::TempDir::new().unwrap();
2351 let root = dir.path().join("repo");
2352 std::fs::create_dir_all(&root).unwrap();
2353 std::fs::write(root.join("ok.py"), "IN_REPO = 1\n").unwrap();
2354 std::fs::write(dir.path().join("secret.py"), "OUTSIDE_SECRET = 1\n").unwrap();
2355 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2356 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2357 let ctx = crate::ContextCompiler::new(
2358 &store,
2359 &graph,
2360 crate::ContextSettings::default(),
2361 Vec::new(),
2362 );
2363 let body = exact_source_tail(
2364 &ctx,
2365 &[
2366 "ok.py".into(),
2367 "../secret.py".into(),
2368 "/etc/passwd".into(),
2369 ],
2370 &[],
2371 );
2372 assert!(body.contains("IN_REPO"), "{body}");
2373 assert!(!body.contains("OUTSIDE_SECRET"), "{body}");
2374 assert!(!body.contains("/etc/passwd"), "{body}");
2375 assert!(!body.contains("secret.py"), "{body}");
2376 }
2377
2378 #[test]
2379 fn tests_to_run_records_direct_reason() {
2381 let dir = tempfile::TempDir::new().unwrap();
2382 let root = dir.path().join("repo");
2383 std::fs::create_dir_all(&root).unwrap();
2384 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2385 let sid = "s:fn".to_string();
2386 let tid = "t:test_fn".to_string();
2387 let mut se = Entity::new(&sid, kinds::SYMBOL, "handleList");
2388 se.attr("file", serde_json::json!("src/a.py"));
2389 store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2390 let mut te = Entity::new(&tid, kinds::TEST, "test_handle_list");
2391 te.attr("file", serde_json::json!("tests/test_a.py"));
2392 store
2393 .insert_entity(&te, &["tests/test_a.py".into()])
2394 .unwrap();
2395 let rel = Relationship::new(
2396 "rel:tb",
2397 sid.clone(),
2398 scc_core::predicates::TESTED_BY,
2399 tid.clone(),
2400 Provenance::Extracted,
2401 );
2402 store.insert_relationship(&rel, "src/a.py").unwrap();
2403 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2404 let ctx = crate::ContextCompiler::new(
2405 &store,
2406 &graph,
2407 crate::ContextSettings::default(),
2408 Vec::new(),
2409 );
2410 let affected: HashSet<&String> = [&sid].into_iter().collect();
2411 let found = collect_tests_to_run(&ctx, &affected, &BTreeSet::new(), &BTreeSet::new());
2412 let reasons = found
2413 .get(&tid)
2414 .expect("direct TESTED_BY must list the test");
2415 assert!(reasons.contains("direct"));
2416 let line = format_test_to_run(&ctx, &tid, reasons);
2417 assert!(line.contains("direct"), "{line}");
2418 assert!(line.contains("test_handle_list"), "{line}");
2419 assert!(line.contains("tests/test_a.py"), "{line}");
2420 }
2421
2422 #[test]
2423 fn tests_to_run_records_import_reason() {
2425 let dir = tempfile::TempDir::new().unwrap();
2426 let root = dir.path().join("repo");
2427 std::fs::create_dir_all(&root).unwrap();
2428 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2429 let sid = "s:fn".to_string();
2430 let tid = "t:import_test".to_string();
2431 let mut se = Entity::new(&sid, kinds::SYMBOL, "handleList");
2432 se.attr("file", serde_json::json!("src/a.py"));
2433 store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2434 let mut te = Entity::new(&tid, kinds::TEST, "test_via_import");
2435 te.attr("file", serde_json::json!("tests/test_a.py"));
2436 store
2437 .insert_entity(&te, &["tests/test_a.py".into()])
2438 .unwrap();
2439 store
2440 .insert_test(&tid, "test_via_import", "tests/test_a.py", "unit", None)
2441 .unwrap();
2442 store
2443 .insert_imports(
2444 "tests/test_a.py",
2445 &[("src.a".into(), vec![], 1, "module".into())],
2446 )
2447 .unwrap();
2448 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2449 let ctx = crate::ContextCompiler::new(
2450 &store,
2451 &graph,
2452 crate::ContextSettings::default(),
2453 Vec::new(),
2454 );
2455 let affected: HashSet<&String> = [&sid].into_iter().collect();
2456 let found = collect_tests_to_run(&ctx, &affected, &BTreeSet::new(), &BTreeSet::new());
2457 let reasons = found
2458 .get(&tid)
2459 .expect("import of affected file must list the test");
2460 assert!(reasons.contains("import"));
2461 assert!(!reasons.contains("direct"));
2462 }
2463
2464 #[test]
2465 fn tests_to_run_records_contract_reason() {
2467 let dir = tempfile::TempDir::new().unwrap();
2468 let root = dir.path().join("repo");
2469 std::fs::create_dir_all(&root).unwrap();
2470 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2471 let sid = "s:handler".to_string();
2472 let cid = "c:list".to_string();
2473 let tid = "t:contract".to_string();
2474 let mut se = Entity::new(&sid, kinds::SYMBOL, "handleList");
2475 se.attr("file", serde_json::json!("src/a.py"));
2476 store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2477 store
2478 .insert_entity(
2479 &Entity::new(&cid, kinds::CONTRACT, "GET /list"),
2480 &["src/a.py".into()],
2481 )
2482 .unwrap();
2483 let mut te = Entity::new(&tid, kinds::TEST, "test_list_route");
2484 te.attr("file", serde_json::json!("tests/test_routes.py"));
2485 store
2486 .insert_entity(&te, &["tests/test_routes.py".into()])
2487 .unwrap();
2488 store
2489 .insert_relationship(
2490 &Relationship::new(
2491 "rel:h",
2492 sid.clone(),
2493 scc_core::predicates::HANDLES,
2494 cid.clone(),
2495 Provenance::Extracted,
2496 ),
2497 "src/a.py",
2498 )
2499 .unwrap();
2500 store
2501 .insert_relationship(
2502 &Relationship::new(
2503 "rel:ct",
2504 cid.clone(),
2505 scc_core::predicates::TESTED_BY,
2506 tid.clone(),
2507 Provenance::Extracted,
2508 ),
2509 "tests/test_routes.py",
2510 )
2511 .unwrap();
2512 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2513 let ctx = crate::ContextCompiler::new(
2514 &store,
2515 &graph,
2516 crate::ContextSettings::default(),
2517 Vec::new(),
2518 );
2519 let affected: HashSet<&String> = [&sid].into_iter().collect();
2520 let found = collect_tests_to_run(&ctx, &affected, &BTreeSet::new(), &BTreeSet::new());
2521 let reasons = found
2522 .get(&tid)
2523 .expect("contract TESTED_BY must list the test");
2524 assert!(reasons.contains("contract"));
2525 assert!(!reasons.contains("direct"));
2526 }
2527
2528 #[test]
2529 fn tests_to_run_records_state_reason() {
2531 let dir = tempfile::TempDir::new().unwrap();
2532 let root = dir.path().join("repo");
2533 std::fs::create_dir_all(&root).unwrap();
2534 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2535 let comp = "comp:orders".to_string();
2536 let st = "state:orders_db".to_string();
2537 let tid = "t:state".to_string();
2538 store
2539 .insert_entity(
2540 &Entity::new(&comp, kinds::COMPONENT, "orders"),
2541 &["src/a.py".into()],
2542 )
2543 .unwrap();
2544 store
2545 .insert_entity(
2546 &Entity::new(&st, kinds::STATE, "orders_db"),
2547 &["src/a.py".into()],
2548 )
2549 .unwrap();
2550 let mut te = Entity::new(&tid, kinds::TEST, "test_orders_state");
2551 te.attr("file", serde_json::json!("tests/test_state.py"));
2552 store
2553 .insert_entity(&te, &["tests/test_state.py".into()])
2554 .unwrap();
2555 store
2556 .insert_test(
2557 &tid,
2558 "test_orders_state",
2559 "tests/test_state.py",
2560 "unit",
2561 None,
2562 )
2563 .unwrap();
2564 store
2565 .insert_relationship(
2566 &Relationship::new(
2567 "rel:owns",
2568 comp.clone(),
2569 scc_core::predicates::OWNS,
2570 st.clone(),
2571 Provenance::Extracted,
2572 ),
2573 "src/a.py",
2574 )
2575 .unwrap();
2576 store
2577 .insert_relationship(
2578 &Relationship::new(
2579 "rel:rd",
2580 tid.clone(),
2581 scc_core::predicates::READS,
2582 st.clone(),
2583 Provenance::Extracted,
2584 ),
2585 "tests/test_state.py",
2586 )
2587 .unwrap();
2588 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2589 let ctx = crate::ContextCompiler::new(
2590 &store,
2591 &graph,
2592 crate::ContextSettings::default(),
2593 Vec::new(),
2594 );
2595 let comps: BTreeSet<String> = [comp].into_iter().collect();
2596 let found = collect_tests_to_run(&ctx, &HashSet::new(), &comps, &BTreeSet::new());
2597 let reasons = found.get(&tid).expect("state READS must list the test");
2598 assert!(reasons.contains("state"));
2599 }
2600
2601 #[test]
2602 fn tests_to_run_records_state_reason_for_data_store() {
2604 let dir = tempfile::TempDir::new().unwrap();
2605 let root = dir.path().join("repo");
2606 std::fs::create_dir_all(&root).unwrap();
2607 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2608 let comp = "comp:orders".to_string();
2609 let st = "store:orders_db".to_string();
2610 let tid = "t:store".to_string();
2611 store
2612 .insert_entity(
2613 &Entity::new(&comp, kinds::COMPONENT, "orders"),
2614 &["src/a.py".into()],
2615 )
2616 .unwrap();
2617 store
2618 .insert_entity(
2619 &Entity::new(&st, kinds::DATA_STORE, "orders_db"),
2620 &["src/a.py".into()],
2621 )
2622 .unwrap();
2623 let mut te = Entity::new(&tid, kinds::TEST, "test_orders_store");
2624 te.attr("file", serde_json::json!("tests/test_store.py"));
2625 store
2626 .insert_entity(&te, &["tests/test_store.py".into()])
2627 .unwrap();
2628 store
2629 .insert_test(
2630 &tid,
2631 "test_orders_store",
2632 "tests/test_store.py",
2633 "unit",
2634 None,
2635 )
2636 .unwrap();
2637 store
2638 .insert_relationship(
2639 &Relationship::new(
2640 "rel:owns-store",
2641 comp.clone(),
2642 scc_core::predicates::OWNS,
2643 st.clone(),
2644 Provenance::Extracted,
2645 ),
2646 "src/a.py",
2647 )
2648 .unwrap();
2649 store
2650 .insert_relationship(
2651 &Relationship::new(
2652 "rel:rd-store",
2653 tid.clone(),
2654 scc_core::predicates::READS,
2655 st.clone(),
2656 Provenance::Extracted,
2657 ),
2658 "tests/test_store.py",
2659 )
2660 .unwrap();
2661 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2662 let ctx = crate::ContextCompiler::new(
2663 &store,
2664 &graph,
2665 crate::ContextSettings::default(),
2666 Vec::new(),
2667 );
2668 let comps: BTreeSet<String> = [comp].into_iter().collect();
2669 let found = collect_tests_to_run(&ctx, &HashSet::new(), &comps, &BTreeSet::new());
2670 let reasons = found.get(&tid).expect("store READS must list the test");
2671 assert!(reasons.contains("state"));
2672 }
2673
2674 #[test]
2675 fn stack_locus_seeds_file_and_innermost_symbol() {
2677 let dir = tempfile::TempDir::new().unwrap();
2678 let root = dir.path().join("repo");
2679 std::fs::create_dir_all(root.join("src")).unwrap();
2680 std::fs::write(root.join("src/app.py"), "def inner():\n x = 1\n").unwrap();
2681 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2682 let fe = Entity::new("f:app", kinds::FILE, "src/app.py");
2683 store.insert_entity(&fe, &["src/app.py".into()]).unwrap();
2684 let mut outer = Entity::new("s:outer", kinds::SYMBOL, "outer");
2685 outer.attr("file", serde_json::json!("src/app.py"));
2686 outer.attr("start_line", serde_json::json!(1u32));
2687 outer.attr("end_line", serde_json::json!(20u32));
2688 store.insert_entity(&outer, &["src/app.py".into()]).unwrap();
2689 let mut inner = Entity::new("s:inner", kinds::SYMBOL, "inner");
2690 inner.attr("file", serde_json::json!("src/app.py"));
2691 inner.attr("start_line", serde_json::json!(10u32));
2692 inner.attr("end_line", serde_json::json!(12u32));
2693 store.insert_entity(&inner, &["src/app.py".into()]).unwrap();
2694 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2695 let ctx = crate::ContextCompiler::new(
2696 &store,
2697 &graph,
2698 crate::ContextSettings::default(),
2699 Vec::new(),
2700 );
2701 let goal = "Traceback (most recent call last):\n File \"src/app.py\", line 11, in inner\nValueError: boom\n";
2702 let hits = resolve_goal_loci(&ctx, goal).expect("locus");
2703 assert!(hits.files.iter().any(|f| f == "src/app.py"));
2704 assert_eq!(hits.symbols, vec!["inner".to_string()]);
2705 assert!(hits.body.contains("inner"));
2706 let miss = resolve_goal_loci(
2707 &ctx,
2708 "Traceback (most recent call last):\n File \"src/missing.py\", line 1, in x\nValueError: x\n",
2709 )
2710 .unwrap();
2711 assert!(miss.body.contains("unmapped"));
2712 assert!(miss.files.is_empty());
2713 }
2714
2715 #[test]
2716 fn finish_with_rollover_discloses_truncated_quota() {
2718 let mut pack = ContextPack::new("task", "rev");
2719 let huge = "word ".repeat(4000);
2720 let sections = vec![
2721 Section::new("TASK", "goal\n".into(), 10),
2722 Section::new("IMPLEMENTATION", huge, 7),
2723 ];
2724 finish_with_rollover(&mut pack, sections, 200, Vec::new());
2725 assert!(
2726 pack.dropped_sections.iter().any(|d| d == "quota:source"),
2727 "{:?}",
2728 pack.dropped_sections
2729 );
2730 assert!(pack.truncated);
2731 assert!(pack.content.contains("truncated") || pack.hard_truncated);
2732 }
2733
2734 #[test]
2735 fn impact_pack_discloses_forgotten_partners_without_merging_them() {
2737 let dir = tempfile::TempDir::new().unwrap();
2738 let root = dir.path().join("repo");
2739 std::fs::create_dir_all(root.join("src")).unwrap();
2740 fn git(root: &std::path::Path, args: &[&str]) {
2741 let out = std::process::Command::new("git")
2742 .args(args)
2743 .current_dir(root)
2744 .output()
2745 .unwrap();
2746 assert!(
2747 out.status.success(),
2748 "{args:?} {}",
2749 String::from_utf8_lossy(&out.stderr)
2750 );
2751 }
2752 git(&root, &["init", "-q"]);
2753 git(&root, &["config", "user.email", "test@example.com"]);
2754 git(&root, &["config", "user.name", "SCC Test"]);
2755 git(&root, &["config", "commit.gpgsign", "false"]);
2756 for i in 0..2 {
2757 std::fs::write(root.join("src/a.py"), format!("a = {i}\n")).unwrap();
2758 std::fs::write(root.join("src/b.py"), format!("b = {i}\n")).unwrap();
2759 git(&root, &["add", "-A"]);
2760 git(&root, &["commit", "-q", "-m", &format!("c{i}")]);
2761 }
2762 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2763 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2764 let ctx = crate::ContextCompiler::new(
2765 &store,
2766 &graph,
2767 crate::ContextSettings::default(),
2768 Vec::new(),
2769 );
2770 let pack = impact(&ctx, &["src/a.py".into()], &[], None, 50_000, false);
2771 assert!(
2772 pack.content.contains("FORGOTTEN PARTNERS"),
2773 "missing section: {}",
2774 pack.content
2775 );
2776 assert!(pack.content.contains("src/b.py"), "{}", pack.content);
2777 assert!(pack.content.contains("cochange"), "{}", pack.content);
2778 assert!(
2779 !pack.content.contains("AFFECTED COMPONENTS\nsrc/b.py"),
2780 "partner must not become affected: {}",
2781 pack.content
2782 );
2783 assert!(pack.content.contains("historical; not EXTRACTED impact"));
2784 }
2785
2786 #[test]
2787 fn task_pack_puts_exact_source_last_and_drops_it_first() {
2789 let dir = tempfile::TempDir::new().unwrap();
2790 let root = dir.path().join("repo");
2791 std::fs::create_dir_all(root.join("src")).unwrap();
2792 let src = "def handle_list():\n return [1, 2, 3]\n";
2793 std::fs::write(root.join("src/a.py"), src).unwrap();
2794 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2795 let mut se = Entity::new("s:fn", kinds::SYMBOL, "handle_list");
2796 se.attr("file", serde_json::json!("src/a.py"));
2797 store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2798 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2799 let ctx = crate::ContextCompiler::new(
2800 &store,
2801 &graph,
2802 crate::ContextSettings::default(),
2803 Vec::new(),
2804 );
2805 let fat = task(&ctx, "handle list", &["src/a.py".into()], &[], 50_000);
2806 let idx_exact = fat
2807 .content
2808 .find("EXACT SOURCE")
2809 .expect("exact source must appear when budget allows");
2810 let idx_task = fat.content.find("TASK").expect("task header");
2811 assert!(
2812 idx_exact > idx_task,
2813 "exact source must follow semantic sections"
2814 );
2815 assert!(
2816 fat.content.contains("shown=2 total=2 capped=0"),
2817 "{}",
2818 fat.content
2819 );
2820 assert!(fat.content.contains("def handle_list()"), "{}", fat.content);
2821 assert!(
2822 fat.content.contains("FETCH") && fat.content.contains("handle=scc://"),
2823 "task pack must stamp fetch handles: {}",
2824 fat.content
2825 );
2826 let thin = task(&ctx, "handle list", &["src/a.py".into()], &[], 80);
2827 assert!(
2828 thin.dropped_sections.iter().any(|s| s == "EXACT SOURCE"),
2829 "exact source is priority 1 and must drop first: {:?}",
2830 thin.dropped_sections
2831 );
2832 assert!(
2833 !thin.content.contains("EXACT SOURCE"),
2834 "dropped exact source must not remain in content: {}",
2835 thin.content
2836 );
2837 }
2838
2839 #[test]
2840 fn task_pack_stamps_fetch_handles_that_roundtrip() {
2842 let dir = tempfile::TempDir::new().unwrap();
2843 let root = dir.path().join("repo");
2844 std::fs::create_dir_all(root.join("src")).unwrap();
2845 let src = "def handle_list():\n return [1, 2, 3]\n";
2846 std::fs::write(root.join("src/a.py"), src).unwrap();
2847 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2848 let mut se = Entity::new("s:fn", kinds::SYMBOL, "handle_list");
2849 se.attr("file", serde_json::json!("src/a.py"));
2850 store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2851 let graph = scc_graph::RealityGraph::load(&store).unwrap();
2852 let ctx = crate::ContextCompiler::new(
2853 &store,
2854 &graph,
2855 crate::ContextSettings::default(),
2856 Vec::new(),
2857 );
2858 let pack = task(&ctx, "handle list", &["src/a.py".into()], &[], 50_000);
2859 let handle = pack
2860 .content
2861 .lines()
2862 .find_map(|l| l.split("handle=").nth(1))
2863 .map(|s| s.split_whitespace().next().unwrap_or(s).to_string())
2864 .expect("handle in FETCH or EXACT SOURCE");
2865 assert!(handle.starts_with("scc://"), "{handle}");
2866 let resolved =
2867 crate::structural_source::resolve_handle_to_path(&root, &handle).expect("fresh handle");
2868 assert_eq!(resolved, "src/a.py");
2869 let stale = format!(
2870 "{}@aaaaaaaaaaaaaaaa",
2871 handle.rsplit_once('@').map(|(p, _)| p).unwrap_or(&handle)
2872 );
2873 let err = crate::structural_source::resolve_handle_to_path(&root, &stale).unwrap_err();
2874 assert!(err.contains("stale"), "{err}");
2875 }
2876
2877 #[test]
2878 fn render_hard_cap_always_fits() {
2880 let sections = vec![
2883 Section::new("IDENTITY", "repo: x\n".into(), 10),
2884 Section::new("COMPONENTS", "line\n".repeat(2000), 9),
2885 Section::new("FLOWS", "flow\n".repeat(2000), 5),
2886 Section::new("INDEX STATUS", "ok\n".into(), 5),
2887 ];
2888 let (content, outcome) = render(sections, 300, vec!["fresh".into()], true);
2889 assert!(
2890 scc_core::estimate_tokens(&content) <= 300,
2891 "hard cap violated: {}",
2892 scc_core::estimate_tokens(&content)
2893 );
2894 assert!(outcome.hard_truncated, "hard path must engage");
2895 assert!(!outcome.dropped_sections.is_empty(), "drops must be recorded");
2896 assert!(!outcome.exceeded_soft_budget, "fit must hold");
2897 }
2898
2899 #[test]
2900 fn render_single_priority_ten_section_fits() {
2902 let sections = vec![Section::new("IDENTITY", "datum\n".repeat(5000), 10)];
2904 let (content, outcome) = render(sections, 200, Vec::new(), true);
2905 assert!(
2906 scc_core::estimate_tokens(&content) <= 200,
2907 "hard cap violated: {}",
2908 scc_core::estimate_tokens(&content)
2909 );
2910 assert!(
2911 outcome.dropped_sections.iter().any(|d| d == "truncated:IDENTITY"),
2912 "{:?}",
2913 outcome.dropped_sections
2914 );
2915 }
2916}