Skip to main content

scc_context/
packs.rs

1//! Context pack builders (docs/CONTEXT_COMPILER.md §8, §9).
2//!
3//! Section priority contract: invariants (10), ownership (10), directly
4//! affected contracts (9), known failure/retry behavior (9), stale warnings
5//! (always appended) may never be cut for budget. Lower-priority sections
6//! are dropped before truncation.
7
8use 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)]
20// trace:exempt reason=internal-detail
21pub(crate) struct Section {
22    pub(crate) title: String,
23    pub(crate) body: String,
24    /// 10 = never cut, 9 = never cut, 5 = lowest
25    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/// What the renderer actually did to fit the budget — honest accounting
39/// (P0): the pack is never silently cut. Dropped and line-truncated
40/// sections are recorded; the delivered text ALWAYS fits the budget
41/// (`estimated_tokens <= budget`, guaranteed by the hard phase below).
42/// `exceeded_soft_budget` survives only as a safety signal and is false
43/// on every successful render.
44#[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
52// trace:exempt reason=internal-detail
53fn 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(&sections)),
62        ..Default::default()
63    };
64    // Warnings are reserved up front: sections fit whatever the warnings
65    // leave. A warnings block that alone exceeds the budget collapses to
66    // a one-line receipt (the count is never silent).
67    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        // Soft legacy path (`--unbounded` human output only — never agents):
80        // drop low-priority sections, then report the excess honestly.
81        let mut content = assemble(&sections);
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(&sections);
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    // Pathological floor: below ~16 tokens even a section title cannot
109    // fit. Deliver the honest receipt instead of an unbounded artifact.
110    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    // Phase 1 (soft): drop lowest-priority sections while over budget.
119    // Sections with priority >= 9 (invariants, ownership, contracts,
120    // failure behavior) survive this phase.
121    let mut content = assemble(&sections);
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; // soft phase cannot drop anything else
127        }
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(&sections);
137        tokens = estimate_tokens(&content);
138        if sections.is_empty() {
139            break;
140        }
141    }
142    // Phase 2 (hard invariant): the delivered pack ALWAYS fits its
143    // budget. Lowest-priority multi-line bodies halve first (line
144    // granularity, explicit marker), then whole sections drop
145    // (recorded) except the highest-priority one, then that last body
146    // truncates to fit exactly. Deterministic: priority, then order.
147    if tokens > content_budget && !sections.is_empty() {
148        outcome.hard_truncated = true;
149        loop {
150            let cur = estimate_tokens(&assemble(&sections));
151            if cur <= content_budget {
152                break;
153            }
154            let mut cand: Option<usize> = None;
155            for (i, s) in sections.iter().enumerate() {
156                // A body already carrying the marker halves to itself —
157                // never pick it twice (termination: each section halves
158                // at most once; the drop phase finishes the job).
159                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(&sections)) > 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(&sections)) > content_budget {
191            // Single section left, still over: binary-search the largest
192            // fitting line prefix (marker included in the probe, so the
193            // result fits by measurement, not by hope).
194            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") + &marker;
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") + &marker;
213            sections[0].body = body;
214            content = assemble(&sections);
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                // Even title + marker exceeds (absurd budget): receipt.
221                // Return directly — the reassemble below must not clobber it.
222                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(&sections);
230    content.push_str(&warn_block);
231    outcome.exceeded_soft_budget = estimate_tokens(&content) > budget;
232    (content, outcome)
233}
234// trace:v1 id=impl.scc.packs work=WORK-SCC-001 satisfies=REQ-SCC-CTX
235
236/// Render a pack and record honest budget accounting on it.
237pub(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
246/// Hard-cap render: the delivered text always fits the budget.
247// trace:exempt reason=internal-detail
248pub(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
266/// Soft legacy render for human `--unbounded` output: may exceed the
267/// budget, reported via `exceeded_soft_budget`, never silently cut.
268/// Agent/MCP paths must never call this.
269// trace:exempt reason=internal-detail
270pub(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
288/// Inspectable fixed-quota packer. Production [`finish`] stays adaptive.
289/// Truncated buckets are named in `dropped_sections` as `quota:<bucket>`.
290// trace:v1 id=impl.scc.context.finish-rollover work=WORK-ripwire-lessons-phase3 satisfies=REQ-budget-rollover
291pub(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(&sections));
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
337// trace:exempt reason=internal-detail
338fn 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
367// trace:v1 id=impl.scc.packs.component-short work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-NX53P4B7
368fn 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    // Merged multi-root components render as 400-char '+' chains; cap the
372    // display (the entity id underneath is untouched).
373    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
382// trace:exempt reason=internal-detail
383fn 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
393// ---------------------------------------------------------------------------
394// system_overview / startup capsule
395// ---------------------------------------------------------------------------
396
397pub 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    // IDENTITY
406    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    // COMPONENTS
452    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    // BOUNDARIES / deployment units + externals
467    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    // STORES
503    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    // FLOWS
531    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    // INVARIANTS
555    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    // EVIDENCE STATUS
570    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
603// ---------------------------------------------------------------------------
604// task_context
605// ---------------------------------------------------------------------------
606
607pub 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
617/// `task` with optional semantic scorer + reranker (SCC-071).
618// trace:exempt reason=internal-detail
619pub 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    // ---- candidate generation ----
650    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    // symbol -> file -> component
656    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    // Every component that CONTAINS a file: merged clusters *and* member
663    // regions. Last-write-wins hid `root`/`services` after type narrowing
664    // merged them into `root+services`.
665    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    // affected components = candidates' components + file args' components
684    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    // flows mentioning affected components
717    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        // goal terms in flow name/trigger
728        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        // concrete behavior flows beat the system-wide architecture view
732        // when choosing a primary flow
733        if f.kind != scc_core::FlowKind::Architecture {
734            score += 0.5;
735        }
736        // signals-only state machines disclaim their own authority at
737        // render time — never lead with one when anything else qualifies
738        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    // upstream / downstream
753    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    // contracts: routes handled by symbols in affected comps
765    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    // ownership: stores owned by affected comps
778    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    // invariants scoped to affected entities
786    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    // tests_to_run: each test carries why it was selected
803    let tests = collect_tests_to_run(ctx, &affected_syms, &affected_comps, &owned_stores);
804
805    // retries/failures in affected components
806    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    // ---- sections ----
820    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    // SYSTEM ROLE
842    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    // RELEVANT COMPONENTS
876    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    // PRIMARY FLOW
904    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    // SECONDARY FLOWS
910    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    // UPSTREAM / DOWNSTREAM
925    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    // DATA OWNERSHIP
959    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        // Each WRITES rel contributes one entry, so the same component
983        // repeats per edge — dedupe across rels and cap (the owner list is
984        // a pointer, not an audit log).
985        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    // CONTRACTS
1003    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    // INVARIANTS
1014    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    // FAILURE / RETRY / FALLBACK
1034    if !retries.is_empty() {
1035        sections.push(Section::new(
1036            "FAILURE / RETRY",
1037            format!("{}\n", retries.join("\n")),
1038            9,
1039        ));
1040    }
1041
1042    // IMPLEMENTATION
1043    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    // TESTS TO RUN (with file locations and reasons)
1081    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    // RECENT CHANGES
1090    if !files.is_empty() {
1091        sections.push(Section::new(
1092            "RECENT CHANGES",
1093            format!("{}\n", files.join("\n")),
1094            6,
1095        ));
1096    }
1097
1098    // EVIDENCE STATUS
1099    let mut ids: Vec<String> = Vec::new();
1100    ids.extend(entity_ids.clone());
1101    ids.extend(affected_comps.iter().cloned());
1102    // expansion for recall: files containing CANDIDATE symbols (not every
1103    // symbol in an affected component — that over-broadens) + downstream
1104    // components (docs/CONTEXT_COMPILER.md §6)
1105    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    // the files containing included tests (agents must find them)
1117    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    // Compact fetch keys (handles) survive after bodies are dropped.
1151    // Not a fifth context level — lazy Level-3 identity, not extra ontology.
1152    let fetch = fetch_handle_list(ctx, files, &candidates);
1153    if !fetch.is_empty() {
1154        sections.push(Section::new("FETCH", fetch, 8));
1155    }
1156
1157    // Exact source last: bodies fill leftover budget and are the first
1158    // section dropped. Never a fifth context level — this is Level 3
1159    // inside Task Context, after semantic sections.
1160    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
1193/// Compact `path handle=scc://...` lines so agents can lazy-fetch exact
1194/// source and refuse stale hashes. Survives after EXACT SOURCE bodies drop.
1195// trace:v1 id=impl.scc.context.fetch-handles work=WORK-phase-13-of-scc-x-ripwire-lessons-1-go-extract-time-receiver-field-as satisfies=REQ-implement-phase-13-of-scc-x-ripwire-lessons-1-go-extract-time-recei implements=PLAN-phase-13-of-scc-x-ripwire-lessons-1-go-extract-time-receiver-field-as
1196fn 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
1210// trace:exempt reason=internal-detail
1211fn 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
1249/// Level-3 exact excerpts for Task Context. Truncation is disclosed
1250/// (`shown=`/`total=`/`capped=`). Priority 1 so semantic sections win.
1251// trace:v1 id=impl.scc.context.exact-source-last work=WORK-ripwire-lessons-phase1 satisfies=REQ-exact-source-dominance,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no
1252fn 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
1285/// RTK output-compression policy derived from the task (docs §49/§11):
1286/// task touches tests -> preserve failures; performance investigation ->
1287/// disable log compression; otherwise standard policy.
1288fn 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
1318// ---------------------------------------------------------------------------
1319// component_context
1320// ---------------------------------------------------------------------------
1321
1322// trace:exempt reason=internal-detail
1323pub 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    // flows this component participates in
1459    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    // tests
1474    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
1504// ---------------------------------------------------------------------------
1505// flow_context
1506// ---------------------------------------------------------------------------
1507
1508// trace:exempt reason=internal-detail
1509fn 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    // Wave 3 §22: lifecycle views detect state-machine signals; they are
1519    // never presented as authoritative state-machine ordering.
1520    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        // keep it tight: strip per-step provenance
1546        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
1565// trace:exempt reason=internal-detail
1566pub 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
1601// ---------------------------------------------------------------------------
1602// impact_context
1603// ---------------------------------------------------------------------------
1604
1605// trace:v1 id=impl.scc.context.impact-forgotten work=WORK-ripwire-lessons-phase6 satisfies=REQ-forgotten-impact-partners
1606pub 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
1824// ---------------------------------------------------------------------------
1825// verify_context
1826// ---------------------------------------------------------------------------
1827
1828// trace:exempt reason=internal-detail
1829pub 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    // freshness
1848    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    // scan inventory (counts recorded at index time; never paths)
1862    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    // stale facts
1882    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    // graph invariants
1898    let mut inv = String::new();
1899    // 1. dangling references
1900    let mut dangling = 0usize;
1901    for r in ctx.view.all_rels() {
1902        // endpoints may be external_api, component, flow ids — check known
1903        // namespaces
1904        let known = |id: &str| -> bool {
1905            ctx.view.entity(id).is_some()
1906                || id.contains("/external_api/")
1907                || id.contains("/component/")
1908                || id.contains("/flow/")
1909                || id.contains("/invariant/")
1910        };
1911        if !known(&r.subject) {
1912            dangling += 1;
1913            if dangling <= 5 {
1914                inv.push_str(&format!(
1915                    "dangling subject: {} — {}\n",
1916                    r.subject, r.predicate
1917                ));
1918            }
1919        }
1920        if !known(&r.object) {
1921            dangling += 1;
1922            if dangling <= 5 {
1923                inv.push_str(&format!(
1924                    "dangling object: {} — {}\n",
1925                    r.predicate, r.object
1926                ));
1927            }
1928        }
1929    }
1930    inv.push_str(&format!("Dangling references: {dangling}\n"));
1931    // 2. RESOLVED facts must have evidence
1932    let mut no_evidence = 0usize;
1933    for r in ctx.view.all_rels() {
1934        if r.provenance == Provenance::Resolved && r.evidence.is_empty() {
1935            no_evidence += 1;
1936        }
1937    }
1938    inv.push_str(&format!("RESOLVED facts without evidence: {no_evidence}\n"));
1939    // 3. critical invariants unenforced
1940    let unenforced = ctx
1941        .view
1942        .invariants()
1943        .iter()
1944        .filter(|i| i.severity == Severity::Critical && i.enforced_by.is_empty())
1945        .count();
1946    inv.push_str(&format!(
1947        "Critical invariants without enforcing tests: {unenforced}\n"
1948    ));
1949    // 4. inferred claims
1950    let inferred = ctx
1951        .view
1952        .all_rels()
1953        .iter()
1954        .filter(|r| r.provenance == Provenance::Inferred)
1955        .count();
1956    inv.push_str(&format!("Inferred claims (labeled): {inferred}\n"));
1957    sections.push(Section::new("GRAPH INVARIANTS", inv, 10));
1958
1959    // drift findings
1960    let findings = ctx.store.drift_findings(true).unwrap_or_default();
1961    let mut drift = String::new();
1962    if findings.is_empty() {
1963        drift.push_str("No drift findings.\n");
1964    }
1965    for (_, kind, sev, msg, _) in &findings {
1966        drift.push_str(&format!("- [{sev}] {kind}: {msg}\n"));
1967    }
1968    sections.push(Section::new("DRIFT", drift, 10));
1969
1970    // conflicts: conflicting writers recorded as drift; also low-confidence deps
1971    let mut low_conf = String::new();
1972    let mut lc = 0usize;
1973    for r in ctx.view.all_rels() {
1974        if r.predicate == scc_core::predicates::DEPENDS_ON && r.confidence < 0.8 {
1975            lc += 1;
1976            if lc <= 8 {
1977                low_conf.push_str(&format!(
1978                    "- {} → {} ({:.2})\n",
1979                    component_short(&ctx.view, &r.subject),
1980                    component_short(&ctx.view, &r.object),
1981                    r.confidence
1982                ));
1983            }
1984        }
1985    }
1986    if lc > 0 {
1987        low_conf.push_str(&format!("… {lc} low-confidence dependency edge(s)\n"));
1988        sections.push(Section::new("LOW-CONFIDENCE DEPENDENCIES", low_conf, 8));
1989    }
1990
1991    // trust boundaries (docs/PRD.md §7, EPIC-148)
1992    if let Ok(crossings) = scc_graph::boundaries::boundary_crossings(ctx.view.graph, ctx.store) {
1993        if !crossings.is_empty() {
1994            let mut b = String::new();
1995            b.push_str(&format!("{} boundary crossing(s):\n", crossings.len()));
1996            for c in crossings.iter().take(12) {
1997                b.push_str(&format!("- {c}\n"));
1998            }
1999            sections.push(Section::new("BOUNDARIES", b, 8));
2000        }
2001    }
2002
2003    // runtime observations (docs/FLOW_COMPILER.md §8)
2004    let runtime_edges = ctx.store.runtime_edge_rows().unwrap_or_default();
2005    if !runtime_edges.is_empty() {
2006        let mut rt = String::new();
2007        let total: u64 = runtime_edges.iter().map(|e| e.count).sum();
2008        let errs: u64 = runtime_edges.iter().map(|e| e.errors).sum();
2009        rt.push_str(&format!(
2010            "{} observed edge(s), {} total observations, {} error(s).\n",
2011            runtime_edges.len(),
2012            total,
2013            errs
2014        ));
2015        for e in runtime_edges.iter().take(12) {
2016            rt.push_str(&format!(
2017                "- {} → {} ×{} (avg {:.1} ms, {} err)\n",
2018                e.source, e.target, e.count, e.latency_ms, e.errors
2019            ));
2020        }
2021        sections.push(Section::new("RUNTIME", rt, 8));
2022    }
2023
2024    // verdict
2025    let mut verdict = String::new();
2026    let ok = ctx.stale_paths.is_empty()
2027        && dangling == 0
2028        && no_evidence == 0
2029        && unenforced == 0
2030        && findings.is_empty();
2031    if ok {
2032        verdict.push_str("VERIFIED: model is fresh, consistent, and drift-free.\n");
2033    } else {
2034        verdict.push_str("ISSUES FOUND: review the sections above before trusting context.\n");
2035        if !ctx.stale_paths.is_empty() {
2036            verdict.push_str("  → re-index changed files (`scc index`)\n");
2037        }
2038        if unenforced > 0 {
2039            verdict.push_str("  → critical invariants need enforcing tests or declaration\n");
2040        }
2041        if dangling > 0 || no_evidence > 0 {
2042            verdict.push_str("  → graph integrity violated; re-index\n");
2043        }
2044    }
2045    sections.push(Section::new("VERDICT", verdict, 10));
2046
2047    if full {
2048        finish_soft(&mut pack, sections, budget, Vec::new());
2049    } else {
2050        finish(&mut pack, sections, budget, Vec::new());
2051    }
2052    pack
2053}
2054
2055// trace:exempt reason=internal-detail
2056struct LocusHits {
2057    files: Vec<String>,
2058    symbols: Vec<String>,
2059    body: String,
2060}
2061
2062/// Map stack/error FILE:LINE frames onto indexed files and innermost symbols.
2063// trace:v1 id=impl.scc.context.stack-locus work=WORK-ripwire-lessons-phase3 satisfies=REQ-stack-locus-ingest
2064fn resolve_goal_loci(ctx: &crate::ContextCompiler, goal: &str) -> Option<LocusHits> {
2065    let plan = route_query(goal);
2066    if !plan.prefer_locus || plan.loci.is_empty() {
2067        return None;
2068    }
2069    let mut files: Vec<String> = Vec::new();
2070    let mut symbols: Vec<String> = Vec::new();
2071    let mut body = String::new();
2072    for loc in &plan.loci {
2073        let mapped_file = ctx
2074            .view
2075            .entities_of_kind(kinds::FILE)
2076            .into_iter()
2077            .map(|e| e.name.clone())
2078            .find(|p| path_matches_locus(p, &loc.path));
2079        let mapped_file = mapped_file.or_else(|| {
2080            ctx.view
2081                .entities_of_kind(kinds::SYMBOL)
2082                .into_iter()
2083                .find_map(|e| {
2084                    e.attributes
2085                        .get("file")
2086                        .and_then(|v| v.as_str())
2087                        .filter(|p| path_matches_locus(p, &loc.path))
2088                        .map(|p| p.to_string())
2089                })
2090        });
2091        match mapped_file {
2092            None => {
2093                body.push_str(&format!(
2094                    "- {}:{} — unmapped (not fabricated)\n",
2095                    loc.path, loc.line
2096                ));
2097            }
2098            Some(path) => {
2099                if !files.iter().any(|f| f == &path) {
2100                    files.push(path.clone());
2101                }
2102                let enclosed = innermost_enclosing_symbol(ctx, &path, loc.line);
2103                if let Some(name) = &enclosed {
2104                    if !symbols.iter().any(|s| s == name) {
2105                        symbols.push(name.clone());
2106                    }
2107                }
2108                match enclosed {
2109                    Some(name) => {
2110                        body.push_str(&format!("- {path}:{} → symbol {name}\n", loc.line))
2111                    }
2112                    None => body.push_str(&format!(
2113                        "- {path}:{} → file (no enclosing symbol)\n",
2114                        loc.line
2115                    )),
2116                }
2117            }
2118        }
2119    }
2120    Some(LocusHits {
2121        files,
2122        symbols,
2123        body,
2124    })
2125}
2126
2127// trace:exempt reason=internal-detail
2128fn innermost_enclosing_symbol(
2129    ctx: &crate::ContextCompiler,
2130    file: &str,
2131    line: u32,
2132) -> Option<String> {
2133    let mut best: Option<(u32, String)> = None;
2134    for e in ctx.view.entities_of_kind(kinds::SYMBOL) {
2135        let Some(f) = e.attributes.get("file").and_then(|v| v.as_str()) else {
2136            continue;
2137        };
2138        if !path_matches_locus(f, file) && f != file {
2139            continue;
2140        }
2141        let start = e
2142            .attributes
2143            .get("start_line")
2144            .and_then(|v| v.as_u64())
2145            .unwrap_or(0) as u32;
2146        let end = e
2147            .attributes
2148            .get("end_line")
2149            .and_then(|v| v.as_u64())
2150            .unwrap_or(u64::from(start)) as u32;
2151        if start == 0 || line < start || line > end {
2152            continue;
2153        }
2154        let span = end.saturating_sub(start);
2155        if best.as_ref().map(|(s, _)| span < *s).unwrap_or(true) {
2156            best = Some((span, e.name.clone()));
2157        }
2158    }
2159    best.map(|(_, n)| n)
2160}
2161
2162/// tests_to_run: test id → reasons (`direct`, `import`, `contract`, `state`).
2163// trace:v1 id=impl.scc.context.tests-to-run work=WORK-ripwire-lessons-phase3 satisfies=REQ-tests-to-run-reasons,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no
2164fn collect_tests_to_run(
2165    ctx: &crate::ContextCompiler,
2166    affected_syms: &HashSet<&String>,
2167    affected_comps: &BTreeSet<String>,
2168    owned_stores: &BTreeSet<String>,
2169) -> BTreeMap<String, BTreeSet<&'static str>> {
2170    let mut tests: BTreeMap<String, BTreeSet<&'static str>> = BTreeMap::new();
2171    for sid in affected_syms {
2172        for r in ctx.view.out_pred(sid, scc_core::predicates::TESTED_BY) {
2173            tests.entry(r.object.clone()).or_default().insert("direct");
2174        }
2175        for pred in [
2176            scc_core::predicates::HANDLES,
2177            scc_core::predicates::IMPLEMENTS,
2178        ] {
2179            for r in ctx.view.out_pred(sid, pred) {
2180                let Some(target) = ctx.view.entity(&r.object) else {
2181                    continue;
2182                };
2183                if target.kind != kinds::CONTRACT && target.kind != kinds::ROUTE {
2184                    continue;
2185                }
2186                for t in ctx
2187                    .view
2188                    .out_pred(&r.object, scc_core::predicates::TESTED_BY)
2189                {
2190                    tests
2191                        .entry(t.object.clone())
2192                        .or_default()
2193                        .insert("contract");
2194                }
2195            }
2196        }
2197    }
2198    let affected_files: BTreeSet<String> = affected_syms
2199        .iter()
2200        .filter_map(|sid| {
2201            ctx.view
2202                .entity(sid.as_str())
2203                .and_then(|e| e.attributes.get("file"))
2204                .and_then(|v| v.as_str())
2205                .map(|f| f.to_string())
2206        })
2207        .collect();
2208    if !affected_files.is_empty() {
2209        for (id, _name, file, _kind, _sym) in ctx.store.tests().unwrap_or_default() {
2210            let imports = ctx.store.imports_in_file(&file).unwrap_or_default();
2211            let hits_affected = imports.iter().any(|(module, _names, _line, _typ)| {
2212                let target = resolve_module_ref(&file, module);
2213                affected_files.iter().any(|f| {
2214                    *f == target
2215                        || f.starts_with(&format!("{target}."))
2216                        || *f == format!("{target}/__init__.py")
2217                })
2218            });
2219            if hits_affected {
2220                tests.entry(id).or_default().insert("import");
2221            }
2222        }
2223    }
2224    let mut state_ids: BTreeSet<String> = BTreeSet::new();
2225    for sid in owned_stores {
2226        if is_state_like(ctx.view.entity(sid).map(|e| e.kind.as_str())) {
2227            state_ids.insert(sid.clone());
2228        }
2229    }
2230    for cid in affected_comps {
2231        for r in ctx.view.out_pred(cid, scc_core::predicates::OWNS) {
2232            if is_state_like(ctx.view.entity(&r.object).map(|e| e.kind.as_str())) {
2233                state_ids.insert(r.object.clone());
2234            }
2235        }
2236    }
2237    if !state_ids.is_empty() {
2238        for (id, _name, _file, _kind, _sym) in ctx.store.tests().unwrap_or_default() {
2239            let reads = ctx
2240                .view
2241                .out_pred(&id, scc_core::predicates::READS)
2242                .iter()
2243                .any(|r| state_ids.contains(&r.object));
2244            let writes = ctx
2245                .view
2246                .out_pred(&id, scc_core::predicates::WRITES)
2247                .iter()
2248                .any(|r| state_ids.contains(&r.object));
2249            if reads || writes {
2250                tests.entry(id).or_default().insert("state");
2251            }
2252        }
2253    }
2254    tests
2255}
2256
2257// trace:exempt reason=internal-detail
2258fn is_state_like(kind: Option<&str>) -> bool {
2259    matches!(
2260        kind,
2261        Some(kinds::STATE)
2262            | Some(kinds::DATA_STORE)
2263            | Some(kinds::DATA_ENTITY)
2264            | Some(kinds::TABLE)
2265            | Some(kinds::COLLECTION)
2266            | Some(kinds::CACHE)
2267    )
2268}
2269
2270// trace:exempt reason=internal-detail
2271fn format_test_to_run(
2272    ctx: &crate::ContextCompiler,
2273    tid: &str,
2274    reasons: &BTreeSet<&'static str>,
2275) -> String {
2276    let name = entity_name(&ctx.view, tid);
2277    let file = ctx
2278        .view
2279        .entity(tid)
2280        .and_then(|e| e.attributes.get("file"))
2281        .and_then(|v| v.as_str())
2282        .unwrap_or("");
2283    let why = reasons.iter().copied().collect::<Vec<_>>().join(", ");
2284    if file.is_empty() {
2285        format!("- {name} — {why}\n")
2286    } else {
2287        format!("- {name} ({file}) — {why}\n")
2288    }
2289}
2290
2291/// Resolve a module specifier (relative or dotted) to a repo-relative path
2292/// prefix, mirroring the indexer's import normalization.
2293fn resolve_module_ref(from_file: &str, module: &str) -> String {
2294    if !module.starts_with('.') {
2295        return module.replace('.', "/");
2296    }
2297    let dir = from_file.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
2298    let joined = if dir.is_empty() {
2299        module.to_string()
2300    } else {
2301        format!("{dir}/{module}")
2302    };
2303    let mut parts: Vec<&str> = Vec::new();
2304    for seg in joined.split('/') {
2305        match seg {
2306            "" | "." => continue,
2307            ".." => {
2308                parts.pop();
2309            }
2310            s => parts.push(s),
2311        }
2312    }
2313    parts.join("/")
2314}
2315
2316fn flow_kind_str(k: scc_core::FlowKind) -> &'static str {
2317    match k {
2318        scc_core::FlowKind::Architecture => "architecture",
2319        scc_core::FlowKind::Workflow => "workflow",
2320        scc_core::FlowKind::Sequence => "sequence",
2321        scc_core::FlowKind::Dataflow => "dataflow",
2322        scc_core::FlowKind::Lifecycle => "lifecycle",
2323    }
2324}
2325
2326fn severity_str(s: Severity) -> &'static str {
2327    match s {
2328        Severity::Info => "info",
2329        Severity::Low => "low",
2330        Severity::Medium => "medium",
2331        Severity::High => "high",
2332        Severity::Critical => "critical",
2333    }
2334}
2335
2336#[cfg(test)]
2337mod tests {
2338    use super::*;
2339    use scc_core::{Entity, Provenance, Relationship};
2340    use scc_store::Store;
2341
2342    #[test]
2343    // trace:v1 id=test.scc.context.task-files-sandbox verifies=REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no exercises=impl.scc.context.repo-sandbox,impl.scc.context.exact-source-last
2344    fn task_context_files_stay_inside_the_repo() {
2345        let dir = tempfile::TempDir::new().unwrap();
2346        let root = dir.path().join("repo");
2347        std::fs::create_dir_all(&root).unwrap();
2348        std::fs::write(root.join("ok.py"), "IN_REPO = 1\n").unwrap();
2349        std::fs::write(dir.path().join("secret.py"), "OUTSIDE_SECRET = 1\n").unwrap();
2350        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2351        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2352        let ctx = crate::ContextCompiler::new(
2353            &store,
2354            &graph,
2355            crate::ContextSettings::default(),
2356            Vec::new(),
2357        );
2358        let body = exact_source_tail(
2359            &ctx,
2360            &[
2361                "ok.py".into(),
2362                "../secret.py".into(),
2363                "/etc/passwd".into(),
2364            ],
2365            &[],
2366        );
2367        assert!(body.contains("IN_REPO"), "{body}");
2368        assert!(!body.contains("OUTSIDE_SECRET"), "{body}");
2369        assert!(!body.contains("/etc/passwd"), "{body}");
2370        assert!(!body.contains("secret.py"), "{body}");
2371    }
2372
2373    #[test]
2374    // trace:v1 id=test.scc.context.tests-to-run verifies=REQ-tests-to-run-reasons exercises=impl.scc.context.tests-to-run
2375    fn tests_to_run_records_direct_reason() {
2376        let dir = tempfile::TempDir::new().unwrap();
2377        let root = dir.path().join("repo");
2378        std::fs::create_dir_all(&root).unwrap();
2379        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2380        let sid = "s:fn".to_string();
2381        let tid = "t:test_fn".to_string();
2382        let mut se = Entity::new(&sid, kinds::SYMBOL, "handleList");
2383        se.attr("file", serde_json::json!("src/a.py"));
2384        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2385        let mut te = Entity::new(&tid, kinds::TEST, "test_handle_list");
2386        te.attr("file", serde_json::json!("tests/test_a.py"));
2387        store
2388            .insert_entity(&te, &["tests/test_a.py".into()])
2389            .unwrap();
2390        let rel = Relationship::new(
2391            "rel:tb",
2392            sid.clone(),
2393            scc_core::predicates::TESTED_BY,
2394            tid.clone(),
2395            Provenance::Extracted,
2396        );
2397        store.insert_relationship(&rel, "src/a.py").unwrap();
2398        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2399        let ctx = crate::ContextCompiler::new(
2400            &store,
2401            &graph,
2402            crate::ContextSettings::default(),
2403            Vec::new(),
2404        );
2405        let affected: HashSet<&String> = [&sid].into_iter().collect();
2406        let found = collect_tests_to_run(&ctx, &affected, &BTreeSet::new(), &BTreeSet::new());
2407        let reasons = found
2408            .get(&tid)
2409            .expect("direct TESTED_BY must list the test");
2410        assert!(reasons.contains("direct"));
2411        let line = format_test_to_run(&ctx, &tid, reasons);
2412        assert!(line.contains("direct"), "{line}");
2413        assert!(line.contains("test_handle_list"), "{line}");
2414        assert!(line.contains("tests/test_a.py"), "{line}");
2415    }
2416
2417    #[test]
2418    // trace:v1 id=test.scc.context.tests-to-run-import verifies=REQ-tests-to-run-reasons exercises=impl.scc.context.tests-to-run
2419    fn tests_to_run_records_import_reason() {
2420        let dir = tempfile::TempDir::new().unwrap();
2421        let root = dir.path().join("repo");
2422        std::fs::create_dir_all(&root).unwrap();
2423        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2424        let sid = "s:fn".to_string();
2425        let tid = "t:import_test".to_string();
2426        let mut se = Entity::new(&sid, kinds::SYMBOL, "handleList");
2427        se.attr("file", serde_json::json!("src/a.py"));
2428        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2429        let mut te = Entity::new(&tid, kinds::TEST, "test_via_import");
2430        te.attr("file", serde_json::json!("tests/test_a.py"));
2431        store
2432            .insert_entity(&te, &["tests/test_a.py".into()])
2433            .unwrap();
2434        store
2435            .insert_test(&tid, "test_via_import", "tests/test_a.py", "unit", None)
2436            .unwrap();
2437        store
2438            .insert_imports(
2439                "tests/test_a.py",
2440                &[("src.a".into(), vec![], 1, "module".into())],
2441            )
2442            .unwrap();
2443        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2444        let ctx = crate::ContextCompiler::new(
2445            &store,
2446            &graph,
2447            crate::ContextSettings::default(),
2448            Vec::new(),
2449        );
2450        let affected: HashSet<&String> = [&sid].into_iter().collect();
2451        let found = collect_tests_to_run(&ctx, &affected, &BTreeSet::new(), &BTreeSet::new());
2452        let reasons = found
2453            .get(&tid)
2454            .expect("import of affected file must list the test");
2455        assert!(reasons.contains("import"));
2456        assert!(!reasons.contains("direct"));
2457    }
2458
2459    #[test]
2460    // trace:v1 id=test.scc.context.tests-to-run-contract verifies=REQ-tests-to-run-reasons exercises=impl.scc.context.tests-to-run
2461    fn tests_to_run_records_contract_reason() {
2462        let dir = tempfile::TempDir::new().unwrap();
2463        let root = dir.path().join("repo");
2464        std::fs::create_dir_all(&root).unwrap();
2465        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2466        let sid = "s:handler".to_string();
2467        let cid = "c:list".to_string();
2468        let tid = "t:contract".to_string();
2469        let mut se = Entity::new(&sid, kinds::SYMBOL, "handleList");
2470        se.attr("file", serde_json::json!("src/a.py"));
2471        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2472        store
2473            .insert_entity(
2474                &Entity::new(&cid, kinds::CONTRACT, "GET /list"),
2475                &["src/a.py".into()],
2476            )
2477            .unwrap();
2478        let mut te = Entity::new(&tid, kinds::TEST, "test_list_route");
2479        te.attr("file", serde_json::json!("tests/test_routes.py"));
2480        store
2481            .insert_entity(&te, &["tests/test_routes.py".into()])
2482            .unwrap();
2483        store
2484            .insert_relationship(
2485                &Relationship::new(
2486                    "rel:h",
2487                    sid.clone(),
2488                    scc_core::predicates::HANDLES,
2489                    cid.clone(),
2490                    Provenance::Extracted,
2491                ),
2492                "src/a.py",
2493            )
2494            .unwrap();
2495        store
2496            .insert_relationship(
2497                &Relationship::new(
2498                    "rel:ct",
2499                    cid.clone(),
2500                    scc_core::predicates::TESTED_BY,
2501                    tid.clone(),
2502                    Provenance::Extracted,
2503                ),
2504                "tests/test_routes.py",
2505            )
2506            .unwrap();
2507        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2508        let ctx = crate::ContextCompiler::new(
2509            &store,
2510            &graph,
2511            crate::ContextSettings::default(),
2512            Vec::new(),
2513        );
2514        let affected: HashSet<&String> = [&sid].into_iter().collect();
2515        let found = collect_tests_to_run(&ctx, &affected, &BTreeSet::new(), &BTreeSet::new());
2516        let reasons = found
2517            .get(&tid)
2518            .expect("contract TESTED_BY must list the test");
2519        assert!(reasons.contains("contract"));
2520        assert!(!reasons.contains("direct"));
2521    }
2522
2523    #[test]
2524    // trace:v1 id=test.scc.context.tests-to-run-state verifies=REQ-tests-to-run-reasons exercises=impl.scc.context.tests-to-run
2525    fn tests_to_run_records_state_reason() {
2526        let dir = tempfile::TempDir::new().unwrap();
2527        let root = dir.path().join("repo");
2528        std::fs::create_dir_all(&root).unwrap();
2529        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2530        let comp = "comp:orders".to_string();
2531        let st = "state:orders_db".to_string();
2532        let tid = "t:state".to_string();
2533        store
2534            .insert_entity(
2535                &Entity::new(&comp, kinds::COMPONENT, "orders"),
2536                &["src/a.py".into()],
2537            )
2538            .unwrap();
2539        store
2540            .insert_entity(
2541                &Entity::new(&st, kinds::STATE, "orders_db"),
2542                &["src/a.py".into()],
2543            )
2544            .unwrap();
2545        let mut te = Entity::new(&tid, kinds::TEST, "test_orders_state");
2546        te.attr("file", serde_json::json!("tests/test_state.py"));
2547        store
2548            .insert_entity(&te, &["tests/test_state.py".into()])
2549            .unwrap();
2550        store
2551            .insert_test(
2552                &tid,
2553                "test_orders_state",
2554                "tests/test_state.py",
2555                "unit",
2556                None,
2557            )
2558            .unwrap();
2559        store
2560            .insert_relationship(
2561                &Relationship::new(
2562                    "rel:owns",
2563                    comp.clone(),
2564                    scc_core::predicates::OWNS,
2565                    st.clone(),
2566                    Provenance::Extracted,
2567                ),
2568                "src/a.py",
2569            )
2570            .unwrap();
2571        store
2572            .insert_relationship(
2573                &Relationship::new(
2574                    "rel:rd",
2575                    tid.clone(),
2576                    scc_core::predicates::READS,
2577                    st.clone(),
2578                    Provenance::Extracted,
2579                ),
2580                "tests/test_state.py",
2581            )
2582            .unwrap();
2583        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2584        let ctx = crate::ContextCompiler::new(
2585            &store,
2586            &graph,
2587            crate::ContextSettings::default(),
2588            Vec::new(),
2589        );
2590        let comps: BTreeSet<String> = [comp].into_iter().collect();
2591        let found = collect_tests_to_run(&ctx, &HashSet::new(), &comps, &BTreeSet::new());
2592        let reasons = found.get(&tid).expect("state READS must list the test");
2593        assert!(reasons.contains("state"));
2594    }
2595
2596    #[test]
2597    // trace:v1 id=test.scc.context.tests-to-run-store verifies=REQ-tests-to-run-reasons,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no exercises=impl.scc.context.tests-to-run
2598    fn tests_to_run_records_state_reason_for_data_store() {
2599        let dir = tempfile::TempDir::new().unwrap();
2600        let root = dir.path().join("repo");
2601        std::fs::create_dir_all(&root).unwrap();
2602        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2603        let comp = "comp:orders".to_string();
2604        let st = "store:orders_db".to_string();
2605        let tid = "t:store".to_string();
2606        store
2607            .insert_entity(
2608                &Entity::new(&comp, kinds::COMPONENT, "orders"),
2609                &["src/a.py".into()],
2610            )
2611            .unwrap();
2612        store
2613            .insert_entity(
2614                &Entity::new(&st, kinds::DATA_STORE, "orders_db"),
2615                &["src/a.py".into()],
2616            )
2617            .unwrap();
2618        let mut te = Entity::new(&tid, kinds::TEST, "test_orders_store");
2619        te.attr("file", serde_json::json!("tests/test_store.py"));
2620        store
2621            .insert_entity(&te, &["tests/test_store.py".into()])
2622            .unwrap();
2623        store
2624            .insert_test(
2625                &tid,
2626                "test_orders_store",
2627                "tests/test_store.py",
2628                "unit",
2629                None,
2630            )
2631            .unwrap();
2632        store
2633            .insert_relationship(
2634                &Relationship::new(
2635                    "rel:owns-store",
2636                    comp.clone(),
2637                    scc_core::predicates::OWNS,
2638                    st.clone(),
2639                    Provenance::Extracted,
2640                ),
2641                "src/a.py",
2642            )
2643            .unwrap();
2644        store
2645            .insert_relationship(
2646                &Relationship::new(
2647                    "rel:rd-store",
2648                    tid.clone(),
2649                    scc_core::predicates::READS,
2650                    st.clone(),
2651                    Provenance::Extracted,
2652                ),
2653                "tests/test_store.py",
2654            )
2655            .unwrap();
2656        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2657        let ctx = crate::ContextCompiler::new(
2658            &store,
2659            &graph,
2660            crate::ContextSettings::default(),
2661            Vec::new(),
2662        );
2663        let comps: BTreeSet<String> = [comp].into_iter().collect();
2664        let found = collect_tests_to_run(&ctx, &HashSet::new(), &comps, &BTreeSet::new());
2665        let reasons = found.get(&tid).expect("store READS must list the test");
2666        assert!(reasons.contains("state"));
2667    }
2668
2669    #[test]
2670    // trace:v1 id=test.scc.context.stack-locus verifies=REQ-stack-locus-ingest exercises=impl.scc.context.stack-locus
2671    fn stack_locus_seeds_file_and_innermost_symbol() {
2672        let dir = tempfile::TempDir::new().unwrap();
2673        let root = dir.path().join("repo");
2674        std::fs::create_dir_all(root.join("src")).unwrap();
2675        std::fs::write(root.join("src/app.py"), "def inner():\n    x = 1\n").unwrap();
2676        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2677        let fe = Entity::new("f:app", kinds::FILE, "src/app.py");
2678        store.insert_entity(&fe, &["src/app.py".into()]).unwrap();
2679        let mut outer = Entity::new("s:outer", kinds::SYMBOL, "outer");
2680        outer.attr("file", serde_json::json!("src/app.py"));
2681        outer.attr("start_line", serde_json::json!(1u32));
2682        outer.attr("end_line", serde_json::json!(20u32));
2683        store.insert_entity(&outer, &["src/app.py".into()]).unwrap();
2684        let mut inner = Entity::new("s:inner", kinds::SYMBOL, "inner");
2685        inner.attr("file", serde_json::json!("src/app.py"));
2686        inner.attr("start_line", serde_json::json!(10u32));
2687        inner.attr("end_line", serde_json::json!(12u32));
2688        store.insert_entity(&inner, &["src/app.py".into()]).unwrap();
2689        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2690        let ctx = crate::ContextCompiler::new(
2691            &store,
2692            &graph,
2693            crate::ContextSettings::default(),
2694            Vec::new(),
2695        );
2696        let goal = "Traceback (most recent call last):\n  File \"src/app.py\", line 11, in inner\nValueError: boom\n";
2697        let hits = resolve_goal_loci(&ctx, goal).expect("locus");
2698        assert!(hits.files.iter().any(|f| f == "src/app.py"));
2699        assert_eq!(hits.symbols, vec!["inner".to_string()]);
2700        assert!(hits.body.contains("inner"));
2701        let miss = resolve_goal_loci(
2702            &ctx,
2703            "Traceback (most recent call last):\n  File \"src/missing.py\", line 1, in x\nValueError: x\n",
2704        )
2705        .unwrap();
2706        assert!(miss.body.contains("unmapped"));
2707        assert!(miss.files.is_empty());
2708    }
2709
2710    #[test]
2711    // trace:v1 id=test.scc.context.finish-rollover verifies=REQ-budget-rollover exercises=impl.scc.context.finish-rollover
2712    fn finish_with_rollover_discloses_truncated_quota() {
2713        let mut pack = ContextPack::new("task", "rev");
2714        let huge = "word ".repeat(4000);
2715        let sections = vec![
2716            Section::new("TASK", "goal\n".into(), 10),
2717            Section::new("IMPLEMENTATION", huge, 7),
2718        ];
2719        finish_with_rollover(&mut pack, sections, 200, Vec::new());
2720        assert!(
2721            pack.dropped_sections.iter().any(|d| d == "quota:source"),
2722            "{:?}",
2723            pack.dropped_sections
2724        );
2725        assert!(pack.truncated);
2726        assert!(pack.content.contains("truncated") || pack.hard_truncated);
2727    }
2728
2729    #[test]
2730    // trace:v1 id=test.scc.context.impact-forgotten verifies=REQ-forgotten-impact-partners exercises=impl.scc.context.impact-forgotten
2731    fn impact_pack_discloses_forgotten_partners_without_merging_them() {
2732        let dir = tempfile::TempDir::new().unwrap();
2733        let root = dir.path().join("repo");
2734        std::fs::create_dir_all(root.join("src")).unwrap();
2735        fn git(root: &std::path::Path, args: &[&str]) {
2736            let out = std::process::Command::new("git")
2737                .args(args)
2738                .current_dir(root)
2739                .output()
2740                .unwrap();
2741            assert!(
2742                out.status.success(),
2743                "{args:?} {}",
2744                String::from_utf8_lossy(&out.stderr)
2745            );
2746        }
2747        git(&root, &["init", "-q"]);
2748        git(&root, &["config", "user.email", "test@example.com"]);
2749        git(&root, &["config", "user.name", "SCC Test"]);
2750        git(&root, &["config", "commit.gpgsign", "false"]);
2751        for i in 0..2 {
2752            std::fs::write(root.join("src/a.py"), format!("a = {i}\n")).unwrap();
2753            std::fs::write(root.join("src/b.py"), format!("b = {i}\n")).unwrap();
2754            git(&root, &["add", "-A"]);
2755            git(&root, &["commit", "-q", "-m", &format!("c{i}")]);
2756        }
2757        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2758        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2759        let ctx = crate::ContextCompiler::new(
2760            &store,
2761            &graph,
2762            crate::ContextSettings::default(),
2763            Vec::new(),
2764        );
2765        let pack = impact(&ctx, &["src/a.py".into()], &[], None, 50_000, false);
2766        assert!(
2767            pack.content.contains("FORGOTTEN PARTNERS"),
2768            "missing section: {}",
2769            pack.content
2770        );
2771        assert!(pack.content.contains("src/b.py"), "{}", pack.content);
2772        assert!(pack.content.contains("cochange"), "{}", pack.content);
2773        assert!(
2774            !pack.content.contains("AFFECTED COMPONENTS\nsrc/b.py"),
2775            "partner must not become affected: {}",
2776            pack.content
2777        );
2778        assert!(pack.content.contains("historical; not EXTRACTED impact"));
2779    }
2780
2781    #[test]
2782    // trace:v1 id=test.scc.context.exact-source-last verifies=REQ-exact-source-dominance exercises=impl.scc.context.exact-source-last
2783    fn task_pack_puts_exact_source_last_and_drops_it_first() {
2784        let dir = tempfile::TempDir::new().unwrap();
2785        let root = dir.path().join("repo");
2786        std::fs::create_dir_all(root.join("src")).unwrap();
2787        let src = "def handle_list():\n    return [1, 2, 3]\n";
2788        std::fs::write(root.join("src/a.py"), src).unwrap();
2789        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2790        let mut se = Entity::new("s:fn", kinds::SYMBOL, "handle_list");
2791        se.attr("file", serde_json::json!("src/a.py"));
2792        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2793        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2794        let ctx = crate::ContextCompiler::new(
2795            &store,
2796            &graph,
2797            crate::ContextSettings::default(),
2798            Vec::new(),
2799        );
2800        let fat = task(&ctx, "handle list", &["src/a.py".into()], &[], 50_000);
2801        let idx_exact = fat
2802            .content
2803            .find("EXACT SOURCE")
2804            .expect("exact source must appear when budget allows");
2805        let idx_task = fat.content.find("TASK").expect("task header");
2806        assert!(
2807            idx_exact > idx_task,
2808            "exact source must follow semantic sections"
2809        );
2810        assert!(
2811            fat.content.contains("shown=2 total=2 capped=0"),
2812            "{}",
2813            fat.content
2814        );
2815        assert!(fat.content.contains("def handle_list()"), "{}", fat.content);
2816        assert!(
2817            fat.content.contains("FETCH") && fat.content.contains("handle=scc://"),
2818            "task pack must stamp fetch handles: {}",
2819            fat.content
2820        );
2821        let thin = task(&ctx, "handle list", &["src/a.py".into()], &[], 80);
2822        assert!(
2823            thin.dropped_sections.iter().any(|s| s == "EXACT SOURCE"),
2824            "exact source is priority 1 and must drop first: {:?}",
2825            thin.dropped_sections
2826        );
2827        assert!(
2828            !thin.content.contains("EXACT SOURCE"),
2829            "dropped exact source must not remain in content: {}",
2830            thin.content
2831        );
2832    }
2833
2834    #[test]
2835    // trace:v1 id=test.scc.context.fetch-handles verifies=REQ-implement-phase-13-of-scc-x-ripwire-lessons-1-go-extract-time-recei exercises=impl.scc.context.fetch-handles
2836    fn task_pack_stamps_fetch_handles_that_roundtrip() {
2837        let dir = tempfile::TempDir::new().unwrap();
2838        let root = dir.path().join("repo");
2839        std::fs::create_dir_all(root.join("src")).unwrap();
2840        let src = "def handle_list():\n    return [1, 2, 3]\n";
2841        std::fs::write(root.join("src/a.py"), src).unwrap();
2842        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2843        let mut se = Entity::new("s:fn", kinds::SYMBOL, "handle_list");
2844        se.attr("file", serde_json::json!("src/a.py"));
2845        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2846        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2847        let ctx = crate::ContextCompiler::new(
2848            &store,
2849            &graph,
2850            crate::ContextSettings::default(),
2851            Vec::new(),
2852        );
2853        let pack = task(&ctx, "handle list", &["src/a.py".into()], &[], 50_000);
2854        let handle = pack
2855            .content
2856            .lines()
2857            .find_map(|l| l.split("handle=").nth(1))
2858            .map(|s| s.split_whitespace().next().unwrap_or(s).to_string())
2859            .expect("handle in FETCH or EXACT SOURCE");
2860        assert!(handle.starts_with("scc://"), "{handle}");
2861        let resolved =
2862            crate::structural_source::resolve_handle_to_path(&root, &handle).expect("fresh handle");
2863        assert_eq!(resolved, "src/a.py");
2864        let stale = format!(
2865            "{}@aaaaaaaaaaaaaaaa",
2866            handle.rsplit_once('@').map(|(p, _)| p).unwrap_or(&handle)
2867        );
2868        let err = crate::structural_source::resolve_handle_to_path(&root, &stale).unwrap_err();
2869        assert!(err.contains("stale"), "{err}");
2870    }
2871
2872    #[test]
2873    // trace:v1 id=test.scc.context.render-hard-cap verifies=REQ-SCC-CTX exercises=impl.scc.packs
2874    fn render_hard_cap_always_fits() {
2875        // Even priority-9/10 material cannot escape the budget: the hard
2876        // phase truncates and drops (recorded) until the text fits.
2877        let sections = vec![
2878            Section::new("IDENTITY", "repo: x\n".into(), 10),
2879            Section::new("COMPONENTS", "line\n".repeat(2000), 9),
2880            Section::new("FLOWS", "flow\n".repeat(2000), 5),
2881            Section::new("INDEX STATUS", "ok\n".into(), 5),
2882        ];
2883        let (content, outcome) = render(sections, 300, vec!["fresh".into()], true);
2884        assert!(
2885            scc_core::estimate_tokens(&content) <= 300,
2886            "hard cap violated: {}",
2887            scc_core::estimate_tokens(&content)
2888        );
2889        assert!(outcome.hard_truncated, "hard path must engage");
2890        assert!(!outcome.dropped_sections.is_empty(), "drops must be recorded");
2891        assert!(!outcome.exceeded_soft_budget, "fit must hold");
2892    }
2893
2894    #[test]
2895    // trace:v1 id=test.scc.context.render-single-section-fit verifies=REQ-SCC-CTX exercises=impl.scc.packs
2896    fn render_single_priority_ten_section_fits() {
2897        // One huge priority-10 body: never dropped, line-truncated to fit.
2898        let sections = vec![Section::new("IDENTITY", "datum\n".repeat(5000), 10)];
2899        let (content, outcome) = render(sections, 200, Vec::new(), true);
2900        assert!(
2901            scc_core::estimate_tokens(&content) <= 200,
2902            "hard cap violated: {}",
2903            scc_core::estimate_tokens(&content)
2904        );
2905        assert!(
2906            outcome.dropped_sections.iter().any(|d| d == "truncated:IDENTITY"),
2907            "{:?}",
2908            outcome.dropped_sections
2909        );
2910    }
2911}