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    // C6: one pass over a single collected relationship vector. The four
1899    // checks below (dangling, resolved-evidence, inferred count, low-conf
1900    // deps) each used to call all_rels() separately — 4× filter + sort of
1901    // the full edge set per verify.
1902    let verify_rels: Vec<&scc_core::Relationship> = ctx.view.all_rels();
1903    let mut inv = String::new();
1904    // 1. dangling references
1905    let mut dangling = 0usize;
1906    for r in &verify_rels {
1907        // endpoints may be external_api, component, flow ids — check known
1908        // namespaces
1909        let known = |id: &str| -> bool {
1910            ctx.view.entity(id).is_some()
1911                || id.contains("/external_api/")
1912                || id.contains("/component/")
1913                || id.contains("/flow/")
1914                || id.contains("/invariant/")
1915        };
1916        if !known(&r.subject) {
1917            dangling += 1;
1918            if dangling <= 5 {
1919                inv.push_str(&format!(
1920                    "dangling subject: {} — {}\n",
1921                    r.subject, r.predicate
1922                ));
1923            }
1924        }
1925        if !known(&r.object) {
1926            dangling += 1;
1927            if dangling <= 5 {
1928                inv.push_str(&format!(
1929                    "dangling object: {} — {}\n",
1930                    r.predicate, r.object
1931                ));
1932            }
1933        }
1934    }
1935    inv.push_str(&format!("Dangling references: {dangling}\n"));
1936    // 2. RESOLVED facts must have evidence
1937    let mut no_evidence = 0usize;
1938    for r in &verify_rels {
1939        if r.provenance == Provenance::Resolved && r.evidence.is_empty() {
1940            no_evidence += 1;
1941        }
1942    }
1943    inv.push_str(&format!("RESOLVED facts without evidence: {no_evidence}\n"));
1944    // 3. critical invariants unenforced
1945    let unenforced = ctx
1946        .view
1947        .invariants()
1948        .iter()
1949        .filter(|i| i.severity == Severity::Critical && i.enforced_by.is_empty())
1950        .count();
1951    inv.push_str(&format!(
1952        "Critical invariants without enforcing tests: {unenforced}\n"
1953    ));
1954    // 4. inferred claims
1955    let inferred = verify_rels
1956        .iter()
1957        .filter(|r| r.provenance == Provenance::Inferred)
1958        .count();
1959    inv.push_str(&format!("Inferred claims (labeled): {inferred}\n"));
1960    sections.push(Section::new("GRAPH INVARIANTS", inv, 10));
1961
1962    // drift findings
1963    let findings = ctx.store.drift_findings(true).unwrap_or_default();
1964    let mut drift = String::new();
1965    if findings.is_empty() {
1966        drift.push_str("No drift findings.\n");
1967    }
1968    for (_, kind, sev, msg, _) in &findings {
1969        drift.push_str(&format!("- [{sev}] {kind}: {msg}\n"));
1970    }
1971    sections.push(Section::new("DRIFT", drift, 10));
1972
1973    // conflicts: conflicting writers recorded as drift; also low-confidence deps
1974    let mut low_conf = String::new();
1975    let mut lc = 0usize;
1976    for r in &verify_rels {
1977        if r.predicate == scc_core::predicates::DEPENDS_ON && r.confidence < 0.8 {
1978            lc += 1;
1979            if lc <= 8 {
1980                low_conf.push_str(&format!(
1981                    "- {} → {} ({:.2})\n",
1982                    component_short(&ctx.view, &r.subject),
1983                    component_short(&ctx.view, &r.object),
1984                    r.confidence
1985                ));
1986            }
1987        }
1988    }
1989    if lc > 0 {
1990        low_conf.push_str(&format!("… {lc} low-confidence dependency edge(s)\n"));
1991        sections.push(Section::new("LOW-CONFIDENCE DEPENDENCIES", low_conf, 8));
1992    }
1993
1994    // trust boundaries (docs/PRD.md §7, EPIC-148)
1995    // C6: render from the already-collected trusted vector — no second
1996    // full-table relationships read (crossing_lines re-read the table).
1997    let crossings =
1998        scc_graph::boundaries::boundary_crossings_from_rels(ctx.view.graph, &verify_rels, false);
1999    if !crossings.is_empty() {
2000        let mut b = String::new();
2001        b.push_str(&format!("{} boundary crossing(s):\n", crossings.len()));
2002        for c in crossings.iter().take(12) {
2003            b.push_str(&format!("- {c}\n"));
2004        }
2005        sections.push(Section::new("BOUNDARIES", b, 8));
2006    }
2007
2008    // runtime observations (docs/FLOW_COMPILER.md §8)
2009    let runtime_edges = ctx.store.runtime_edge_rows().unwrap_or_default();
2010    if !runtime_edges.is_empty() {
2011        let mut rt = String::new();
2012        let total: u64 = runtime_edges.iter().map(|e| e.count).sum();
2013        let errs: u64 = runtime_edges.iter().map(|e| e.errors).sum();
2014        rt.push_str(&format!(
2015            "{} observed edge(s), {} total observations, {} error(s).\n",
2016            runtime_edges.len(),
2017            total,
2018            errs
2019        ));
2020        for e in runtime_edges.iter().take(12) {
2021            rt.push_str(&format!(
2022                "- {} → {} ×{} (avg {:.1} ms, {} err)\n",
2023                e.source, e.target, e.count, e.latency_ms, e.errors
2024            ));
2025        }
2026        sections.push(Section::new("RUNTIME", rt, 8));
2027    }
2028
2029    // verdict
2030    let mut verdict = String::new();
2031    let ok = ctx.stale_paths.is_empty()
2032        && dangling == 0
2033        && no_evidence == 0
2034        && unenforced == 0
2035        && findings.is_empty();
2036    if ok {
2037        verdict.push_str("VERIFIED: model is fresh, consistent, and drift-free.\n");
2038    } else {
2039        verdict.push_str("ISSUES FOUND: review the sections above before trusting context.\n");
2040        if !ctx.stale_paths.is_empty() {
2041            verdict.push_str("  → re-index changed files (`scc index`)\n");
2042        }
2043        if unenforced > 0 {
2044            verdict.push_str("  → critical invariants need enforcing tests or declaration\n");
2045        }
2046        if dangling > 0 || no_evidence > 0 {
2047            verdict.push_str("  → graph integrity violated; re-index\n");
2048        }
2049    }
2050    sections.push(Section::new("VERDICT", verdict, 10));
2051
2052    if full {
2053        finish_soft(&mut pack, sections, budget, Vec::new());
2054    } else {
2055        finish(&mut pack, sections, budget, Vec::new());
2056    }
2057    pack
2058}
2059
2060// trace:exempt reason=internal-detail
2061struct LocusHits {
2062    files: Vec<String>,
2063    symbols: Vec<String>,
2064    body: String,
2065}
2066
2067/// Map stack/error FILE:LINE frames onto indexed files and innermost symbols.
2068// trace:v1 id=impl.scc.context.stack-locus work=WORK-ripwire-lessons-phase3 satisfies=REQ-stack-locus-ingest
2069fn resolve_goal_loci(ctx: &crate::ContextCompiler, goal: &str) -> Option<LocusHits> {
2070    let plan = route_query(goal);
2071    if !plan.prefer_locus || plan.loci.is_empty() {
2072        return None;
2073    }
2074    let mut files: Vec<String> = Vec::new();
2075    let mut symbols: Vec<String> = Vec::new();
2076    let mut body = String::new();
2077    for loc in &plan.loci {
2078        let mapped_file = ctx
2079            .view
2080            .entities_of_kind(kinds::FILE)
2081            .into_iter()
2082            .map(|e| e.name.clone())
2083            .find(|p| path_matches_locus(p, &loc.path));
2084        let mapped_file = mapped_file.or_else(|| {
2085            ctx.view
2086                .entities_of_kind(kinds::SYMBOL)
2087                .into_iter()
2088                .find_map(|e| {
2089                    e.attributes
2090                        .get("file")
2091                        .and_then(|v| v.as_str())
2092                        .filter(|p| path_matches_locus(p, &loc.path))
2093                        .map(|p| p.to_string())
2094                })
2095        });
2096        match mapped_file {
2097            None => {
2098                body.push_str(&format!(
2099                    "- {}:{} — unmapped (not fabricated)\n",
2100                    loc.path, loc.line
2101                ));
2102            }
2103            Some(path) => {
2104                if !files.iter().any(|f| f == &path) {
2105                    files.push(path.clone());
2106                }
2107                let enclosed = innermost_enclosing_symbol(ctx, &path, loc.line);
2108                if let Some(name) = &enclosed {
2109                    if !symbols.iter().any(|s| s == name) {
2110                        symbols.push(name.clone());
2111                    }
2112                }
2113                match enclosed {
2114                    Some(name) => {
2115                        body.push_str(&format!("- {path}:{} → symbol {name}\n", loc.line))
2116                    }
2117                    None => body.push_str(&format!(
2118                        "- {path}:{} → file (no enclosing symbol)\n",
2119                        loc.line
2120                    )),
2121                }
2122            }
2123        }
2124    }
2125    Some(LocusHits {
2126        files,
2127        symbols,
2128        body,
2129    })
2130}
2131
2132// trace:exempt reason=internal-detail
2133fn innermost_enclosing_symbol(
2134    ctx: &crate::ContextCompiler,
2135    file: &str,
2136    line: u32,
2137) -> Option<String> {
2138    let mut best: Option<(u32, String)> = None;
2139    for e in ctx.view.entities_of_kind(kinds::SYMBOL) {
2140        let Some(f) = e.attributes.get("file").and_then(|v| v.as_str()) else {
2141            continue;
2142        };
2143        if !path_matches_locus(f, file) && f != file {
2144            continue;
2145        }
2146        let start = e
2147            .attributes
2148            .get("start_line")
2149            .and_then(|v| v.as_u64())
2150            .unwrap_or(0) as u32;
2151        let end = e
2152            .attributes
2153            .get("end_line")
2154            .and_then(|v| v.as_u64())
2155            .unwrap_or(u64::from(start)) as u32;
2156        if start == 0 || line < start || line > end {
2157            continue;
2158        }
2159        let span = end.saturating_sub(start);
2160        if best.as_ref().map(|(s, _)| span < *s).unwrap_or(true) {
2161            best = Some((span, e.name.clone()));
2162        }
2163    }
2164    best.map(|(_, n)| n)
2165}
2166
2167/// tests_to_run: test id → reasons (`direct`, `import`, `contract`, `state`).
2168// 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
2169fn collect_tests_to_run(
2170    ctx: &crate::ContextCompiler,
2171    affected_syms: &HashSet<&String>,
2172    affected_comps: &BTreeSet<String>,
2173    owned_stores: &BTreeSet<String>,
2174) -> BTreeMap<String, BTreeSet<&'static str>> {
2175    let mut tests: BTreeMap<String, BTreeSet<&'static str>> = BTreeMap::new();
2176    for sid in affected_syms {
2177        for r in ctx.view.out_pred(sid, scc_core::predicates::TESTED_BY) {
2178            tests.entry(r.object.clone()).or_default().insert("direct");
2179        }
2180        for pred in [
2181            scc_core::predicates::HANDLES,
2182            scc_core::predicates::IMPLEMENTS,
2183        ] {
2184            for r in ctx.view.out_pred(sid, pred) {
2185                let Some(target) = ctx.view.entity(&r.object) else {
2186                    continue;
2187                };
2188                if target.kind != kinds::CONTRACT && target.kind != kinds::ROUTE {
2189                    continue;
2190                }
2191                for t in ctx
2192                    .view
2193                    .out_pred(&r.object, scc_core::predicates::TESTED_BY)
2194                {
2195                    tests
2196                        .entry(t.object.clone())
2197                        .or_default()
2198                        .insert("contract");
2199                }
2200            }
2201        }
2202    }
2203    let affected_files: BTreeSet<String> = affected_syms
2204        .iter()
2205        .filter_map(|sid| {
2206            ctx.view
2207                .entity(sid.as_str())
2208                .and_then(|e| e.attributes.get("file"))
2209                .and_then(|v| v.as_str())
2210                .map(|f| f.to_string())
2211        })
2212        .collect();
2213    if !affected_files.is_empty() {
2214        for (id, _name, file, _kind, _sym) in ctx.store.tests().unwrap_or_default() {
2215            let imports = ctx.store.imports_in_file(&file).unwrap_or_default();
2216            let hits_affected = imports.iter().any(|(module, _names, _line, _typ)| {
2217                let target = resolve_module_ref(&file, module);
2218                affected_files.iter().any(|f| {
2219                    *f == target
2220                        || f.starts_with(&format!("{target}."))
2221                        || *f == format!("{target}/__init__.py")
2222                })
2223            });
2224            if hits_affected {
2225                tests.entry(id).or_default().insert("import");
2226            }
2227        }
2228    }
2229    let mut state_ids: BTreeSet<String> = BTreeSet::new();
2230    for sid in owned_stores {
2231        if is_state_like(ctx.view.entity(sid).map(|e| e.kind.as_str())) {
2232            state_ids.insert(sid.clone());
2233        }
2234    }
2235    for cid in affected_comps {
2236        for r in ctx.view.out_pred(cid, scc_core::predicates::OWNS) {
2237            if is_state_like(ctx.view.entity(&r.object).map(|e| e.kind.as_str())) {
2238                state_ids.insert(r.object.clone());
2239            }
2240        }
2241    }
2242    if !state_ids.is_empty() {
2243        for (id, _name, _file, _kind, _sym) in ctx.store.tests().unwrap_or_default() {
2244            let reads = ctx
2245                .view
2246                .out_pred(&id, scc_core::predicates::READS)
2247                .iter()
2248                .any(|r| state_ids.contains(&r.object));
2249            let writes = ctx
2250                .view
2251                .out_pred(&id, scc_core::predicates::WRITES)
2252                .iter()
2253                .any(|r| state_ids.contains(&r.object));
2254            if reads || writes {
2255                tests.entry(id).or_default().insert("state");
2256            }
2257        }
2258    }
2259    tests
2260}
2261
2262// trace:exempt reason=internal-detail
2263fn is_state_like(kind: Option<&str>) -> bool {
2264    matches!(
2265        kind,
2266        Some(kinds::STATE)
2267            | Some(kinds::DATA_STORE)
2268            | Some(kinds::DATA_ENTITY)
2269            | Some(kinds::TABLE)
2270            | Some(kinds::COLLECTION)
2271            | Some(kinds::CACHE)
2272    )
2273}
2274
2275// trace:exempt reason=internal-detail
2276fn format_test_to_run(
2277    ctx: &crate::ContextCompiler,
2278    tid: &str,
2279    reasons: &BTreeSet<&'static str>,
2280) -> String {
2281    let name = entity_name(&ctx.view, tid);
2282    let file = ctx
2283        .view
2284        .entity(tid)
2285        .and_then(|e| e.attributes.get("file"))
2286        .and_then(|v| v.as_str())
2287        .unwrap_or("");
2288    let why = reasons.iter().copied().collect::<Vec<_>>().join(", ");
2289    if file.is_empty() {
2290        format!("- {name} — {why}\n")
2291    } else {
2292        format!("- {name} ({file}) — {why}\n")
2293    }
2294}
2295
2296/// Resolve a module specifier (relative or dotted) to a repo-relative path
2297/// prefix, mirroring the indexer's import normalization.
2298fn resolve_module_ref(from_file: &str, module: &str) -> String {
2299    if !module.starts_with('.') {
2300        return module.replace('.', "/");
2301    }
2302    let dir = from_file.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
2303    let joined = if dir.is_empty() {
2304        module.to_string()
2305    } else {
2306        format!("{dir}/{module}")
2307    };
2308    let mut parts: Vec<&str> = Vec::new();
2309    for seg in joined.split('/') {
2310        match seg {
2311            "" | "." => continue,
2312            ".." => {
2313                parts.pop();
2314            }
2315            s => parts.push(s),
2316        }
2317    }
2318    parts.join("/")
2319}
2320
2321fn flow_kind_str(k: scc_core::FlowKind) -> &'static str {
2322    match k {
2323        scc_core::FlowKind::Architecture => "architecture",
2324        scc_core::FlowKind::Workflow => "workflow",
2325        scc_core::FlowKind::Sequence => "sequence",
2326        scc_core::FlowKind::Dataflow => "dataflow",
2327        scc_core::FlowKind::Lifecycle => "lifecycle",
2328    }
2329}
2330
2331fn severity_str(s: Severity) -> &'static str {
2332    match s {
2333        Severity::Info => "info",
2334        Severity::Low => "low",
2335        Severity::Medium => "medium",
2336        Severity::High => "high",
2337        Severity::Critical => "critical",
2338    }
2339}
2340
2341#[cfg(test)]
2342mod tests {
2343    use super::*;
2344    use scc_core::{Entity, Provenance, Relationship};
2345    use scc_store::Store;
2346
2347    #[test]
2348    // 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
2349    fn task_context_files_stay_inside_the_repo() {
2350        let dir = tempfile::TempDir::new().unwrap();
2351        let root = dir.path().join("repo");
2352        std::fs::create_dir_all(&root).unwrap();
2353        std::fs::write(root.join("ok.py"), "IN_REPO = 1\n").unwrap();
2354        std::fs::write(dir.path().join("secret.py"), "OUTSIDE_SECRET = 1\n").unwrap();
2355        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2356        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2357        let ctx = crate::ContextCompiler::new(
2358            &store,
2359            &graph,
2360            crate::ContextSettings::default(),
2361            Vec::new(),
2362        );
2363        let body = exact_source_tail(
2364            &ctx,
2365            &[
2366                "ok.py".into(),
2367                "../secret.py".into(),
2368                "/etc/passwd".into(),
2369            ],
2370            &[],
2371        );
2372        assert!(body.contains("IN_REPO"), "{body}");
2373        assert!(!body.contains("OUTSIDE_SECRET"), "{body}");
2374        assert!(!body.contains("/etc/passwd"), "{body}");
2375        assert!(!body.contains("secret.py"), "{body}");
2376    }
2377
2378    #[test]
2379    // trace:v1 id=test.scc.context.tests-to-run verifies=REQ-tests-to-run-reasons exercises=impl.scc.context.tests-to-run
2380    fn tests_to_run_records_direct_reason() {
2381        let dir = tempfile::TempDir::new().unwrap();
2382        let root = dir.path().join("repo");
2383        std::fs::create_dir_all(&root).unwrap();
2384        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2385        let sid = "s:fn".to_string();
2386        let tid = "t:test_fn".to_string();
2387        let mut se = Entity::new(&sid, kinds::SYMBOL, "handleList");
2388        se.attr("file", serde_json::json!("src/a.py"));
2389        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2390        let mut te = Entity::new(&tid, kinds::TEST, "test_handle_list");
2391        te.attr("file", serde_json::json!("tests/test_a.py"));
2392        store
2393            .insert_entity(&te, &["tests/test_a.py".into()])
2394            .unwrap();
2395        let rel = Relationship::new(
2396            "rel:tb",
2397            sid.clone(),
2398            scc_core::predicates::TESTED_BY,
2399            tid.clone(),
2400            Provenance::Extracted,
2401        );
2402        store.insert_relationship(&rel, "src/a.py").unwrap();
2403        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2404        let ctx = crate::ContextCompiler::new(
2405            &store,
2406            &graph,
2407            crate::ContextSettings::default(),
2408            Vec::new(),
2409        );
2410        let affected: HashSet<&String> = [&sid].into_iter().collect();
2411        let found = collect_tests_to_run(&ctx, &affected, &BTreeSet::new(), &BTreeSet::new());
2412        let reasons = found
2413            .get(&tid)
2414            .expect("direct TESTED_BY must list the test");
2415        assert!(reasons.contains("direct"));
2416        let line = format_test_to_run(&ctx, &tid, reasons);
2417        assert!(line.contains("direct"), "{line}");
2418        assert!(line.contains("test_handle_list"), "{line}");
2419        assert!(line.contains("tests/test_a.py"), "{line}");
2420    }
2421
2422    #[test]
2423    // trace:v1 id=test.scc.context.tests-to-run-import verifies=REQ-tests-to-run-reasons exercises=impl.scc.context.tests-to-run
2424    fn tests_to_run_records_import_reason() {
2425        let dir = tempfile::TempDir::new().unwrap();
2426        let root = dir.path().join("repo");
2427        std::fs::create_dir_all(&root).unwrap();
2428        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2429        let sid = "s:fn".to_string();
2430        let tid = "t:import_test".to_string();
2431        let mut se = Entity::new(&sid, kinds::SYMBOL, "handleList");
2432        se.attr("file", serde_json::json!("src/a.py"));
2433        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2434        let mut te = Entity::new(&tid, kinds::TEST, "test_via_import");
2435        te.attr("file", serde_json::json!("tests/test_a.py"));
2436        store
2437            .insert_entity(&te, &["tests/test_a.py".into()])
2438            .unwrap();
2439        store
2440            .insert_test(&tid, "test_via_import", "tests/test_a.py", "unit", None)
2441            .unwrap();
2442        store
2443            .insert_imports(
2444                "tests/test_a.py",
2445                &[("src.a".into(), vec![], 1, "module".into())],
2446            )
2447            .unwrap();
2448        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2449        let ctx = crate::ContextCompiler::new(
2450            &store,
2451            &graph,
2452            crate::ContextSettings::default(),
2453            Vec::new(),
2454        );
2455        let affected: HashSet<&String> = [&sid].into_iter().collect();
2456        let found = collect_tests_to_run(&ctx, &affected, &BTreeSet::new(), &BTreeSet::new());
2457        let reasons = found
2458            .get(&tid)
2459            .expect("import of affected file must list the test");
2460        assert!(reasons.contains("import"));
2461        assert!(!reasons.contains("direct"));
2462    }
2463
2464    #[test]
2465    // trace:v1 id=test.scc.context.tests-to-run-contract verifies=REQ-tests-to-run-reasons exercises=impl.scc.context.tests-to-run
2466    fn tests_to_run_records_contract_reason() {
2467        let dir = tempfile::TempDir::new().unwrap();
2468        let root = dir.path().join("repo");
2469        std::fs::create_dir_all(&root).unwrap();
2470        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2471        let sid = "s:handler".to_string();
2472        let cid = "c:list".to_string();
2473        let tid = "t:contract".to_string();
2474        let mut se = Entity::new(&sid, kinds::SYMBOL, "handleList");
2475        se.attr("file", serde_json::json!("src/a.py"));
2476        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2477        store
2478            .insert_entity(
2479                &Entity::new(&cid, kinds::CONTRACT, "GET /list"),
2480                &["src/a.py".into()],
2481            )
2482            .unwrap();
2483        let mut te = Entity::new(&tid, kinds::TEST, "test_list_route");
2484        te.attr("file", serde_json::json!("tests/test_routes.py"));
2485        store
2486            .insert_entity(&te, &["tests/test_routes.py".into()])
2487            .unwrap();
2488        store
2489            .insert_relationship(
2490                &Relationship::new(
2491                    "rel:h",
2492                    sid.clone(),
2493                    scc_core::predicates::HANDLES,
2494                    cid.clone(),
2495                    Provenance::Extracted,
2496                ),
2497                "src/a.py",
2498            )
2499            .unwrap();
2500        store
2501            .insert_relationship(
2502                &Relationship::new(
2503                    "rel:ct",
2504                    cid.clone(),
2505                    scc_core::predicates::TESTED_BY,
2506                    tid.clone(),
2507                    Provenance::Extracted,
2508                ),
2509                "tests/test_routes.py",
2510            )
2511            .unwrap();
2512        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2513        let ctx = crate::ContextCompiler::new(
2514            &store,
2515            &graph,
2516            crate::ContextSettings::default(),
2517            Vec::new(),
2518        );
2519        let affected: HashSet<&String> = [&sid].into_iter().collect();
2520        let found = collect_tests_to_run(&ctx, &affected, &BTreeSet::new(), &BTreeSet::new());
2521        let reasons = found
2522            .get(&tid)
2523            .expect("contract TESTED_BY must list the test");
2524        assert!(reasons.contains("contract"));
2525        assert!(!reasons.contains("direct"));
2526    }
2527
2528    #[test]
2529    // trace:v1 id=test.scc.context.tests-to-run-state verifies=REQ-tests-to-run-reasons exercises=impl.scc.context.tests-to-run
2530    fn tests_to_run_records_state_reason() {
2531        let dir = tempfile::TempDir::new().unwrap();
2532        let root = dir.path().join("repo");
2533        std::fs::create_dir_all(&root).unwrap();
2534        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2535        let comp = "comp:orders".to_string();
2536        let st = "state:orders_db".to_string();
2537        let tid = "t:state".to_string();
2538        store
2539            .insert_entity(
2540                &Entity::new(&comp, kinds::COMPONENT, "orders"),
2541                &["src/a.py".into()],
2542            )
2543            .unwrap();
2544        store
2545            .insert_entity(
2546                &Entity::new(&st, kinds::STATE, "orders_db"),
2547                &["src/a.py".into()],
2548            )
2549            .unwrap();
2550        let mut te = Entity::new(&tid, kinds::TEST, "test_orders_state");
2551        te.attr("file", serde_json::json!("tests/test_state.py"));
2552        store
2553            .insert_entity(&te, &["tests/test_state.py".into()])
2554            .unwrap();
2555        store
2556            .insert_test(
2557                &tid,
2558                "test_orders_state",
2559                "tests/test_state.py",
2560                "unit",
2561                None,
2562            )
2563            .unwrap();
2564        store
2565            .insert_relationship(
2566                &Relationship::new(
2567                    "rel:owns",
2568                    comp.clone(),
2569                    scc_core::predicates::OWNS,
2570                    st.clone(),
2571                    Provenance::Extracted,
2572                ),
2573                "src/a.py",
2574            )
2575            .unwrap();
2576        store
2577            .insert_relationship(
2578                &Relationship::new(
2579                    "rel:rd",
2580                    tid.clone(),
2581                    scc_core::predicates::READS,
2582                    st.clone(),
2583                    Provenance::Extracted,
2584                ),
2585                "tests/test_state.py",
2586            )
2587            .unwrap();
2588        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2589        let ctx = crate::ContextCompiler::new(
2590            &store,
2591            &graph,
2592            crate::ContextSettings::default(),
2593            Vec::new(),
2594        );
2595        let comps: BTreeSet<String> = [comp].into_iter().collect();
2596        let found = collect_tests_to_run(&ctx, &HashSet::new(), &comps, &BTreeSet::new());
2597        let reasons = found.get(&tid).expect("state READS must list the test");
2598        assert!(reasons.contains("state"));
2599    }
2600
2601    #[test]
2602    // 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
2603    fn tests_to_run_records_state_reason_for_data_store() {
2604        let dir = tempfile::TempDir::new().unwrap();
2605        let root = dir.path().join("repo");
2606        std::fs::create_dir_all(&root).unwrap();
2607        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2608        let comp = "comp:orders".to_string();
2609        let st = "store:orders_db".to_string();
2610        let tid = "t:store".to_string();
2611        store
2612            .insert_entity(
2613                &Entity::new(&comp, kinds::COMPONENT, "orders"),
2614                &["src/a.py".into()],
2615            )
2616            .unwrap();
2617        store
2618            .insert_entity(
2619                &Entity::new(&st, kinds::DATA_STORE, "orders_db"),
2620                &["src/a.py".into()],
2621            )
2622            .unwrap();
2623        let mut te = Entity::new(&tid, kinds::TEST, "test_orders_store");
2624        te.attr("file", serde_json::json!("tests/test_store.py"));
2625        store
2626            .insert_entity(&te, &["tests/test_store.py".into()])
2627            .unwrap();
2628        store
2629            .insert_test(
2630                &tid,
2631                "test_orders_store",
2632                "tests/test_store.py",
2633                "unit",
2634                None,
2635            )
2636            .unwrap();
2637        store
2638            .insert_relationship(
2639                &Relationship::new(
2640                    "rel:owns-store",
2641                    comp.clone(),
2642                    scc_core::predicates::OWNS,
2643                    st.clone(),
2644                    Provenance::Extracted,
2645                ),
2646                "src/a.py",
2647            )
2648            .unwrap();
2649        store
2650            .insert_relationship(
2651                &Relationship::new(
2652                    "rel:rd-store",
2653                    tid.clone(),
2654                    scc_core::predicates::READS,
2655                    st.clone(),
2656                    Provenance::Extracted,
2657                ),
2658                "tests/test_store.py",
2659            )
2660            .unwrap();
2661        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2662        let ctx = crate::ContextCompiler::new(
2663            &store,
2664            &graph,
2665            crate::ContextSettings::default(),
2666            Vec::new(),
2667        );
2668        let comps: BTreeSet<String> = [comp].into_iter().collect();
2669        let found = collect_tests_to_run(&ctx, &HashSet::new(), &comps, &BTreeSet::new());
2670        let reasons = found.get(&tid).expect("store READS must list the test");
2671        assert!(reasons.contains("state"));
2672    }
2673
2674    #[test]
2675    // trace:v1 id=test.scc.context.stack-locus verifies=REQ-stack-locus-ingest exercises=impl.scc.context.stack-locus
2676    fn stack_locus_seeds_file_and_innermost_symbol() {
2677        let dir = tempfile::TempDir::new().unwrap();
2678        let root = dir.path().join("repo");
2679        std::fs::create_dir_all(root.join("src")).unwrap();
2680        std::fs::write(root.join("src/app.py"), "def inner():\n    x = 1\n").unwrap();
2681        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2682        let fe = Entity::new("f:app", kinds::FILE, "src/app.py");
2683        store.insert_entity(&fe, &["src/app.py".into()]).unwrap();
2684        let mut outer = Entity::new("s:outer", kinds::SYMBOL, "outer");
2685        outer.attr("file", serde_json::json!("src/app.py"));
2686        outer.attr("start_line", serde_json::json!(1u32));
2687        outer.attr("end_line", serde_json::json!(20u32));
2688        store.insert_entity(&outer, &["src/app.py".into()]).unwrap();
2689        let mut inner = Entity::new("s:inner", kinds::SYMBOL, "inner");
2690        inner.attr("file", serde_json::json!("src/app.py"));
2691        inner.attr("start_line", serde_json::json!(10u32));
2692        inner.attr("end_line", serde_json::json!(12u32));
2693        store.insert_entity(&inner, &["src/app.py".into()]).unwrap();
2694        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2695        let ctx = crate::ContextCompiler::new(
2696            &store,
2697            &graph,
2698            crate::ContextSettings::default(),
2699            Vec::new(),
2700        );
2701        let goal = "Traceback (most recent call last):\n  File \"src/app.py\", line 11, in inner\nValueError: boom\n";
2702        let hits = resolve_goal_loci(&ctx, goal).expect("locus");
2703        assert!(hits.files.iter().any(|f| f == "src/app.py"));
2704        assert_eq!(hits.symbols, vec!["inner".to_string()]);
2705        assert!(hits.body.contains("inner"));
2706        let miss = resolve_goal_loci(
2707            &ctx,
2708            "Traceback (most recent call last):\n  File \"src/missing.py\", line 1, in x\nValueError: x\n",
2709        )
2710        .unwrap();
2711        assert!(miss.body.contains("unmapped"));
2712        assert!(miss.files.is_empty());
2713    }
2714
2715    #[test]
2716    // trace:v1 id=test.scc.context.finish-rollover verifies=REQ-budget-rollover exercises=impl.scc.context.finish-rollover
2717    fn finish_with_rollover_discloses_truncated_quota() {
2718        let mut pack = ContextPack::new("task", "rev");
2719        let huge = "word ".repeat(4000);
2720        let sections = vec![
2721            Section::new("TASK", "goal\n".into(), 10),
2722            Section::new("IMPLEMENTATION", huge, 7),
2723        ];
2724        finish_with_rollover(&mut pack, sections, 200, Vec::new());
2725        assert!(
2726            pack.dropped_sections.iter().any(|d| d == "quota:source"),
2727            "{:?}",
2728            pack.dropped_sections
2729        );
2730        assert!(pack.truncated);
2731        assert!(pack.content.contains("truncated") || pack.hard_truncated);
2732    }
2733
2734    #[test]
2735    // trace:v1 id=test.scc.context.impact-forgotten verifies=REQ-forgotten-impact-partners exercises=impl.scc.context.impact-forgotten
2736    fn impact_pack_discloses_forgotten_partners_without_merging_them() {
2737        let dir = tempfile::TempDir::new().unwrap();
2738        let root = dir.path().join("repo");
2739        std::fs::create_dir_all(root.join("src")).unwrap();
2740        fn git(root: &std::path::Path, args: &[&str]) {
2741            let out = std::process::Command::new("git")
2742                .args(args)
2743                .current_dir(root)
2744                .output()
2745                .unwrap();
2746            assert!(
2747                out.status.success(),
2748                "{args:?} {}",
2749                String::from_utf8_lossy(&out.stderr)
2750            );
2751        }
2752        git(&root, &["init", "-q"]);
2753        git(&root, &["config", "user.email", "test@example.com"]);
2754        git(&root, &["config", "user.name", "SCC Test"]);
2755        git(&root, &["config", "commit.gpgsign", "false"]);
2756        for i in 0..2 {
2757            std::fs::write(root.join("src/a.py"), format!("a = {i}\n")).unwrap();
2758            std::fs::write(root.join("src/b.py"), format!("b = {i}\n")).unwrap();
2759            git(&root, &["add", "-A"]);
2760            git(&root, &["commit", "-q", "-m", &format!("c{i}")]);
2761        }
2762        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2763        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2764        let ctx = crate::ContextCompiler::new(
2765            &store,
2766            &graph,
2767            crate::ContextSettings::default(),
2768            Vec::new(),
2769        );
2770        let pack = impact(&ctx, &["src/a.py".into()], &[], None, 50_000, false);
2771        assert!(
2772            pack.content.contains("FORGOTTEN PARTNERS"),
2773            "missing section: {}",
2774            pack.content
2775        );
2776        assert!(pack.content.contains("src/b.py"), "{}", pack.content);
2777        assert!(pack.content.contains("cochange"), "{}", pack.content);
2778        assert!(
2779            !pack.content.contains("AFFECTED COMPONENTS\nsrc/b.py"),
2780            "partner must not become affected: {}",
2781            pack.content
2782        );
2783        assert!(pack.content.contains("historical; not EXTRACTED impact"));
2784    }
2785
2786    #[test]
2787    // trace:v1 id=test.scc.context.exact-source-last verifies=REQ-exact-source-dominance exercises=impl.scc.context.exact-source-last
2788    fn task_pack_puts_exact_source_last_and_drops_it_first() {
2789        let dir = tempfile::TempDir::new().unwrap();
2790        let root = dir.path().join("repo");
2791        std::fs::create_dir_all(root.join("src")).unwrap();
2792        let src = "def handle_list():\n    return [1, 2, 3]\n";
2793        std::fs::write(root.join("src/a.py"), src).unwrap();
2794        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2795        let mut se = Entity::new("s:fn", kinds::SYMBOL, "handle_list");
2796        se.attr("file", serde_json::json!("src/a.py"));
2797        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2798        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2799        let ctx = crate::ContextCompiler::new(
2800            &store,
2801            &graph,
2802            crate::ContextSettings::default(),
2803            Vec::new(),
2804        );
2805        let fat = task(&ctx, "handle list", &["src/a.py".into()], &[], 50_000);
2806        let idx_exact = fat
2807            .content
2808            .find("EXACT SOURCE")
2809            .expect("exact source must appear when budget allows");
2810        let idx_task = fat.content.find("TASK").expect("task header");
2811        assert!(
2812            idx_exact > idx_task,
2813            "exact source must follow semantic sections"
2814        );
2815        assert!(
2816            fat.content.contains("shown=2 total=2 capped=0"),
2817            "{}",
2818            fat.content
2819        );
2820        assert!(fat.content.contains("def handle_list()"), "{}", fat.content);
2821        assert!(
2822            fat.content.contains("FETCH") && fat.content.contains("handle=scc://"),
2823            "task pack must stamp fetch handles: {}",
2824            fat.content
2825        );
2826        let thin = task(&ctx, "handle list", &["src/a.py".into()], &[], 80);
2827        assert!(
2828            thin.dropped_sections.iter().any(|s| s == "EXACT SOURCE"),
2829            "exact source is priority 1 and must drop first: {:?}",
2830            thin.dropped_sections
2831        );
2832        assert!(
2833            !thin.content.contains("EXACT SOURCE"),
2834            "dropped exact source must not remain in content: {}",
2835            thin.content
2836        );
2837    }
2838
2839    #[test]
2840    // 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
2841    fn task_pack_stamps_fetch_handles_that_roundtrip() {
2842        let dir = tempfile::TempDir::new().unwrap();
2843        let root = dir.path().join("repo");
2844        std::fs::create_dir_all(root.join("src")).unwrap();
2845        let src = "def handle_list():\n    return [1, 2, 3]\n";
2846        std::fs::write(root.join("src/a.py"), src).unwrap();
2847        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2848        let mut se = Entity::new("s:fn", kinds::SYMBOL, "handle_list");
2849        se.attr("file", serde_json::json!("src/a.py"));
2850        store.insert_entity(&se, &["src/a.py".into()]).unwrap();
2851        let graph = scc_graph::RealityGraph::load(&store).unwrap();
2852        let ctx = crate::ContextCompiler::new(
2853            &store,
2854            &graph,
2855            crate::ContextSettings::default(),
2856            Vec::new(),
2857        );
2858        let pack = task(&ctx, "handle list", &["src/a.py".into()], &[], 50_000);
2859        let handle = pack
2860            .content
2861            .lines()
2862            .find_map(|l| l.split("handle=").nth(1))
2863            .map(|s| s.split_whitespace().next().unwrap_or(s).to_string())
2864            .expect("handle in FETCH or EXACT SOURCE");
2865        assert!(handle.starts_with("scc://"), "{handle}");
2866        let resolved =
2867            crate::structural_source::resolve_handle_to_path(&root, &handle).expect("fresh handle");
2868        assert_eq!(resolved, "src/a.py");
2869        let stale = format!(
2870            "{}@aaaaaaaaaaaaaaaa",
2871            handle.rsplit_once('@').map(|(p, _)| p).unwrap_or(&handle)
2872        );
2873        let err = crate::structural_source::resolve_handle_to_path(&root, &stale).unwrap_err();
2874        assert!(err.contains("stale"), "{err}");
2875    }
2876
2877    #[test]
2878    // trace:v1 id=test.scc.context.render-hard-cap verifies=REQ-SCC-CTX exercises=impl.scc.packs
2879    fn render_hard_cap_always_fits() {
2880        // Even priority-9/10 material cannot escape the budget: the hard
2881        // phase truncates and drops (recorded) until the text fits.
2882        let sections = vec![
2883            Section::new("IDENTITY", "repo: x\n".into(), 10),
2884            Section::new("COMPONENTS", "line\n".repeat(2000), 9),
2885            Section::new("FLOWS", "flow\n".repeat(2000), 5),
2886            Section::new("INDEX STATUS", "ok\n".into(), 5),
2887        ];
2888        let (content, outcome) = render(sections, 300, vec!["fresh".into()], true);
2889        assert!(
2890            scc_core::estimate_tokens(&content) <= 300,
2891            "hard cap violated: {}",
2892            scc_core::estimate_tokens(&content)
2893        );
2894        assert!(outcome.hard_truncated, "hard path must engage");
2895        assert!(!outcome.dropped_sections.is_empty(), "drops must be recorded");
2896        assert!(!outcome.exceeded_soft_budget, "fit must hold");
2897    }
2898
2899    #[test]
2900    // trace:v1 id=test.scc.context.render-single-section-fit verifies=REQ-SCC-CTX exercises=impl.scc.packs
2901    fn render_single_priority_ten_section_fits() {
2902        // One huge priority-10 body: never dropped, line-truncated to fit.
2903        let sections = vec![Section::new("IDENTITY", "datum\n".repeat(5000), 10)];
2904        let (content, outcome) = render(sections, 200, Vec::new(), true);
2905        assert!(
2906            scc_core::estimate_tokens(&content) <= 200,
2907            "hard cap violated: {}",
2908            scc_core::estimate_tokens(&content)
2909        );
2910        assert!(
2911            outcome.dropped_sections.iter().any(|d| d == "truncated:IDENTITY"),
2912            "{:?}",
2913            outcome.dropped_sections
2914        );
2915    }
2916}