Skip to main content

scc_context/
structural_source.rs

1//! Structural Source compiler (Wave 14, Level 2 of the context stack,
2//! docs/SYSTEM_DESIGN.md Wave 14): a semantic skeleton of an implementation
3//! slice. Denser than signature compression: per symbol it preserves the
4//! signature plus the calls, state access, events, contracts, and control
5//! skeleton the extractor actually evidenced — and never fabricates
6//! sequence beyond CFG/flow truth.
7//!
8//! Every unit carries provenance back to the exact source (`source:
9//! <path>:L<start>-L<end>`, `representation`, `revision`) so compressed
10//! output is never mistaken for source text.
11//!
12//! ## CFG/control evidence the store actually holds
13//!
14//! The indexer records, per symbol entity (`write.rs`): `call_order`
15//! (callee -> min lexical order), `call_blocks` (callee -> nearest
16//! control-block kind: if/else/for/while/try/catch/match/switch/with/do/
17//! loop/finally/select), `awaited_calls`, `call_returns` (callees whose
18//! result is consumed — the return evidence), and `conditional_calls`
19//! (older fallback: callees inside any branch, kind unknown). Control
20//! blocks carry no nesting depth and no condition expressions — the
21//! skeleton therefore emits one control line per callee, labeled by the
22//! callee, with that callee's lines nested beneath it.
23
24use crate::ContextCompiler;
25use scc_core::{
26    choose_representation, estimate_tokens, fnv1a64_hex, kinds, predicates, ContentHandle,
27    HandleError, HandleKind, Relationship, RepresentationKind, StructuralSourceUnit,
28};
29use std::collections::BTreeMap;
30use std::path::Path;
31
32/// Representation label for deep structural units.
33const STRUCTURAL: &str = "STRUCTURAL";
34/// Representation label for fallback (signature-only) units.
35const SIGNATURES: &str = "SIGNATURES";
36/// Representation label when exact source is cheaper than the skeleton.
37const EXACT: &str = "EXACT";
38
39/// CFG attributes the indexer writes on symbol entities.
40const ATTR_CALL_ORDER: &str = "call_order";
41const ATTR_CALL_BLOCKS: &str = "call_blocks";
42const ATTR_CONDITIONAL_CALLS: &str = "conditional_calls";
43const ATTR_CALL_RETURNS: &str = "call_returns";
44
45/// Edge predicates the skeleton renders, in deterministic priority order
46/// for same-line ties.
47const EDGE_ORDER: &[(&str, &str)] = &[
48    (predicates::CALLS, "CALL"),
49    (predicates::READS, "READ"),
50    (predicates::QUERIES, "QUERY"),
51    (predicates::WRITES, "WRITE"),
52    (predicates::TRANSFORMS, "TRANSFORM"),
53    (predicates::PUBLISHES, "EVENT"),
54    (predicates::SUBSCRIBES, "QUEUE"),
55    (predicates::REGISTERS, "CONTRACT"),
56    (predicates::CONSUMES, "CONTRACT"),
57    (predicates::PRODUCES, "CONTRACT"),
58    (predicates::PARTICIPATES_IN, "CONTRACT"),
59];
60
61/// Contract verbs only render when the edge target is a CONTRACT entity.
62const CONTRACT_VERBS: &[&str] = &[
63    predicates::REGISTERS,
64    predicates::CONSUMES,
65    predicates::PRODUCES,
66    predicates::PARTICIPATES_IN,
67];
68
69/// Callee names containing any of these tokens are logging noise and are
70/// stripped from the skeleton.
71const LOGGING_TOKENS: &[&str] = &["log", "debug", "info", "warn", "print"];
72
73/// Map a control-block kind to its skeleton verb.
74// trace:exempt reason=internal-detail
75fn control_verb(kind: &str) -> String {
76    match kind {
77        "if" | "else" => "IF".to_string(),
78        "for" | "while" | "do" | "loop" => "LOOP".to_string(),
79        "try" | "catch" | "finally" => "TRY".to_string(),
80        "match" | "switch" => "MATCH".to_string(),
81        other => other.to_uppercase(),
82    }
83}
84
85/// Smallest evidence line across an edge's evidence ids, or `default` when
86/// the edge carries no line-bearing evidence.
87// trace:exempt reason=internal-detail
88fn evidence_line(compiler: &ContextCompiler, edge: &Relationship, default: u32) -> u32 {
89    let evmap = compiler.evidence_map();
90    let mut min: Option<u32> = None;
91    for id in &edge.evidence {
92        if let Some(ev) = evmap.get(id) {
93            if let Some(l) = ev.start_line {
94                min = Some(min.map_or(l, |m| m.min(l)));
95            }
96        }
97    }
98    min.unwrap_or(default)
99}
100
101/// First sentence of a docstring, kept as a `# <doc>` line.
102// trace:exempt reason=internal-detail
103fn first_sentence(doc: &str) -> String {
104    let doc = doc.trim();
105    if doc.is_empty() {
106        return String::new();
107    }
108    let mut end = doc.len();
109    for (i, c) in doc.char_indices() {
110        if c == '\n' {
111            end = i;
112            break;
113        }
114        if c == '.' {
115            let rest = &doc[i + 1..];
116            if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\n') {
117                end = i + 1;
118                break;
119            }
120        }
121    }
122    doc[..end].trim().to_string()
123}
124
125/// Whether a callee name is logging noise.
126// trace:exempt reason=internal-detail
127fn is_logging(name: &str) -> bool {
128    let lower = name.to_lowercase();
129    LOGGING_TOKENS.iter().any(|t| lower.contains(t))
130}
131
132/// `true` when the symbol carries any CFG evidence attribute.
133// trace:exempt reason=internal-detail
134fn has_cfg_evidence(sym: &scc_core::Entity) -> bool {
135    [ATTR_CALL_ORDER, ATTR_CALL_BLOCKS, ATTR_CONDITIONAL_CALLS]
136        .iter()
137        .any(|a| sym.attributes.contains_key(*a))
138}
139
140// trace:exempt reason=internal-detail
141fn attr_u32(sym: &scc_core::Entity, key: &str) -> u32 {
142    sym.attributes
143        .get(key)
144        .and_then(|v| v.as_u64())
145        .unwrap_or(0) as u32
146}
147
148// trace:exempt reason=internal-detail
149fn attr_str<'e>(sym: &'e scc_core::Entity, key: &str) -> Option<&'e str> {
150    sym.attributes.get(key).and_then(|v| v.as_str())
151}
152
153/// Build one symbol's body lines from the trusted view.
154// trace:exempt reason=internal-detail
155fn symbol_body(
156    compiler: &ContextCompiler,
157    sym: &scc_core::Entity,
158) -> Vec<String> {
159    let view = &compiler.view;
160    let sym_start = attr_u32(sym, "start_line");
161    let name_of = |id: &str| view.name_of(id);
162
163    // callee -> control-block kind (CFG evidence, per-callee).
164    let call_blocks: BTreeMap<String, String> = sym
165        .attributes
166        .get(ATTR_CALL_BLOCKS)
167        .and_then(|v| serde_json::from_value(v.clone()).ok())
168        .unwrap_or_default();
169    // callees whose result is consumed — the return evidence.
170    let call_returns: std::collections::BTreeSet<String> = sym
171        .attributes
172        .get(ATTR_CALL_RETURNS)
173        .and_then(|v| serde_json::from_value(v.clone()).ok())
174        .unwrap_or_default();
175
176    // Collect (line, rank, name, rendered) per trusted edge.
177    let mut raw: Vec<(u32, usize, String, String)> = Vec::new();
178    for (rank, (pred, verb)) in EDGE_ORDER.iter().enumerate() {
179        for edge in view.out_pred(&sym.id, pred) {
180            let target = view.entity(&edge.object);
181            let name = name_of(&edge.object);
182            if CONTRACT_VERBS.contains(pred) {
183                let is_contract = target.map(|t| t.kind == kinds::CONTRACT).unwrap_or(false);
184                if !is_contract {
185                    continue;
186                }
187            }
188            let line = evidence_line(compiler, edge, sym_start);
189            if *pred == predicates::CALLS {
190                if is_logging(&name) {
191                    continue;
192                }
193                let is_ctor = target.map(|t| t.kind == "class").unwrap_or(false);
194                let v = if is_ctor { "CONSTRUCT" } else { "CALL" };
195                raw.push((line, rank, name.clone(), format!("{v} {name}")));
196            } else {
197                raw.push((line, rank, name.clone(), format!("{verb} {name}")));
198            }
199        }
200    }
201    raw.sort();
202    // Dedupe consecutive identical rendered lines (same callee at the same
203    // or adjacent evidence sites).
204    let mut dedup: Vec<(u32, usize, String, String)> = Vec::new();
205    for r in raw {
206        if dedup.last().map(|d| d.3 == r.3).unwrap_or(false) {
207            continue;
208        }
209        dedup.push(r);
210    }
211
212    // Emit with control nesting: a callee with a recorded block kind gets a
213    // control line, and its own lines nest one level deeper.
214    let mut out: Vec<String> = Vec::new();
215    for (_, _, name, rendered) in &dedup {
216        let is_call = rendered.starts_with("CALL ") || rendered.starts_with("CONSTRUCT ");
217        if is_call {
218            if let Some(kind) = call_blocks.get(name) {
219                let ctl = format!("  {} {name}", control_verb(kind));
220                if out.last().map(|l| l == &ctl).unwrap_or(false) {
221                    // same control line already open; keep nesting
222                } else {
223                    out.push(ctl);
224                }
225                out.push(format!("    {rendered}"));
226            } else {
227                out.push(format!("  {rendered}"));
228            }
229            if call_returns.contains(name) {
230                let ret = format!("    RETURN {name}");
231                if out.last().map(|l| l == &ret).unwrap_or(false) {
232                    // already emitted
233                } else {
234                    out.push(ret);
235                }
236            }
237        } else {
238            out.push(format!("  {rendered}"));
239        }
240    }
241    // Final consecutive dedupe (e.g. RETURN emitted twice at one site).
242    let mut lines: Vec<String> = Vec::new();
243    for l in out {
244        if lines.last().map(|p| p == &l).unwrap_or(false) {
245            continue;
246        }
247        lines.push(l);
248    }
249    lines
250}
251
252/// The file's symbols sorted by (start_line, name), via CONTAINS edges of
253/// the file entity (kind SYMBOL only).
254// trace:exempt reason=internal-detail
255fn file_symbols<'c>(compiler: &'c ContextCompiler<'_>, file_id: &str) -> Vec<&'c scc_core::Entity> {
256    let view = &compiler.view;
257    let mut syms: Vec<&scc_core::Entity> = view
258        .out_pred(file_id, predicates::CONTAINS)
259        .iter()
260        .filter_map(|r| view.entity(&r.object))
261        .filter(|e| e.kind == kinds::SYMBOL)
262        .collect();
263    syms.sort_by(|a, b| {
264        let la = attr_u32(a, "start_line");
265        let lb = attr_u32(b, "start_line");
266        (la, &a.name).cmp(&(lb, &b.name))
267    });
268    syms
269}
270
271/// Build one file's structural unit (deep or fallback). `None` when the
272/// file is unknown to the trusted view or contains no symbols.
273// trace:exempt reason=internal-detail
274fn build_unit(compiler: &ContextCompiler, path: &str) -> Option<StructuralSourceUnit> {
275    let repo = &compiler.view.graph.repo_id;
276    let file_id = scc_core::entity_id(repo, kinds::FILE, path);
277    let syms = file_symbols(compiler, &file_id);
278    if syms.is_empty() {
279        return None;
280    }
281
282    let deep = syms.iter().any(|s| has_cfg_evidence(s));
283    let (min_line, max_line) = syms.iter().fold((u32::MAX, 0u32), |(mn, mx), s| {
284        let start = attr_u32(s, "start_line");
285        let end = attr_u32(s, "end_line").max(start);
286        (mn.min(start), mx.max(end))
287    });
288    let (min_line, max_line) = if min_line == u32::MAX { (0, 0) } else { (min_line, max_line) };
289
290    let mut content = String::new();
291    if deep {
292        for sym in &syms {
293            if !content.is_empty() {
294                content.push('\n');
295            }
296            let sig = attr_str(sym, "signature")
297                .map(|s| s.to_string())
298                .unwrap_or_else(|| {
299                    let k = attr_str(sym, "kind").unwrap_or(&sym.kind);
300                    format!("{k} {}", sym.name)
301                });
302            content.push_str(&sig);
303            content.push('\n');
304            if let Some(doc) = attr_str(sym, "docstring") {
305                let sentence = first_sentence(doc);
306                if !sentence.is_empty() {
307                    content.push_str("# ");
308                    content.push_str(&sentence);
309                    content.push('\n');
310                }
311            }
312            let body = symbol_body(compiler, sym);
313            if !body.is_empty() {
314                content.push('\n');
315                for l in &body {
316                    content.push_str(l);
317                    content.push('\n');
318                }
319            }
320        }
321    } else {
322        // Fallback: imports + type declarations/signatures.
323        let view = &compiler.view;
324        let mut imports: Vec<(u32, String)> = view
325            .out_pred(&file_id, predicates::IMPORTS)
326            .iter()
327            .map(|r| {
328                let line = evidence_line(compiler, r, 0);
329                let name = view.name_of(&r.object);
330                (line, format!("IMPORT {name}"))
331            })
332            .collect();
333        imports.sort();
334        let mut seen_imports: Vec<String> = Vec::new();
335        for (_, l) in imports {
336            if seen_imports.last().map(|p| p == &l).unwrap_or(false) {
337                continue;
338            }
339            seen_imports.push(l);
340        }
341        for l in seen_imports {
342            content.push_str(&l);
343            content.push('\n');
344        }
345        for sym in &syms {
346            if !content.is_empty() {
347                content.push('\n');
348            }
349            let sig = attr_str(sym, "signature")
350                .map(|s| s.to_string())
351                .unwrap_or_else(|| {
352                    let k = attr_str(sym, "kind").unwrap_or(&sym.kind);
353                    format!("{k} {}", sym.name)
354                });
355            content.push_str(&sig);
356            content.push('\n');
357            if let Some(doc) = attr_str(sym, "docstring") {
358                let sentence = first_sentence(doc);
359                if !sentence.is_empty() {
360                    content.push_str("# ");
361                    content.push_str(&sentence);
362                    content.push('\n');
363                }
364            }
365        }
366    }
367    let content = content.trim_end().to_string();
368    let generated_kind = if deep { STRUCTURAL } else { SIGNATURES };
369    let (content, representation, representation_reason) = apply_representation_policy(
370        &compiler.store.root,
371        path,
372        min_line,
373        max_line,
374        content,
375        generated_kind,
376    );
377    let handle = file_handle(compiler, path);
378
379    Some(StructuralSourceUnit {
380        path: path.to_string(),
381        source: format!("source: {path}:L{min_line}-L{max_line}"),
382        representation,
383        revision: compiler.revision(),
384        content,
385        handle,
386        representation_reason,
387    })
388}
389
390/// Exact-source dominance: never spend more tokens to be clever.
391/// When the file is not on disk (synthetic fixtures), keep the generated
392/// skeleton so tests stay representation-stable.
393// trace:v1 id=impl.scc.structural.exact-dominance work=WORK-ripwire-lessons-phase1 satisfies=REQ-exact-source-dominance
394fn apply_representation_policy(
395    root: &Path,
396    path: &str,
397    min_line: u32,
398    max_line: u32,
399    generated: String,
400    generated_kind: &str,
401) -> (String, String, String) {
402    let Some(exact) = read_exact_span(root, path, min_line, max_line) else {
403        return (generated, generated_kind.to_string(), String::new());
404    };
405    let choice = choose_representation(estimate_tokens(&exact), estimate_tokens(&generated));
406    match choice.kind {
407        RepresentationKind::Exact => (exact, EXACT.to_string(), choice.reason),
408        RepresentationKind::Structural | RepresentationKind::Signatures => {
409            (generated, generated_kind.to_string(), choice.reason)
410        }
411    }
412}
413
414fn read_exact_span(root: &Path, path: &str, start: u32, end: u32) -> Option<String> {
415    if start == 0 {
416        return None;
417    }
418    let text = crate::repo_path::read_repo_text(root, path)?;
419    let mut out = String::new();
420    for (i, line) in text.lines().enumerate() {
421        let n = (i + 1) as u32;
422        if n > end {
423            break;
424        }
425        if n >= start {
426            out.push_str(line);
427            out.push('\n');
428        }
429    }
430    if out.is_empty() {
431        None
432    } else {
433        Some(out.trim_end().to_string())
434    }
435}
436
437// trace:exempt reason=internal-detail
438pub(crate) fn file_handle(compiler: &ContextCompiler, path: &str) -> String {
439    let hash = match crate::repo_path::read_repo_file(&compiler.store.root, path) {
440        Some(bytes) => fnv1a64_hex(&bytes),
441        None => String::new(),
442    };
443    ContentHandle::for_file(
444        &compiler.store.repo_id,
445        &compiler.revision(),
446        path,
447        &hash,
448    )
449    .to_string()
450}
451
452/// Resolve a repository-relative path or an `scc://` handle to a path.
453/// Stale handles refuse; the file is left unread. Never guesses a target.
454// trace:v1 id=impl.scc.structural.resolve-handle work=WORK-ripwire-lessons-phase1 satisfies=REQ-stable-content-handles
455pub fn resolve_handle_to_path(root: &Path, spec: &str) -> Result<String, String> {
456    if !spec.starts_with("scc://") {
457        return Ok(spec.to_string());
458    }
459    let h = ContentHandle::parse(spec).map_err(|e| e.to_string())?;
460    let path = match h.kind {
461        HandleKind::File => h.key.clone(),
462        HandleKind::Symbol => h
463            .key
464            .split_once("::")
465            .map(|(p, _)| p.to_string())
466            .ok_or_else(|| "malformed symbol handle".to_string())?,
467        HandleKind::Span => h
468            .key
469            .split_once(":L")
470            .map(|(p, _)| p.to_string())
471            .ok_or_else(|| "malformed span handle".to_string())?,
472        other => {
473            return Err(format!(
474                "handle kind {} is not a source fetch",
475                other.as_str()
476            ))
477        }
478    };
479    let bytes = crate::repo_path::read_repo_file(root, &path)
480        .ok_or_else(|| format!("handle target missing: {path}"))?;
481    h.refuse_if_stale(&fnv1a64_hex(&bytes))
482        .map_err(|e: HandleError| e.to_string())?;
483    Ok(path)
484}
485
486/// Build structural-source units for the requested paths (deduped, order
487/// preserved), capped at `max_units`. Files unknown to the trusted view or
488/// containing no symbols produce no unit.
489// trace:v1 id=impl.scc.structural_source work=WORK-SCC-014 satisfies=REQ-SCC-IR
490pub fn structural_source(
491    compiler: &ContextCompiler,
492    paths: &[String],
493    max_units: usize,
494) -> Vec<StructuralSourceUnit> {
495    let mut units: Vec<StructuralSourceUnit> = Vec::new();
496    if max_units == 0 {
497        return units;
498    }
499    let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
500    for p in paths {
501        if units.len() >= max_units {
502            break;
503        }
504        if seen.contains(p) {
505            continue;
506        }
507        seen.insert(p.clone());
508        if let Some(u) = build_unit(compiler, p) {
509            units.push(u);
510        }
511    }
512    units
513}
514
515/// Render units as the spec's format:
516///
517/// ```text
518/// <path>
519///
520/// source: <path>:L<min>-L<max>
521/// representation: STRUCTURAL
522/// revision: <rev>
523///
524/// <signature>
525/// # <doc>
526///
527///   CALL worker
528///   WRITE db
529/// ```
530// trace:exempt reason=internal-detail
531pub fn render_structural(units: &[StructuralSourceUnit]) -> String {
532    let mut out = String::new();
533    for (i, u) in units.iter().enumerate() {
534        if i > 0 {
535            out.push('\n');
536        }
537        out.push_str(&u.path);
538        out.push_str("\n\n");
539        out.push_str(&u.source);
540        out.push('\n');
541        out.push_str("representation: ");
542        out.push_str(&u.representation);
543        out.push('\n');
544        if !u.representation_reason.is_empty() {
545            out.push_str("reason: ");
546            out.push_str(&u.representation_reason);
547            out.push('\n');
548        }
549        if !u.handle.is_empty() {
550            out.push_str("handle: ");
551            out.push_str(&u.handle);
552            out.push('\n');
553        }
554        out.push_str("revision: ");
555        out.push_str(&u.revision);
556        out.push_str("\n\n");
557        out.push_str(&u.content);
558        out.push('\n');
559    }
560    out.trim_end().to_string()
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use scc_core::{
567        entity_id, kinds, predicates, relationship_id, symbol_id, Entity, Evidence,
568        EvidenceType, Provenance, Relationship,
569    };
570    use scc_graph::RealityGraph;
571    use scc_store::Store;
572    use std::collections::HashMap;
573
574// trace:exempt reason=internal-detail
575    fn evidence(id: &str, path: &str, line: u32) -> Evidence {
576        let mut e = Evidence::source(id, path);
577        e.start_line = Some(line);
578        e.r#type = EvidenceType::Source;
579        e
580    }
581
582    /// Fixture repo:
583    /// - app.py: `handler` (L10-L14) calls `worker` (L12, inside an `if`
584    ///   block, result returned) and `debug_log` (L13, logging — must be
585    ///   stripped), writes store `db` (L14). CFG attrs present -> deep.
586    /// - lib.py: `reader` (L3-L8) with no call evidence, imports `util`
587    ///   -> fallback (signature-only).
588// trace:exempt reason=internal-detail
589    fn fixture() -> (tempfile::TempDir, Store, RealityGraph) {
590        let dir = tempfile::TempDir::new().unwrap();
591        let root = dir.path().join("repo");
592        std::fs::create_dir_all(&root).unwrap();
593        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
594        let repo = "repo";
595        store.upsert_file("app.py", "h1", "python", "source", 20).unwrap();
596        store.upsert_file("lib.py", "h2", "python", "source", 20).unwrap();
597
598        let file_id = entity_id(repo, kinds::FILE, "app.py");
599        let lib_id = entity_id(repo, kinds::FILE, "lib.py");
600        let mk = |n: &str| symbol_id(repo, "app.py", n);
601        let db_id = entity_id(repo, kinds::DATA_STORE, "db");
602
603        let mut entities: HashMap<String, Entity> = HashMap::new();
604
605        // handler symbol with CFG evidence
606        let mut handler = Entity::new(mk("handler"), kinds::SYMBOL, "handler");
607        handler.attr("file", serde_json::json!("app.py"));
608        handler.attr("signature", serde_json::json!("def handler(x: str) -> str:"));
609        handler.attr("start_line", serde_json::json!(10));
610        handler.attr("end_line", serde_json::json!(14));
611        handler.attr(
612            "docstring",
613            serde_json::json!("Normalizes a raw transcript. Deprecated: use normalize_v2."),
614        );
615        handler.attr("call_order", serde_json::json!({"worker": 0, "debug_log": 1}));
616        handler.attr("call_blocks", serde_json::json!({"worker": "if"}));
617        handler.attr("call_returns", serde_json::json!(["worker"]));
618        entities.insert(handler.id.clone(), handler);
619
620        // helper symbols
621        for n in ["worker", "debug_log"] {
622            let mut s = Entity::new(mk(n), kinds::SYMBOL, n);
623            s.attr("file", serde_json::json!("app.py"));
624            s.attr("signature", serde_json::json!(format!("def {n}(y):")));
625            s.attr("start_line", serde_json::json!(20));
626            s.attr("end_line", serde_json::json!(21));
627            entities.insert(s.id.clone(), s);
628        }
629
630        // store entity
631        let mut de = Entity::new(db_id.clone(), kinds::DATA_STORE, "db");
632        de.attr("technology", serde_json::json!("postgres"));
633        entities.insert(de.id.clone(), de);
634
635        // lib.py reader (no CFG evidence)
636        let reader_id = symbol_id(repo, "lib.py", "reader");
637        let mut reader = Entity::new(reader_id.clone(), kinds::SYMBOL, "reader");
638        reader.attr("file", serde_json::json!("lib.py"));
639        reader.attr("signature", serde_json::json!("def reader(path: str) -> list:"));
640        reader.attr("start_line", serde_json::json!(3));
641        reader.attr("end_line", serde_json::json!(8));
642        reader.attr("docstring", serde_json::json!("Reads lines from a file."));
643        entities.insert(reader.id.clone(), reader);
644
645        // util external module for lib.py imports
646        let util_id = entity_id(repo, kinds::EXTERNAL_API, "util");
647        entities.insert(
648            util_id.clone(),
649            Entity::new(util_id.clone(), kinds::EXTERNAL_API, "util"),
650        );
651
652        let mut out: HashMap<String, Vec<Relationship>> = HashMap::new();
653        let mut inn: HashMap<String, Vec<Relationship>> = HashMap::new();
654        let mut n = 0u64;
655        let mut rel = |s: String,
656                       pred: &str,
657                       o: String,
658                       ev: Vec<String>,
659                       inn: &mut HashMap<String, Vec<Relationship>>| {
660            n += 1;
661            let r = Relationship::new(
662                relationship_id(n),
663                s.clone(),
664                pred,
665                o,
666                Provenance::Extracted,
667            )
668            .with_evidence(ev);
669            out.entry(s.clone()).or_default().push(r.clone());
670            inn.entry(r.object.clone()).or_default().push(r);
671        };
672
673        // evidence rows (in store so the compiler's evidence map has lines)
674        store.insert_evidence(&evidence("evidence:10", "app.py", 12)).unwrap();
675        store.insert_evidence(&evidence("evidence:11", "app.py", 13)).unwrap();
676        store.insert_evidence(&evidence("evidence:12", "app.py", 14)).unwrap();
677        store.insert_evidence(&evidence("evidence:13", "app.py", 12)).unwrap();
678        store.insert_evidence(&evidence("evidence:20", "lib.py", 1)).unwrap();
679        let sev = Evidence::source("evidence:21", "app.py");
680        store.insert_evidence(&sev).unwrap();
681
682        // app.py: file contains handler
683        rel(
684            file_id.clone(),
685            predicates::CONTAINS,
686            mk("handler"),
687            vec!["evidence:21".to_string()],
688            &mut inn,
689        );
690        // handler CALLS worker (two sites at L12 -> dedupe) + CALLS debug_log (L13)
691        rel(
692            mk("handler"),
693            predicates::CALLS,
694            mk("worker"),
695            vec!["evidence:10".to_string()],
696            &mut inn,
697        );
698        rel(
699            mk("handler"),
700            predicates::CALLS,
701            mk("worker"),
702            vec!["evidence:13".to_string()],
703            &mut inn,
704        );
705        rel(
706            mk("handler"),
707            predicates::CALLS,
708            mk("debug_log"),
709            vec!["evidence:11".to_string()],
710            &mut inn,
711        );
712        // handler WRITES db (L14)
713        rel(
714            mk("handler"),
715            predicates::WRITES,
716            db_id.clone(),
717            vec!["evidence:12".to_string()],
718            &mut inn,
719        );
720        // lib.py: file contains reader + file imports util
721        rel(
722            lib_id.clone(),
723            predicates::CONTAINS,
724            reader_id,
725            vec!["evidence:20".to_string()],
726            &mut inn,
727        );
728        rel(
729            lib_id.clone(),
730            predicates::IMPORTS,
731            util_id,
732            vec!["evidence:20".to_string()],
733            &mut inn,
734        );
735
736        let graph = RealityGraph {
737            repo_id: repo.to_string(),
738            entities,
739            out,
740            inn,
741            components: vec![],
742            flows: vec![],
743            invariants: vec![],
744        };
745        (dir, store, graph)
746    }
747
748// trace:exempt reason=internal-detail
749    fn compiler<'a>(
750        store: &'a Store,
751        graph: &'a RealityGraph,
752    ) -> ContextCompiler<'a> {
753        ContextCompiler::new(
754            store,
755            graph,
756            crate::ContextSettings::default(),
757            Vec::new(),
758        )
759    }
760
761    #[test]
762// trace:exempt reason=internal-detail
763    fn structural_unit_has_call_write_return_in_evidence_order() {
764        let (_d, store, graph) = fixture();
765        let c = compiler(&store, &graph);
766        let units = structural_source(&c, &["app.py".to_string()], 10);
767
768        assert_eq!(units.len(), 1, "one unit for app.py");
769        let u = &units[0];
770        assert_eq!(u.path, "app.py");
771        // provenance header
772        assert_eq!(u.source, "source: app.py:L10-L14");
773        assert_eq!(u.representation, "STRUCTURAL");
774        assert_eq!(u.revision, c.revision());
775
776        let content = &u.content;
777        // signature + doc (first sentence only)
778        assert!(content.contains("def handler(x: str) -> str:"), "{content}");
779        assert!(content.contains("# Normalizes a raw transcript"), "{content}");
780        assert!(!content.contains("Deprecated"), "{content}");
781        // control skeleton from call_blocks
782        assert!(content.contains("  IF worker"), "{content}");
783        assert!(content.contains("    CALL worker"), "{content}");
784        assert!(content.contains("    RETURN worker"), "{content}");
785        assert!(content.contains("  WRITE db"), "{content}");
786        // logging calls stripped
787        assert!(!content.contains("debug_log"), "{content}");
788        // dedupe: two CALLS worker sites collapse to one line
789        assert_eq!(content.matches("CALL worker").count(), 1, "{content}");
790        // evidence order: call (L12) before write (L14)
791        let call_i = content.find("CALL worker").unwrap();
792        let write_i = content.find("WRITE db").unwrap();
793        let ctl_i = content.find("IF worker").unwrap();
794        assert!(ctl_i < call_i && call_i < write_i, "{content}");
795    }
796
797    #[test]
798// trace:exempt reason=internal-detail
799    fn fallback_produces_signatures_for_file_without_cfg_evidence() {
800        let (_d, store, graph) = fixture();
801        let c = compiler(&store, &graph);
802        let units = structural_source(&c, &["lib.py".to_string()], 10);
803
804        assert_eq!(units.len(), 1);
805        let u = &units[0];
806        assert_eq!(u.path, "lib.py");
807        assert_eq!(u.source, "source: lib.py:L3-L8");
808        assert_eq!(u.representation, "SIGNATURES");
809        assert!(u.content.contains("IMPORT util"), "{}", u.content);
810        assert!(u.content.contains("def reader(path: str) -> list:"), "{}", u.content);
811        assert!(u.content.contains("# Reads lines from a file."), "{}", u.content);
812        // no body skeleton in the fallback
813        assert!(!u.content.contains("CALL "), "{}", u.content);
814        assert!(!u.content.contains("  WRITE"), "{}", u.content);
815    }
816
817    #[test]
818// trace:exempt reason=internal-detail
819    fn render_structural_emits_provenance_header_per_unit() {
820        let (_d, store, graph) = fixture();
821        let c = compiler(&store, &graph);
822        let units = structural_source(
823            &c,
824            &["app.py".to_string(), "lib.py".to_string()],
825            10,
826        );
827        assert_eq!(units.len(), 2);
828        let rendered = render_structural(&units);
829        assert!(rendered.contains("app.py\n\nsource: app.py:L10-L14"), "{rendered}");
830        assert!(rendered.contains("representation: STRUCTURAL"), "{rendered}");
831        assert!(rendered.contains("lib.py\n\nsource: lib.py:L3-L8"), "{rendered}");
832        assert!(rendered.contains("representation: SIGNATURES"), "{rendered}");
833        assert!(rendered.contains("revision: "), "{rendered}");
834        // body lines survive rendering
835        assert!(rendered.contains("    CALL worker"), "{rendered}");
836        assert!(rendered.contains("  WRITE db"), "{rendered}");
837    }
838
839    #[test]
840// trace:exempt reason=internal-detail
841    fn unknown_and_duplicate_paths_are_handled_deterministically() {
842        let (_d, store, graph) = fixture();
843        let c = compiler(&store, &graph);
844        // unknown path (not in store) -> skipped; duplicate path -> once;
845        // max_units caps.
846        let units = structural_source(
847            &c,
848            &[
849                "nope.py".to_string(),
850                "app.py".to_string(),
851                "app.py".to_string(),
852                "lib.py".to_string(),
853            ],
854            1,
855        );
856        assert_eq!(units.len(), 1);
857        assert_eq!(units[0].path, "app.py");
858        let units = structural_source(&c, &["app.py".to_string()], 0);
859        assert!(units.is_empty());
860    }
861
862    #[test]
863    // trace:v1 id=test.scc.structural.exact-dominance verifies=REQ-exact-source-dominance exercises=impl.scc.structural.exact-dominance
864    fn exact_wins_when_on_disk_body_is_cheaper() {
865        let (_d, store, graph) = fixture();
866        let mut body = String::new();
867        for _ in 1..10 {
868            body.push('\n');
869        }
870        body.push_str("def handler(x: str) -> str:\n    return worker()\n");
871        std::fs::write(store.root.join("app.py"), body).unwrap();
872        let c = compiler(&store, &graph);
873        let units = structural_source(&c, &["app.py".to_string()], 10);
874        assert_eq!(units.len(), 1);
875        assert_eq!(units[0].representation, "EXACT");
876        assert_eq!(units[0].representation_reason, "exact_body_cheaper_than_structural");
877        assert!(units[0].content.contains("def handler"), "{}", units[0].content);
878        assert!(!units[0].handle.is_empty(), "handle must be stamped when file exists");
879    }
880
881    #[test]
882    // trace:v1 id=test.scc.structural.handle-stale verifies=REQ-stable-content-handles exercises=impl.scc.structural.resolve-handle
883    fn stale_handle_refuses_without_guessing() {
884        let dir = tempfile::TempDir::new().unwrap();
885        let root = dir.path();
886        std::fs::write(root.join("a.py"), "print(1)\n").unwrap();
887        let fresh = ContentHandle::for_file("r", "e", "a.py", &fnv1a64_hex(b"print(1)\n"));
888        assert_eq!(
889            resolve_handle_to_path(root, &fresh.to_string()).unwrap(),
890            "a.py"
891        );
892        let stale = ContentHandle::for_file("r", "e", "a.py", "aaaaaaaaaaaaaaaa");
893        let err = resolve_handle_to_path(root, &stale.to_string()).unwrap_err();
894        assert!(err.contains("stale"), "{err}");
895        assert_eq!(resolve_handle_to_path(root, "a.py").unwrap(), "a.py");
896    }
897
898    #[test]
899    // trace:v1 id=test.scc.structural.empty-handle-hash verifies=REQ-stable-content-handles,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no exercises=impl.scc.structural.resolve-handle
900    fn empty_file_handle_is_not_a_wildcard() {
901        let (_d, store, graph) = fixture();
902        std::fs::write(store.root.join("empty.py"), b"").unwrap();
903        let c = compiler(&store, &graph);
904        let rendered = file_handle(&c, "empty.py");
905        let parsed = ContentHandle::parse(&rendered).expect("handle");
906        let empty_hash = fnv1a64_hex(b"");
907        assert_eq!(parsed.content_hash, empty_hash);
908        assert!(!parsed.content_hash.is_empty());
909        assert!(parsed.matches_content(&empty_hash));
910        assert!(!parsed.matches_content(&fnv1a64_hex(b"later\n")));
911    }
912}