Skip to main content

scc_graph/
components.rs

1//! Component Compiler (EPIC-040, docs/SYSTEM_DESIGN.md §7).
2//!
3//! Deterministic MVP signals:
4//! 1. explicit intent (`.scc/intent.yaml` components with `paths`)
5//! 2. package.json workspace members
6//! 3. docker-compose service build contexts
7//! 4. top-level directory boundaries
8//!
9//! Each component aggregates: responsibilities (routes owned, docstrings
10//! INFERRED, intent DECLARED), ownership (store write edges), dependency
11//! edges (cross-component calls), and implementation (paths/symbols).
12
13use crate::{RealityGraph, Result};
14use scc_core::kinds;
15use scc_core::{entity_id, Provenance, Relationship};
16use scc_store::Store;
17use serde_json::json;
18use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
19
20pub const RELPREFIX: &str = "rel:comp:";
21
22/// Evidence class that created a component candidate (Wave 5, plan §27-30).
23/// The `boundary_kind` attribute records it on every compiled component;
24/// `code-region` is the bare top-level directory fallback and is never
25/// authoritative architecture.
26pub const BOUNDARY_DECLARED: &str = "declared";
27pub const BOUNDARY_PACKAGE: &str = "package";
28pub const BOUNDARY_DEPLOYMENT: &str = "deployment";
29pub const BOUNDARY_CLI: &str = "cli";
30pub const BOUNDARY_CODE_REGION: &str = "code-region";
31pub const BOUNDARY_ROOT: &str = "root";
32
33// ---- hierarchical architecture layers (Ontology phase) ----
34pub const LAYER_CODE_REGION: &str = "code_region";
35pub const LAYER_COMPONENT: &str = "component";
36pub const LAYER_SUBSYSTEM: &str = "subsystem";
37pub const LAYER_SERVICE: &str = "service";
38
39/// Greedy merge threshold for the first (subsystem) pass.
40pub const MERGE_THRESHOLD: i32 = 6;
41/// Greedy merge threshold for the second (service) pass.
42pub const SERVICE_THRESHOLD: i32 = 12;
43
44/// Authority order for `boundary_kind` when one candidate is created by
45/// several sources: declared intent > deployment units > workspace
46/// packages > directory fallback. Deterministic (fixed precedence).
47pub(crate) fn boundary_rank(kind: &str) -> u8 {
48    match kind {
49        BOUNDARY_DECLARED => 3,
50        BOUNDARY_DEPLOYMENT => 2,
51        BOUNDARY_CLI => 2,
52        BOUNDARY_PACKAGE => 1,
53        _ => 0,
54    }
55}
56
57pub fn rel(parts: &[&str]) -> String {
58    let mut h = blake3::Hasher::new();
59    for p in parts {
60        h.update(p.as_bytes());
61        h.update(b"|");
62    }
63    format!("{RELPREFIX}{}", &h.finalize().to_hex()[..12])
64}
65
66pub fn parse_prov(s: &str) -> Provenance {
67    match s {
68        "RESOLVED" => Provenance::Resolved,
69        "EXTRACTED" => Provenance::Extracted,
70        "DECLARED" => Provenance::Declared,
71        "OBSERVED" => Provenance::Observed,
72        "INFERRED" => Provenance::Inferred,
73        _ => Provenance::Inferred,
74    }
75}
76
77pub fn prov_rank(p: Provenance) -> u8 {
78    match p {
79        Provenance::Resolved => 4,
80        Provenance::Observed => 4,
81        Provenance::Extracted => 3,
82        Provenance::Declared => 2,
83        Provenance::Inferred => 1,
84        Provenance::Stale => 0,
85    }
86}
87
88/// Repository role of a component from its implementation paths, by generic
89/// directory convention (never repository names): test, fixture, benchmark,
90/// example, and docs trees are structure, not production architecture.
91/// A single non-production role when every path shares it, `mixed` when
92/// production and non-production mix, `production` otherwise. Deterministic.
93/// Repository role of ONE path by generic directory convention (never
94/// repository names). `None` means no non-production segment matched — the
95/// caller maps that to `production`. Shared by component aggregation,
96/// skeleton labeling, and atlas scope filtering so all three agree on
97/// what "fixture" (etc.) means.
98// trace:exempt reason=internal-detail
99pub fn path_role(path: &str) -> Option<&'static str> {
100    // Depth guard for the examples family: generic English words are only
101    // reliable as LEADING layout signals (`examples/`, `crate/examples/`).
102    // Deeper occurrences are overwhelmingly package namespaces — notably
103    // Java's conventional `com.example` / `org.example` placeholder
104    // packages (RFC 2606), which must never classify production sources as
105    // examples. Technical markers (test, fixture, benchmark, docs, …) match
106    // at any depth: they rarely appear as namespace words, and `src/test`
107    // style nesting must keep matching.
108    for (depth, seg) in path.split('/').enumerate() {
109        match seg.to_ascii_lowercase().as_str() {
110            "tests" | "test" | "testing" | "__tests__" | "spec" | "specs" | "e2e" => {
111                return Some("test")
112            }
113            "fixtures" | "fixture" | "testdata" | "test-data" => return Some("fixture"),
114            "benchmarks" | "benchmark" | "benches" | "bench" => return Some("benchmark"),
115            "examples" | "example" | "samples" | "demo" => {
116                if depth <= 1 {
117                    return Some("example");
118                }
119            }
120            "docs" | "doc" => return Some("docs"),
121            "config" | "conf" | "configuration" | "workflows" => return Some("config"),
122            "generated" | "__generated__" => return Some("generated"),
123            "vendor" | "third_party" | "third-party" | "node_modules" => return Some("vendor"),
124            "target" | "dist" | "build" | "out" | "coverage" => return Some("build"),
125            "tools" | "tooling" | "devtools" => return Some("tooling"),
126            "sdk" => return Some("sdk"),
127            _ => {}
128        }
129    }
130    None
131}
132
133/// True when no path segment places the path outside production.
134// trace:exempt reason=internal-detail
135pub fn is_production_path(path: &str) -> bool {
136    path_role(path).is_none()
137}
138
139// trace:v1 id=impl.scc.components.role work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-NX53P4B7
140pub fn component_role(paths: &[String]) -> &'static str {
141    let mut roles = std::collections::BTreeSet::new();
142    for p in paths {
143        if let Some(r) = path_role(p) {
144            roles.insert(r);
145        }
146    }
147    if roles.is_empty() {
148        return "production";
149    }
150    let all_covered = paths.iter().all(|p| path_role(p).is_some());
151    if roles.len() == 1 && all_covered {
152        return roles.into_iter().next().unwrap_or("production");
153    }
154    "mixed"
155}
156
157#[derive(Debug, Clone)]
158// trace:exempt reason=internal-detail
159pub struct ComponentCandidate {
160    pub name: String,
161    pub dirs: Vec<String>,
162    /// Evidence class that created this candidate (one of the
163    /// `BOUNDARY_*` constants).
164    pub boundary_kind: String,
165    /// Declared intent name (`.scc/intent.yaml` component) when this
166    /// candidate derives from one — propagated through region splits so
167    /// a declared component keeps its name even when the clusterer
168    /// splits its directory (declared intent names architecture;
169    /// clustering decides membership).
170    pub intent: Option<String>,
171}
172
173// trace:exempt reason=internal-detail
174impl ComponentCandidate {
175// trace:exempt reason=internal-detail
176    pub fn new(name: impl Into<String>, dirs: Vec<String>, boundary_kind: impl Into<String>) -> Self {
177        ComponentCandidate {
178            name: name.into(),
179            dirs,
180            boundary_kind: boundary_kind.into(),
181            intent: None,
182        }
183    }
184}
185
186/// Determine the component for a path: longest matching dir prefix wins.
187/// Returns a `String` (candidate names are owned).
188pub fn component_for_path(path: &str, candidates: &[ComponentCandidate]) -> String {
189    let mut best: Option<(String, usize)> = None;
190    for c in candidates {
191        for d in &c.dirs {
192            let d = d.trim_end_matches('/');
193            if d.is_empty() {
194                continue;
195            }
196            if path == d || path.starts_with(&format!("{d}/")) {
197                let len = d.len();
198                if best.as_ref().map(|(_, bl)| len > *bl).unwrap_or(true) {
199                    best = Some((c.name.clone(), len));
200                }
201            }
202        }
203    }
204    match best {
205        Some((name, _)) => name,
206        None => {
207            // root-level file -> "root" component; nested -> first segment
208            if path.contains('/') {
209                let seg = path.split('/').next().unwrap_or("root");
210                if seg == ".scc" {
211                    "root".to_string()
212                } else {
213                    seg.to_string()
214                }
215            } else {
216                "root".to_string()
217            }
218        }
219    }
220}
221
222/// Normalize a manifest-declared directory: strip a leading `./` and any
223/// trailing slashes. Deterministic.
224fn norm_manifest_dir(p: &str) -> String {
225    let t = p.trim();
226    let t = t.strip_prefix("./").unwrap_or(t);
227    t.trim_end_matches('/').to_string()
228}
229
230/// Collect the `"..."` string values of a (possibly multi-line) TOML array
231/// assigned to `key` — e.g. `members = [\n "a",\n "b",\n]`. Stops at the
232/// first `]` that is not inside a string literal. `key` must be a bare
233/// token (`exclude_members` must not match `members`).
234fn toml_string_array(table: &str, key: &str) -> Vec<String> {
235    let mut out: Vec<String> = Vec::new();
236    let mut rest = table;
237    while let Some(pos) = rest.find(key) {
238        let before_ok = pos == 0
239            || !rest[..pos]
240                .chars()
241                .last()
242                .map(|c| c.is_alphanumeric() || c == '_')
243                .unwrap_or(false);
244        let after_trim = rest[pos + key.len()..].trim_start();
245        if before_ok && after_trim.starts_with('=') {
246            let mut scan = &after_trim[1..];
247            loop {
248                let open = scan.find('"');
249                let close = scan.find(']');
250                match (open, close) {
251                    (Some(q), Some(c)) if q < c => match scan[q + 1..].find('"') {
252                        Some(e) => {
253                            out.push(scan[q + 1..q + 1 + e].to_string());
254                            scan = &scan[q + 1 + e + 1..];
255                        }
256                        None => break,
257                    },
258                    _ => break, // ']' first (or unterminated string): done
259                }
260            }
261            break;
262        }
263        rest = &rest[pos + key.len()..];
264    }
265    out
266}
267
268/// Return the text of a TOML table (from its header line up to the next
269/// top-level header or EOF). `header` must match exactly, e.g.
270/// `[workspace]` (so `[workspace.dependencies]` is not a false match).
271fn toml_table(text: &str, header: &str) -> Option<String> {
272    let mut start: Option<usize> = None;
273    let mut end = text.lines().count();
274    for (i, line) in text.lines().enumerate() {
275        let l = line.trim();
276        if start.is_none() {
277            if l == header {
278                start = Some(i);
279            }
280            continue;
281        }
282        if l.starts_with('[') && l.ends_with(']') {
283            end = i;
284            break;
285        }
286    }
287    let start = start?;
288    Some(
289        text.lines()
290            .skip(start)
291            .take(end.saturating_sub(start))
292            .collect::<Vec<_>>()
293            .join("\n"),
294    )
295}
296
297/// `[package] name = "..."` from a crate manifest (only inside the
298/// `[package]` table).
299fn toml_package_name(text: &str) -> Option<String> {
300    let table = toml_table(text, "[package]")?;
301    for line in table.lines() {
302        let l = line.trim();
303        if let Some(eq) = l.find('=') {
304            if l[..eq].trim() == "name" {
305                let v = l[eq + 1..].trim();
306                if let Some(s) = v.strip_prefix('"') {
307                    if let Some(e) = s.find('"') {
308                        return Some(s[..e].to_string());
309                    }
310                }
311            }
312        }
313    }
314    None
315}
316
317/// go.work `use` directives (block form `use ( ... )` and inline form
318/// `use ./path`) as normalized module directories.
319fn gowork_use_dirs(text: &str) -> Vec<String> {
320    let mut out: Vec<String> = Vec::new();
321    let lines: Vec<&str> = text.lines().map(str::trim).collect();
322    let mut i = 0;
323    while i < lines.len() {
324        let toks: Vec<&str> = lines[i].split_whitespace().collect();
325        if toks.first() == Some(&"use") {
326            if toks.len() >= 2 && toks[1].starts_with('(') {
327                // `use ( ./a ./b )` on one line, or `use (` + block
328                for t in &toks[1..] {
329                    let t = t.trim_matches(|c| c == '(' || c == ')');
330                    if !t.is_empty() {
331                        out.push(norm_manifest_dir(t));
332                    }
333                }
334                i += 1;
335                while i < lines.len() && lines[i] != ")" {
336                    let l = lines[i];
337                    if !l.is_empty() && !l.starts_with("//") {
338                        out.push(norm_manifest_dir(l));
339                    }
340                    i += 1;
341                }
342            } else if toks.len() >= 2 {
343                out.push(norm_manifest_dir(toks[1]));
344            } else {
345                // bare `use` + block on following lines
346                i += 1;
347                while i < lines.len() && lines[i] != ")" {
348                    let l = lines[i];
349                    if !l.is_empty() && !l.starts_with("//") {
350                        out.push(norm_manifest_dir(l));
351                    }
352                    i += 1;
353                }
354            }
355        }
356        i += 1;
357    }
358    out
359}
360
361/// Resolve a cargo package's name from its own Cargo.toml `[package] name`;
362/// falls back to the directory name when the manifest is missing or
363/// unparseable.
364fn crate_name(root: &std::path::Path, dir: &str) -> String {
365    if let Ok(text) = std::fs::read_to_string(root.join(dir).join("Cargo.toml")) {
366        if let Some(n) = toml_package_name(&text) {
367            if !n.is_empty() {
368                return n;
369            }
370        }
371    }
372    dir.rsplit('/').next().unwrap_or(dir).to_string()
373}
374
375/// Cargo workspace members from the root `Cargo.toml` `[workspace]` table:
376/// `members = [...]` (expanding `dir/*` globs against the filesystem,
377/// sorted) minus `exclude = [...]`. Returns (crate name, member dir) pairs.
378fn cargo_workspace_members(root: &std::path::Path) -> Vec<(String, String)> {
379    let text = match std::fs::read_to_string(root.join("Cargo.toml")) {
380        Ok(t) => t,
381        Err(_) => return Vec::new(),
382    };
383    let Some(ws) = toml_table(&text, "[workspace]") else {
384        return Vec::new();
385    };
386    let members = toml_string_array(&ws, "members");
387    if members.is_empty() {
388        return Vec::new();
389    }
390    let excludes: BTreeSet<String> =
391        toml_string_array(&ws, "exclude").into_iter().map(|e| norm_manifest_dir(&e)).collect();
392    let mut out: Vec<(String, String)> = Vec::new();
393    for m in members {
394        let m = norm_manifest_dir(&m);
395        if m.is_empty() || m == "." || excludes.contains(&m) {
396            continue;
397        }
398        if let Some(glob_dir) = m.strip_suffix("/*") {
399            let base = root.join(glob_dir);
400            let mut dirs: Vec<String> = std::fs::read_dir(&base)
401                .map(|rd| {
402                    rd.filter_map(|e| e.ok())
403                        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
404                        .filter_map(|e| e.file_name().into_string().ok())
405                        .filter(|n| !n.starts_with('.'))
406                        .filter(|n| {
407                            root.join(format!("{glob_dir}/{n}")).join("Cargo.toml").is_file()
408                        })
409                        .collect()
410                })
411                .unwrap_or_default();
412            dirs.sort();
413            for d in dirs {
414                let dir = format!("{glob_dir}/{d}");
415                if excludes.contains(&dir) {
416                    continue;
417                }
418                out.push((crate_name(root, &dir), dir));
419            }
420        } else if root.join(&m).is_dir() {
421            out.push((crate_name(root, &m), m));
422        }
423        // stale (missing) members are skipped deterministically
424    }
425    out
426}
427
428// trace:exempt reason=internal-detail
429fn merge_manifest_candidate(out: &mut Vec<ComponentCandidate>, name: String, dir: String) {
430    if let Some(c) = out.iter_mut().find(|c| c.name == name) {
431        if !c.dirs.contains(&dir) {
432            c.dirs.push(dir);
433        }
434        if boundary_rank(BOUNDARY_PACKAGE) > boundary_rank(&c.boundary_kind) {
435            c.boundary_kind = BOUNDARY_PACKAGE.to_string();
436        }
437    } else {
438        out.push(ComponentCandidate {
439            name,
440            dirs: vec![dir],
441            boundary_kind: BOUNDARY_PACKAGE.to_string(), intent: None,
442        });
443    }
444}
445
446/// Workspace-member component candidates from manifests the config
447/// extractors do not model: Cargo.toml `[workspace] members` (with
448/// `[package] name` for crate names) and go.work `use (...)` modules.
449/// File-based and deterministic; member dirs later out-prefix the top-dir
450/// fallback via longest-prefix matching in [`component_for_path`].
451fn manifest_package_candidates(root: &std::path::Path) -> Vec<ComponentCandidate> {
452    let mut out: Vec<ComponentCandidate> = Vec::new();
453    for (name, dir) in cargo_workspace_members(root) {
454        merge_manifest_candidate(&mut out, name, dir);
455    }
456    for dir in gowork_use_dirs(&std::fs::read_to_string(root.join("go.work")).unwrap_or_default()) {
457        if dir.is_empty() || dir == "." || !root.join(&dir).is_dir() {
458            continue; // stale module: deterministic skip
459        }
460        let name = dir.rsplit('/').next().unwrap_or(&dir).to_string();
461        merge_manifest_candidate(&mut out, name, dir);
462    }
463    out
464}
465
466/// Directories holding CLI registrations (clap/cobra/argparse/click):
467/// the parent dir of every file with `cli-subcommand` entrypoints and of
468/// every file whose symbols carry `cli-subcommand` entrypoints or
469/// `cli_flags`. Each such directory becomes its own `boundary_kind=cli`
470/// component (the CLI package), so the clusterer never folds it into a
471/// generic top-level code-region component. Root-level registration files
472/// map to the `root` dir. Deterministic (BTreeSet, sorted).
473fn cli_package_dirs(graph: &RealityGraph) -> BTreeSet<String> {
474    let mut dirs: BTreeSet<String> = BTreeSet::new();
475    let note_file = |fname: &str, dirs: &mut BTreeSet<String>| {
476        let dir = match fname.rsplit_once('/') {
477            Some((parent, _)) if !parent.is_empty() => parent.to_string(),
478            _ => "root".to_string(),
479        };
480        dirs.insert(dir);
481    };
482    // file-level cli entrypoints (no symbol, e.g. clap Subcommand enum or
483    // cobra registrations landing on the file entity)
484    for f in graph.entities_of_kind(kinds::FILE) {
485        let has_cli = f
486            .attributes
487            .get("entrypoints")
488            .and_then(|v| v.as_array())
489            .map(|eps| eps.iter().any(|e| e.as_str() == Some("cli-subcommand")))
490            .unwrap_or(false);
491        if has_cli {
492            note_file(&f.name, &mut dirs);
493        }
494    }
495    // symbol-level cli evidence: `cli-subcommand` entrypoints or `cli_flags`
496    for e in graph.entities_of_kind(kinds::SYMBOL) {
497        let is_cli = e
498            .attributes
499            .get("entrypoints")
500            .and_then(|v| v.as_array())
501            .map(|eps| eps.iter().any(|e| e.as_str() == Some("cli-subcommand")))
502            .unwrap_or(false)
503            || e
504                .attributes
505                .get("cli_flags")
506                .and_then(|v| v.as_array())
507                .map(|fl| !fl.is_empty())
508                .unwrap_or(false);
509        if is_cli {
510            if let Some(file) = e.attributes.get("file").and_then(|v| v.as_str()) {
511                note_file(file, &mut dirs);
512            }
513        }
514    }
515    dirs
516}
517
518/// Build the component candidates from evidence (declared intent, workspace
519/// packages, deployment-unit build contexts, CLI registration dirs, and the
520/// bare top-level directory fallback). Shared by the component compiler and
521/// the semantic clusterer so both see the same boundary priors.
522// trace:v1 id=impl.scc.components work=WORK-SCC-001 satisfies=REQ-SCC-IR
523pub(crate) fn build_candidates(
524    graph: &RealityGraph,
525    store: &Store,
526    intent: &[(String, serde_json::Value)],
527) -> Vec<ComponentCandidate> {
528    // declared first so they win authority
529    let mut candidates: Vec<ComponentCandidate> = Vec::new();
530    let mut declared_names: HashSet<String> = HashSet::new();
531    for (source, claim) in intent {
532        if source == "component" {
533            let name = claim["name"].as_str().unwrap_or("").to_string();
534            if name.is_empty() {
535                continue;
536            }
537            declared_names.insert(name.clone());
538            let mut dirs: Vec<String> = Vec::new();
539            if let Some(paths) = claim["paths"].as_array() {
540                for p in paths {
541                    if let Some(s) = p.as_str() {
542                        dirs.push(s.to_string());
543                    }
544                }
545            }
546            dirs.push(name.clone()); // implicit: declared name == directory
547            candidates.push(ComponentCandidate {
548                name: name.clone(),
549                dirs,
550                boundary_kind: BOUNDARY_DECLARED.to_string(),
551                intent: Some(name),
552            });
553        }
554    }
555    // workspace packages
556    for pkg in graph.entities_of_kind(kinds::PACKAGE) {
557        if let Some(path) = pkg.attributes.get("path").and_then(|v| v.as_str()) {
558            let name = pkg.name.clone();
559            if let Some(c) = candidates.iter_mut().find(|c| c.name == name) {
560                if !c.dirs.contains(&path.to_string()) {
561                    c.dirs.push(path.to_string());
562                }
563                if boundary_rank(BOUNDARY_PACKAGE) > boundary_rank(&c.boundary_kind) {
564                    c.boundary_kind = BOUNDARY_PACKAGE.to_string();
565                }
566            } else {
567                candidates.push(ComponentCandidate {
568                    name,
569                    dirs: vec![path.to_string()],
570                    boundary_kind: BOUNDARY_PACKAGE.to_string(), intent: None,
571                });
572            }
573        }
574    }
575    // workspace members from manifests the config extractors don't model
576    // (Cargo.toml `[workspace] members`, go.work `use (...)`): file-based,
577    // deterministic; member dirs win over the top-dir fallback below via
578    // longest-prefix matching in `component_for_path`.
579    for cand in manifest_package_candidates(&store.root) {
580        for dir in cand.dirs {
581            merge_manifest_candidate(&mut candidates, cand.name.clone(), dir);
582        }
583    }
584    // deployment units with build contexts
585    for du in graph.entities_of_kind(kinds::DEPLOYMENT_UNIT) {
586        if let Some(ctx) = du.attributes.get("build_context").and_then(|v| v.as_str()) {
587            if ctx == "." || ctx == "./" {
588                continue;
589            }
590            let ctx = ctx.trim_start_matches("./");
591            let name = du.name.clone();
592            if let Some(c) = candidates.iter_mut().find(|c| c.name == name) {
593                if !c.dirs.contains(&ctx.to_string()) {
594                    c.dirs.push(ctx.to_string());
595                }
596                if boundary_rank(BOUNDARY_DEPLOYMENT) > boundary_rank(&c.boundary_kind) {
597                    c.boundary_kind = BOUNDARY_DEPLOYMENT.to_string();
598                }
599            } else {
600                candidates.push(ComponentCandidate {
601                    name,
602                    dirs: vec![ctx.to_string()],
603                    boundary_kind: BOUNDARY_DEPLOYMENT.to_string(), intent: None,
604                });
605            }
606        }
607    }
608    // CLI package dirs (clap/cobra/argparse/click registrations) become
609    // their own `cli`-boundary components, so the top-dir fallback below
610    // never demotes them to a generic code-region component
611    for dir in cli_package_dirs(graph) {
612        if let Some(c) = candidates.iter_mut().find(|c| c.name == dir) {
613            if !c.dirs.contains(&dir) {
614                c.dirs.push(dir);
615            }
616            if boundary_rank(BOUNDARY_CLI) > boundary_rank(&c.boundary_kind) {
617                c.boundary_kind = BOUNDARY_CLI.to_string();
618            }
619        } else {
620            candidates.push(ComponentCandidate {
621                name: dir.clone(),
622                dirs: vec![dir.clone()],
623                boundary_kind: BOUNDARY_CLI.to_string(), intent: None,
624            });
625        }
626    }
627    // top-level source dirs so nothing is orphaned; root-level files all
628    // belong to the "root" component
629    let mut top_dirs: HashSet<String> = HashSet::new();
630    for f in graph.entities_of_kind(kinds::FILE) {
631        if f.name.contains('/') {
632            if let Some(seg) = f.name.split('/').next() {
633                if !seg.is_empty() {
634                    top_dirs.insert(seg.to_string());
635                }
636            }
637        }
638    }
639    top_dirs.insert("root".to_string());
640    for d in &top_dirs {
641        if !candidates.iter().any(|c| c.name == *d) {
642            candidates.push(ComponentCandidate {
643                name: d.clone(),
644                dirs: vec![d.clone()],
645                boundary_kind: if d == "root" {
646                    BOUNDARY_ROOT.to_string()
647                } else {
648                    BOUNDARY_CODE_REGION.to_string()
649                }, intent: None,
650            });
651        }
652    }
653    candidates
654}
655
656// trace:v1 id=impl.scc.components.compile work=WORK-SCC-001 satisfies=REQ-SCC-IR
657pub fn compile_components(
658    graph: &RealityGraph,
659    store: &Store,
660    intent: &[(String, serde_json::Value)],
661    pairs: &[crate::cochange::CochangePair],
662) -> Result<Vec<scc_core::Entity>> {
663    let repo_id = &store.repo_id;
664    let candidates = build_candidates(graph, store, intent);
665
666    // ---- semantic clustering over REGIONS (generalization wave):
667    // files belong to architecture because of BEHAVIOR, not directories.
668    // The clusterer may SPLIT a top-level dir or a package (the region
669    // hierarchy starts one level below authoritative boundary dirs: subdir
670    // regions + per-file regions for a flat package's direct files — see
671    // `clustering::build_regions`) and may MERGE regions across dirs (high
672    // call+state weight) — the longest-prefix path assignment is replaced
673    // by the clustering result. Path/package/deployment stay as
674    // constraints and priors: package membership is a +5 cohesion signal
675    // between a package's subregions (not a hard atom), and merging across
676    // deployment units requires weight > SERVICE_THRESHOLD. ----
677    let du_ctxs: Vec<(String, String)> = graph
678        .entities_of_kind(kinds::DEPLOYMENT_UNIT)
679        .into_iter()
680        .filter_map(|du| {
681            let ctx = du.attributes.get("build_context").and_then(|v| v.as_str())?;
682            if ctx == "." || ctx == "./" {
683                return None;
684            }
685            Some((du.name.clone(), ctx.trim_start_matches("./").to_string()))
686        })
687        .collect();
688    let mut du_ctxs_sorted = du_ctxs;
689    du_ctxs_sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
690    let clustering = crate::clustering::cluster_components(
691        graph,
692        store,
693        intent,
694        &candidates,
695        pairs,
696        &du_ctxs_sorted,
697    )?;
698
699    // ---- file/symbol assignment from the clustering result ----
700    let files_in_component: BTreeMap<String, Vec<String>> = clustering.files_in_component;
701    // symbol → component map (cluster membership)
702    let symbol_component: HashMap<String, String> = clustering.symbol_component;
703    let parent_per_comp: BTreeMap<String, String> = clustering.parent_per_comp;
704
705    // ---- aggregation ----
706    let mut responsibilities: BTreeMap<String, Vec<(String, Provenance, f64)>> = BTreeMap::new();
707    // Ownership claims: (target entity id, provenance, confidence, evidence).
708    // Write edges keep their own provenance; intent ownership stays DECLARED
709    // — the compiler never promotes a claim's provenance (P0, §5).
710    type OwnershipClaim = (String, Provenance, f64, Vec<String>);
711    let mut owns: BTreeMap<String, Vec<OwnershipClaim>> = BTreeMap::new();
712    let mut depends: BTreeMap<String, Vec<(String, Provenance, f64, u32)>> = BTreeMap::new();
713    let mut symbols_per_comp: BTreeMap<String, Vec<String>> = BTreeMap::new();
714    let mut evidence_per_comp: BTreeMap<String, Vec<String>> = BTreeMap::new();
715    let mut retries_per_comp: BTreeMap<String, Vec<String>> = BTreeMap::new();
716
717    // route ownership: handler handles route (RESOLVED responsibility)
718    for (sym_id, comp) in &symbol_component {
719        for r in graph.out_pred(sym_id, scc_core::predicates::HANDLES) {
720            if let Some(route) = graph.entities.get(&r.object) {
721                let method = route.attributes.get("method").and_then(|v| v.as_str()).unwrap_or("");
722                let path = route.attributes.get("path").and_then(|v| v.as_str()).unwrap_or("");
723                responsibilities.entry(comp.clone()).or_default().push((
724                    format!("Handles {method} {path}"),
725                    Provenance::Resolved,
726                    1.0,
727                ));
728            }
729        }
730    }
731    // store write ownership (RESOLVED, evidence = the write edges' evidence)
732    for (sym_id, comp) in &symbol_component {
733        for r in graph.out_pred(sym_id, scc_core::predicates::WRITES) {
734            owns.entry(comp.clone()).or_default().push((
735                r.object.clone(),
736                r.provenance,
737                r.confidence,
738                r.evidence.clone(),
739            ));
740        }
741    }
742    // cross-component call dependencies
743    for (sym_id, comp) in &symbol_component {
744        for r in graph.out_pred(sym_id, scc_core::predicates::CALLS) {
745            if let Some(target_comp) = symbol_component.get(&r.object) {
746                if target_comp != comp {
747                    let entry = depends.entry(comp.clone()).or_default();
748                    if let Some((_, p, c, n)) = entry
749                        .iter_mut()
750                        .find(|(t, _, _, _)| t == target_comp)
751                    {
752                        *n += 1;
753                        if prov_rank(r.provenance) > prov_rank(*p) {
754                            *p = r.provenance;
755                        }
756                        *c = c.max(r.confidence);
757                    } else {
758                        entry.push((target_comp.clone(), r.provenance, r.confidence, 1));
759                    }
760                }
761            }
762        }
763    }
764    // owns/depends per component (sorted for determinism — aggregation
765    // iterates a HashMap; vec order feeds owns/depends_on attrs and rel
766    // evidence, so unsorted order is visible run-to-run nondeterminism)
767    for v in owns.values_mut() {
768        v.sort_by(|a, b| a.0.cmp(&b.0));
769    }
770    for v in depends.values_mut() {
771        v.sort_by(|a, b| a.0.cmp(&b.0));
772    }
773    // symbols/evidence/retries per component (sorted for determinism —
774    // aggregation iterates a HashMap)
775    for (sym_id, comp) in &symbol_component {
776        if let Some(e) = graph.entities.get(sym_id) {
777            symbols_per_comp
778                .entry(comp.clone())
779                .or_default()
780                .push(e.name.clone());
781            evidence_per_comp
782                .entry(comp.clone())
783                .or_default()
784                .extend(e.evidence.clone());
785            if let Some(rp) = e.attributes.get("retry_policy").and_then(|v| v.as_str()) {
786                retries_per_comp
787                    .entry(comp.clone())
788                    .or_default()
789                    .push(format!("{} ({rp})", e.name));
790            }
791        }
792    }
793    for v in symbols_per_comp.values_mut() {
794        v.sort();
795        v.dedup();
796    }
797    for v in evidence_per_comp.values_mut() {
798        v.sort();
799        v.dedup();
800    }
801    for v in retries_per_comp.values_mut() {
802        v.sort();
803        v.dedup();
804    }
805
806    // ---- clustering evidence (Wave 5, plan §28): weighted per-candidate
807    // signals feeding `clustering_score`. Deterministic by construction:
808    // every loop below iterates a sorted collection (entities_of_kind,
809    // files_in_component, or the sorted (symbol, component) list), never a
810    // raw HashMap.
811    let mut symbol_list: Vec<(String, String)> = symbol_component
812        .iter()
813        .map(|(s, c)| (s.clone(), c.clone()))
814        .collect();
815    symbol_list.sort();
816    // (component, store) -> distinct symbols in the component writing it
817    let mut shared_writes: BTreeMap<(String, String), HashSet<String>> = BTreeMap::new();
818    // component -> HANDLES edges from its symbols (entrypoint ownership)
819    let mut entrypoints: BTreeMap<String, usize> = BTreeMap::new();
820    // component -> PUBLISHES/CONSUMES edges from its symbols (event ownership)
821    let mut events: BTreeMap<String, usize> = BTreeMap::new();
822    // component -> (internal calls, total calls) from its symbols
823    let mut calls: BTreeMap<String, (usize, usize)> = BTreeMap::new();
824    for (sym_id, comp) in &symbol_list {
825        for r in graph.out_pred(sym_id, scc_core::predicates::WRITES) {
826            // data entities (repo://r/data/store.entity) resolve to their
827            // owning store so two symbols writing db.users and db.orders
828            // count as shared ownership of the same store
829            let target = if r.object.contains("/data/") {
830                graph
831                    .entities
832                    .get(&r.object)
833                    .and_then(|e| e.attributes.get("store"))
834                    .and_then(|v| v.as_str())
835                    .map(|s| entity_id(repo_id, kinds::DATA_STORE, s))
836                    .unwrap_or_else(|| r.object.clone())
837            } else {
838                r.object.clone()
839            };
840            shared_writes
841                .entry((comp.clone(), target))
842                .or_default()
843                .insert(sym_id.clone());
844        }
845        for _r in graph.out_pred(sym_id, scc_core::predicates::HANDLES) {
846            *entrypoints.entry(comp.clone()).or_insert(0) += 1;
847        }
848        for _r in graph
849            .out_pred(sym_id, scc_core::predicates::PUBLISHES)
850            .into_iter()
851            .chain(graph.out_pred(sym_id, scc_core::predicates::CONSUMES))
852        {
853            *events.entry(comp.clone()).or_insert(0) += 1;
854        }
855        for r in graph.out_pred(sym_id, scc_core::predicates::CALLS) {
856            let e = calls.entry(comp.clone()).or_insert((0, 0));
857            e.1 += 1;
858            if symbol_component.get(&r.object) == Some(comp) {
859                e.0 += 1;
860            }
861        }
862    }
863    // component -> route entities contained in its files (route ownership)
864    let mut route_entities: BTreeMap<String, usize> = BTreeMap::new();
865    for (comp, files) in &files_in_component {
866        for fid in files {
867            for r in graph.out_pred(fid, scc_core::predicates::CONTAINS) {
868                if graph
869                    .entities
870                    .get(&r.object)
871                    .map(|e| e.kind == kinds::ROUTE)
872                    .unwrap_or(false)
873                {
874                    *route_entities.entry(comp.clone()).or_insert(0) += 1;
875                }
876            }
877        }
878    }
879    // deployment units with build contexts, most specific (longest context)
880    // first so `parent` picks the tightest unit; name tiebreak for
881    // determinism — the per-component `parent` attribute comes from the
882    // clusterer's parent_per_comp (cluster dirs vs unit build contexts).
883
884    // intent responsibilities / ownership (DECLARED): attach to every
885    // cluster whose name matches the claim or whose dirs contain the
886    // claim's paths — a merged/split component keeps its members' declared
887    // intent (deterministic: clusters are sorted by name).
888    let mut intent_resp: BTreeMap<String, Vec<String>> = BTreeMap::new();
889    let mut intent_owns: BTreeMap<String, Vec<String>> = BTreeMap::new();
890    for (source, claim) in intent {
891        if source == "component" {
892            let name = claim["name"].as_str().unwrap_or("").to_string();
893            if name.is_empty() {
894                continue;
895            }
896            let paths: Vec<String> = claim["paths"]
897                .as_array()
898                .map(|a| {
899                    a.iter()
900                        .filter_map(|p| p.as_str().map(String::from))
901                        .collect()
902                })
903                .unwrap_or_default();
904            let mut targets: Vec<String> = Vec::new();
905            for c in &clustering.clusters {
906                // the declared component's REGION is a member of the
907                // cluster (a merged/split component keeps its members'
908                // declared intent); also accept explicit dir containment
909                // and an exact name match
910                let member_region = c.member_regions.iter().any(|&m| {
911                    clustering
912                        .regions
913                        .get(m)
914                        .map(|r| r.name == name)
915                        .unwrap_or(false)
916                });
917                let covered = !paths.is_empty()
918                    && paths.iter().all(|p| {
919                        c.dirs.iter().any(|d| {
920                            let d = d.trim_end_matches('/');
921                            p == d || p.starts_with(&format!("{d}/")) || d.starts_with(&format!("{p}/"))
922                        })
923                    });
924                if member_region || covered || c.name == name {
925                    targets.push(c.name.clone());
926                }
927            }
928            if let Some(resp) = claim["responsibility"].as_array() {
929                for t in &targets {
930                    for r in resp {
931                        if let Some(s) = r.as_str() {
932                            intent_resp.entry(t.clone()).or_default().push(s.to_string());
933                        }
934                    }
935                }
936            }
937            if let Some(o) = claim["owns"].as_array() {
938                for t in &targets {
939                    for ow in o {
940                        if let Some(s) = ow.as_str() {
941                            intent_owns.entry(t.clone()).or_default().push(s.to_string());
942                        }
943                    }
944                }
945            }
946        }
947    }
948
949    // ---- build component entities (one per clustering result) ----
950    let comp_names: Vec<String> = clustering
951        .clusters
952        .iter()
953        .map(|c| c.name.clone())
954        .collect();
955
956    let mut out: Vec<scc_core::Entity> = Vec::new();
957    for name in comp_names {
958        let id = entity_id(repo_id, kinds::COMPONENT, &name);
959        let mut e = scc_core::Entity::new(id.clone(), kinds::COMPONENT, name.clone());
960
961        let mut resp: Vec<serde_json::Value> = Vec::new();
962        let mut seen: HashSet<String> = HashSet::new();
963        let push_resp = |text: String, prov: Provenance, conf: f64,
964                             resp: &mut Vec<serde_json::Value>, seen: &mut HashSet<String>| {
965            if !seen.insert(text.clone()) {
966                return;
967            }
968            resp.push(json!({
969                "text": text,
970                "provenance": prov.as_str(),
971                "confidence": conf,
972            }));
973        };
974        if let Some(irs) = intent_resp.get(&name) {
975            for s in irs {
976                push_resp(s.clone(), Provenance::Declared, 1.0, &mut resp, &mut seen);
977            }
978        }
979        if let Some(rs) = responsibilities.get(&name) {
980            let mut sorted = rs.clone();
981            sorted.sort_by(|a, b| {
982                prov_rank(b.1)
983                    .cmp(&prov_rank(a.1))
984                    .then_with(|| a.0.cmp(&b.0))
985            });
986            for (text, prov, conf) in sorted {
987                push_resp(text, prov, conf, &mut resp, &mut seen);
988            }
989        }
990        // No evidence → no responsibility claim. Silence beats the old
991        // content-free fallback ("Hosts the X code module"), which readers
992        // mistook for knowledge. An empty array renders as unknown;
993        // consumers (`purpose_text`, packs, trust filter) already handle it.
994        e.attr("responsibility", json!(resp));
995
996        let cluster = clustering
997            .clusters
998            .iter()
999            .find(|c| c.name == name)
1000            .expect("every compiled component is a clustering result");
1001        let dirs = cluster.dirs.clone();
1002        e.attr(
1003            "implementation",
1004            json!({
1005                "paths": dirs,
1006                "symbols": symbols_per_comp.get(&name).cloned().unwrap_or_default(),
1007            }),
1008        );
1009
1010        // Repository role travels with the component so renders can scope
1011        // test/fixture/benchmark trees as structure, never production.
1012        e.attr("role", json!(component_role(&dirs)));
1013
1014        // ---- Wave 5: boundary kind + weighted clustering score ----
1015        let mut score: f64 = match cluster.boundary_kind.as_str() {
1016            BOUNDARY_DEPLOYMENT => 5.0,
1017            BOUNDARY_PACKAGE => 4.0,
1018            // CLI packages are real evidence-backed boundaries (like
1019            // workspace packages), not bare directory fallbacks
1020            BOUNDARY_CLI => 4.0,
1021            BOUNDARY_CODE_REGION | BOUNDARY_ROOT => 1.0,
1022            // declared intent carries its authority in `boundary_kind`;
1023            // the clustering score only counts graph evidence (plan §28)
1024            _ => 0.0,
1025        };
1026        if shared_writes
1027            .iter()
1028            .any(|((c, _), syms)| c == &name && syms.len() >= 2)
1029        {
1030            score += 4.0; // shared data ownership
1031        }
1032        if entrypoints.get(&name).copied().unwrap_or(0) > 0 {
1033            score += 4.0; // entrypoint ownership (route handlers)
1034        }
1035        if route_entities.get(&name).copied().unwrap_or(0) > 0 {
1036            score += 3.0; // route ownership
1037        }
1038        if events.get(&name).copied().unwrap_or(0) > 0 {
1039            score += 3.0; // event ownership
1040        }
1041        if let Some((internal, total)) = calls.get(&name) {
1042            if *total > 0 {
1043                score += 3.0 * (*internal as f64 / *total as f64); // cohesion
1044            }
1045        }
1046        let dir_refs: Vec<&str> = dirs.iter().map(|d| d.as_str()).collect();
1047        let co_pairs = pairs
1048            .iter()
1049            .filter(|p| {
1050                crate::cochange::file_in_paths(&p.a, &dir_refs)
1051                    && crate::cochange::file_in_paths(&p.b, &dir_refs)
1052            })
1053            .count();
1054        score += 2.0 * co_pairs as f64; // co-change (+2 per pair inside)
1055        score = (score * 1000.0).round() / 1000.0;
1056        e.attr("boundary_kind", json!(cluster.boundary_kind.clone()));
1057        e.attr("layer", json!(cluster.layer.clone()));
1058        e.attr("clustering_score", json!(score));
1059        if let Some(parent) = parent_per_comp.get(&name) {
1060            e.attr("parent", json!(parent));
1061        }
1062
1063        // typed ownership claims: (target, provenance, confidence, evidence)
1064        // — intent stays DECLARED, write edges keep their own provenance
1065        let mut owned_claims: Vec<(String, Provenance, f64, Vec<String>)> = owns
1066            .get(&name)
1067            .cloned()
1068            .unwrap_or_default();
1069        if let Some(ios) = intent_owns.get(&name) {
1070            for target in ios {
1071                let target_l = target.to_ascii_lowercase();
1072                let matched = graph
1073                    .entities_of_kind(kinds::DATA_STORE)
1074                    .into_iter()
1075                    .chain(graph.entities_of_kind(kinds::DATA_ENTITY))
1076                    .find(|e| e.name.to_ascii_lowercase() == target_l)
1077                    .map(|e| e.id.clone());
1078                if let Some(mid) = matched {
1079                    owned_claims.push((mid, Provenance::Declared, 1.0, Vec::new()));
1080                }
1081            }
1082        }
1083        owned_claims.sort_by(|a, b| {
1084            a.0.cmp(&b.0)
1085                .then_with(|| prov_rank(b.1).cmp(&prov_rank(a.1)))
1086        });
1087        let owned_json: Vec<serde_json::Value> = owned_claims
1088            .iter()
1089            .map(|(t, p, c, ev)| {
1090                json!({
1091                    "target": t,
1092                    "provenance": p.as_str(),
1093                    "confidence": c,
1094                    "evidence": ev,
1095                })
1096            })
1097            .collect();
1098        e.attr("owns", json!(owned_json));
1099
1100        let deps: Vec<serde_json::Value> = depends
1101            .get(&name)
1102            .map(|v| {
1103                let mut sorted = v.clone();
1104                sorted.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
1105                sorted
1106                    .into_iter()
1107                    .map(|(t, p, c, n)| {
1108                        json!({"target": t, "provenance": p.as_str(), "confidence": c, "call_count": n})
1109                    })
1110                    .collect()
1111            })
1112            .unwrap_or_default();
1113        e.attr("depends_on", json!(deps));
1114        e.attr(
1115            "retries",
1116            json!(retries_per_comp.get(&name).cloned().unwrap_or_default()),
1117        );
1118
1119        e.evidence = evidence_per_comp.get(&name).cloned().unwrap_or_default();
1120        out.push(e);
1121    }
1122
1123    // ---- component-level relationships (derived; carry the evidence of
1124    // the underlying source facts) ----
1125    clear_component_relationships(store)?;
1126    let mut rels: Vec<(Relationship, String)> = Vec::new();
1127
1128    // evidence aggregation helpers over the reality graph
1129    let sym_evidence_in_file = |fid: &str| -> Vec<String> {
1130        let mut ev: Vec<String> = Vec::new();
1131        for r in graph.out_pred(fid, scc_core::predicates::CONTAINS) {
1132            if let Some(e) = graph.entities.get(&r.object) {
1133                ev.extend(e.evidence.clone());
1134            }
1135        }
1136        ev
1137    };
1138    let write_evidence_to = |store_id: &str| -> Vec<String> {
1139        // data entities (repo://r/data/store.entity) resolve to their store
1140        let store_target = if store_id.contains("/data/") {
1141            graph
1142                .entities
1143                .get(store_id)
1144                .and_then(|e| e.attributes.get("store"))
1145                .and_then(|v| v.as_str())
1146                .map(|s| entity_id(repo_id, kinds::DATA_STORE, s))
1147                .unwrap_or_else(|| store_id.to_string())
1148        } else {
1149            store_id.to_string()
1150        };
1151        let mut ev: Vec<String> = Vec::new();
1152        for r in graph.in_pred(&store_target, scc_core::predicates::WRITES) {
1153            ev.extend(r.evidence.clone());
1154        }
1155        ev.sort();
1156        ev.dedup();
1157        ev
1158    };
1159    let call_evidence_between = |from_comp: &str, to_comp: &str| -> Vec<String> {
1160        let mut ev: Vec<String> = Vec::new();
1161        for (sym_id, comp) in &symbol_component {
1162            if comp != from_comp {
1163                continue;
1164            }
1165            for r in graph.out_pred(sym_id, scc_core::predicates::CALLS) {
1166                if let Some(tc) = symbol_component.get(&r.object) {
1167                    if tc == to_comp {
1168                        ev.extend(r.evidence.clone());
1169                    }
1170                }
1171            }
1172        }
1173        // sorted: symbol_component is a HashMap, iteration order is random
1174        ev.sort();
1175        ev.dedup();
1176        ev
1177    };
1178
1179    for e in &out {
1180        if let Some(files) = files_in_component.get(&e.name) {
1181            for fid in files {
1182                rels.push((
1183                    Relationship::new(
1184                        rel(&["component_contains", &e.id, fid]),
1185                        e.id.clone(),
1186                        scc_core::predicates::CONTAINS,
1187                        fid.clone(),
1188                        Provenance::Extracted,
1189                    )
1190                    .with_evidence(sym_evidence_in_file(fid)),
1191                    String::new(),
1192                ));
1193            }
1194        }
1195        if let Some(owned) = e.attributes.get("owns").and_then(|v| v.as_array()) {
1196            for o in owned {
1197                let target = o.get("target").and_then(|v| v.as_str());
1198                let prov = parse_prov(
1199                    o.get("provenance")
1200                        .and_then(|v| v.as_str())
1201                        .unwrap_or("INFERRED"),
1202                );
1203                let conf = o
1204                    .get("confidence")
1205                    .and_then(|v| v.as_f64())
1206                    .unwrap_or_else(|| prov.default_confidence());
1207                let claim_evidence: Vec<String> = o
1208                    .get("evidence")
1209                    .and_then(|v| v.as_array())
1210                    .map(|a| {
1211                        a.iter()
1212                            .filter_map(|x| x.as_str().map(|s| s.to_string()))
1213                            .collect()
1214                    })
1215                    .unwrap_or_default();
1216                if let Some(os) = target {
1217                    // provenance-preserving: the derived OWNS relationship
1218                    // carries the claim's own provenance (DECLARED intent
1219                    // never becomes a resolved ownership fact), and the rel
1220                    // id includes provenance so conflicting claims coexist
1221                    let prov_tag = prov.as_str().to_ascii_lowercase();
1222                    let mut evidence = claim_evidence;
1223                    if evidence.is_empty() {
1224                        evidence = write_evidence_to(os);
1225                    }
1226                    rels.push((
1227                        Relationship::new(
1228                            rel(&["component_owns", &e.id, os, &prov_tag]),
1229                            e.id.clone(),
1230                            scc_core::predicates::OWNS,
1231                            os.to_string(),
1232                            prov,
1233                        )
1234                        .with_confidence(conf)
1235                        .with_evidence(evidence),
1236                        String::new(),
1237                    ));
1238                }
1239            }
1240        }
1241        if let Some(deps) = e.attributes.get("depends_on").and_then(|v| v.as_array()) {
1242            for d in deps {
1243                if let Some(t) = d.get("target").and_then(|v| v.as_str()) {
1244                    let target_id = entity_id(repo_id, kinds::COMPONENT, t);
1245                    let prov = parse_prov(
1246                        d.get("provenance")
1247                            .and_then(|v| v.as_str())
1248                            .unwrap_or("INFERRED"),
1249                    );
1250                    rels.push((
1251                        Relationship::new(
1252                            rel(&["component_depends", &e.id, &target_id]),
1253                            e.id.clone(),
1254                            scc_core::predicates::DEPENDS_ON,
1255                            target_id,
1256                            prov,
1257                        )
1258                        .with_evidence(call_evidence_between(&e.name, t)),
1259                        String::new(),
1260                    ));
1261                }
1262            }
1263        }
1264    }
1265    for (r, src) in rels {
1266        store.insert_relationship(&r, &src)?;
1267    }
1268
1269    // ---- per-component state attribution + pass-2 service compilation
1270    // (additive — the flat clustering list stays intact; services are extra
1271    // entities of kind SERVICE with CONTAINS edges to member components). ----
1272    let state_authority = crate::state::compile_state_authority(graph, &symbol_component);
1273    for c in out.iter_mut() {
1274        let mut mine: Vec<String> = Vec::new();
1275        for section in [
1276            crate::state::S_RUNTIME,
1277            crate::state::S_REACTIVE,
1278            crate::state::S_CONFIGURATION,
1279            crate::state::S_CACHES,
1280            crate::state::S_DERIVED,
1281        ] {
1282            if let Some(lines) = state_authority.get(section) {
1283                let prefix = format!("{} ", c.name);
1284                for l in lines {
1285                    if l.starts_with(&prefix) {
1286                        mine.push(l.clone());
1287                    }
1288                }
1289            }
1290        }
1291        c.attr("state_authority", json!(mine));
1292    }
1293    crate::clustering::compile_services(
1294        store,
1295        &out,
1296        &clustering.component_weights,
1297        &clustering.cross_unit,
1298        &parent_per_comp,
1299    )?;
1300
1301    Ok(out)
1302}
1303
1304fn clear_component_relationships(store: &Store) -> Result<()> {
1305    let rows = store.all_relationships()?;
1306    let ids: Vec<String> = rows
1307        .into_iter()
1308        .filter(|r| r.id.starts_with(RELPREFIX))
1309        .map(|r| r.id)
1310        .collect();
1311    for id in ids {
1312        store.delete_relationship(&id)?;
1313    }
1314    Ok(())
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319    use super::*;
1320
1321    #[test]
1322    // trace:v1 id=test.scc.graph.component-role verifies=REQ-SI-NX53P4B7 exercises=impl.scc.components.role
1323    fn component_role_scopes_non_production_trees() {
1324        let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<Vec<_>>();
1325        assert_eq!(component_role(&v(&["src/api", "src/db"])), "production");
1326        assert_eq!(component_role(&v(&["fixtures/http-service-python"])), "fixture");
1327        assert_eq!(component_role(&v(&["tests", "spec/e2e"])), "test");
1328        assert_eq!(component_role(&v(&["benchmarks/external"])), "benchmark");
1329        assert_eq!(component_role(&v(&["src/api", "tests/api"])), "mixed");
1330        assert_eq!(component_role(&v(&["fixtures/a", "benchmarks/b"])), "mixed");
1331        assert_eq!(component_role(&[]), "production");
1332    }
1333
1334    #[test]
1335    // trace:v1 id=test.scc.graph.path-role-namespace verifies=REQ-SI-NX53P4B7 exercises=impl.scc.components.role
1336    fn path_role_ignores_namespace_example_segments() {
1337        // Java's conventional com.example placeholder package is a
1338        // namespace, not an examples tree.
1339        assert_eq!(
1340            path_role("java_service/src/main/java/com/example/greet/GreetingImpl.java"),
1341            None
1342        );
1343        // Genuine example trees still classify (top level or one down).
1344        assert_eq!(path_role("examples/foo/main.py"), Some("example"));
1345        assert_eq!(path_role("mycrate/examples/foo.rs"), Some("example"));
1346        assert_eq!(path_role("fixtures/foo/app.py"), Some("fixture"));
1347        assert_eq!(path_role("src/test/foo.py"), Some("test"));
1348    }
1349
1350    #[test]
1351    fn intent_ownership_stays_declared() {
1352        // P0 provenance rule: DECLARED intent ownership must never be
1353        // promoted to a resolved OWNS relationship by the component compiler.
1354        let dir = tempfile::TempDir::new().unwrap();
1355        let root = dir.path().join("repo");
1356        std::fs::create_dir_all(&root).unwrap();
1357        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1358
1359        // a data store, a file, and a symbol in it that writes the store
1360        let repo = store.repo_id.clone();
1361        let store_ent = scc_core::entity_id(&repo, kinds::DATA_STORE, "db");
1362        store
1363            .insert_entity(
1364                    &scc_core::Entity::new(store_ent.clone(), kinds::DATA_STORE, "db"),
1365                &["main.py".into()],
1366            )
1367            .unwrap();
1368        let file = scc_core::entity_id(&repo, kinds::FILE, "main.py");
1369        store
1370            .insert_entity(
1371                    &scc_core::Entity::new(file.clone(), kinds::FILE, "main.py"),
1372                &["main.py".into()],
1373            )
1374            .unwrap();
1375        let sym = scc_core::symbol_id(&repo, "main.py", "save");
1376        store
1377            .insert_entity(
1378                    &scc_core::Entity::new(sym.clone(), kinds::SYMBOL, "save"),
1379                &["main.py".into()],
1380            )
1381            .unwrap();
1382        store
1383            .insert_relationship(
1384                &Relationship::new(
1385                    "rel:contains",
1386                    file,
1387                    scc_core::predicates::CONTAINS,
1388                    sym.clone(),
1389                    Provenance::Extracted,
1390                ),
1391                "main.py",
1392            )
1393            .unwrap();
1394        store
1395            .insert_relationship(
1396                &Relationship::new(
1397                    "rel:w",
1398                    sym.clone(),
1399                    scc_core::predicates::WRITES,
1400                    store_ent.clone(),
1401                    Provenance::Extracted,
1402                )
1403                .with_confidence(1.0),
1404                "main.py",
1405            )
1406            .unwrap();
1407
1408        // intent: root component declares ownership of db too
1409        let intent = vec![(
1410            "component".to_string(),
1411            serde_json::json!({"name": "root", "owns": ["db"]}),
1412        )];
1413        let graph = RealityGraph::load(&store).unwrap();
1414        let comps = compile_components(&graph, &store, &intent, &[]).unwrap();
1415        let root_comp = comps.iter().find(|c| c.name == "root").unwrap();
1416
1417        // the owns attribute carries typed claims with provenance
1418        let claims = root_comp.attributes.get("owns").unwrap().as_array().unwrap();
1419        assert_eq!(claims.len(), 2, "{claims:?}");
1420        let declared = claims
1421            .iter()
1422            .find(|c| c.get("provenance").and_then(|v| v.as_str()) == Some("DECLARED"))
1423            .expect("intent claim present");
1424        assert_eq!(declared["target"].as_str().unwrap(), store_ent);
1425        let extracted = claims
1426            .iter()
1427            .find(|c| c.get("provenance").and_then(|v| v.as_str()) == Some("EXTRACTED"))
1428            .expect("write-edge claim present");
1429        assert_eq!(extracted["target"].as_str().unwrap(), store_ent);
1430
1431        // relationships: DECLARED claim stays DECLARED, never RESOLVED
1432        let rels = store.all_relationships().unwrap();
1433        let owns: Vec<_> = rels
1434            .iter()
1435            .filter(|r| r.predicate == scc_core::predicates::OWNS)
1436            .collect();
1437        assert_eq!(owns.len(), 2, "{rels:?}");
1438        assert!(
1439            owns.iter().any(|r| r.provenance == Provenance::Declared),
1440            "declared ownership relationship must exist: {owns:?}"
1441        );
1442        assert!(
1443            !owns.iter().any(|r| r.provenance == Provenance::Resolved),
1444            "no provenance promotion allowed: {owns:?}"
1445        );
1446    }
1447
1448    #[test]
1449// trace:exempt reason=internal-detail
1450    fn path_assignment() {
1451        let cands = vec![
1452            ComponentCandidate::new("web", vec!["src/web".into()], BOUNDARY_PACKAGE),
1453            ComponentCandidate::new("api", vec!["src/api".into()], BOUNDARY_DECLARED),
1454        ];
1455        assert_eq!(component_for_path("src/api/routes.py", &cands), "api");
1456        assert_eq!(component_for_path("src/web/app.ts", &cands), "web");
1457        assert_eq!(component_for_path("src/shared/util.py", &cands), "src");
1458        assert_eq!(component_for_path("README.md", &cands), "root");
1459    }
1460
1461    /// Insert a FILE entity plus CONTAINS edges to its symbols; returns the
1462    /// file id and the symbol ids.
1463    fn insert_file_with_symbols(
1464        store: &Store,
1465        path: &str,
1466        symbols: &[&str],
1467    ) -> (String, Vec<String>) {
1468        let repo = store.repo_id.clone();
1469        let file_id = scc_core::entity_id(&repo, kinds::FILE, path);
1470        store
1471            .insert_entity(
1472                    &scc_core::Entity::new(file_id.clone(), kinds::FILE, path),
1473                &[path.into()],
1474            )
1475            .unwrap();
1476        let mut sym_ids = Vec::new();
1477        for s in symbols {
1478            let sid = scc_core::symbol_id(&repo, path, s);
1479            store
1480                .insert_entity(
1481                    &scc_core::Entity::new(sid.clone(), kinds::SYMBOL, *s),
1482                    &[path.into()],
1483                )
1484                .unwrap();
1485            store
1486                .insert_relationship(
1487                    &Relationship::new(
1488                        format!("rel:contains:{}:{s}", path.replace('/', "_")),
1489                        file_id.clone(),
1490                        scc_core::predicates::CONTAINS,
1491                        sid.clone(),
1492                        Provenance::Extracted,
1493                    ),
1494                    path,
1495                )
1496                .unwrap();
1497            sym_ids.push(sid);
1498        }
1499        (file_id, sym_ids)
1500    }
1501
1502    #[test]
1503    fn boundary_kind_classification() {
1504        // Wave 5: every compiled component records the evidence class that
1505        // created it — declared intent, workspace package, deployment-unit
1506        // build context, bare top-level directory, or root-level files —
1507        // while the entity kind stays kinds::COMPONENT.
1508        let dir = tempfile::TempDir::new().unwrap();
1509        let root = dir.path().join("repo");
1510        std::fs::create_dir_all(&root).unwrap();
1511        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1512        let repo = store.repo_id.clone();
1513
1514        for f in [
1515            "web/app.py",
1516            "packages/a/util.py",
1517            "services/api/main.py",
1518            "misc/util.py",
1519            "README.md",
1520        ] {
1521            store
1522                .insert_entity(
1523                    &scc_core::Entity::new(
1524                        scc_core::entity_id(&repo, kinds::FILE, f),
1525                        kinds::FILE,
1526                        f,
1527                    ),
1528                    &[f.into()],
1529                )
1530                .unwrap();
1531        }
1532        // workspace package member
1533        let mut pkg = scc_core::Entity::new(
1534            scc_core::entity_id(&repo, kinds::PACKAGE, "pkg_a"),
1535            kinds::PACKAGE,
1536            "pkg_a",
1537        );
1538        pkg.attr("path", serde_json::json!("packages/a"));
1539        store
1540            .insert_entity(&pkg, &["packages/a/util.py".into()])
1541            .unwrap();
1542        // deployment unit with a build context
1543        let mut du = scc_core::Entity::new(
1544            scc_core::entity_id(&repo, kinds::DEPLOYMENT_UNIT, "api"),
1545            kinds::DEPLOYMENT_UNIT,
1546            "api",
1547        );
1548        du.attr("build_context", serde_json::json!("services/api"));
1549        store
1550            .insert_entity(&du, &["services/api/main.py".into()])
1551            .unwrap();
1552
1553        let intent = vec![(
1554            "component".to_string(),
1555            serde_json::json!({"name": "web", "paths": ["web"]}),
1556        )];
1557        let graph = RealityGraph::load(&store).unwrap();
1558        let comps = compile_components(&graph, &store, &intent, &[]).unwrap();
1559        let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
1560            comps.iter().map(|c| (c.name.as_str(), c)).collect();
1561        let kind_of = |n: &str| by_name[n].attributes["boundary_kind"].as_str().unwrap();
1562        assert_eq!(kind_of("web"), BOUNDARY_DECLARED);
1563        assert_eq!(kind_of("pkg_a"), BOUNDARY_PACKAGE);
1564        assert_eq!(kind_of("api"), BOUNDARY_DEPLOYMENT);
1565        assert_eq!(kind_of("misc"), BOUNDARY_CODE_REGION);
1566        assert_eq!(kind_of("root"), BOUNDARY_ROOT);
1567        // semantic clustering: the bare `services` top dir holds NO shell
1568        // component — its only file belongs to the deployment region, so
1569        // no empty code-region shell survives (behavior-driven, not
1570        // directory-driven)
1571        assert!(
1572            !by_name.contains_key("services"),
1573            "empty dir shells are pruned by the clusterer: {comps:?}"
1574        );
1575        // entity kind is never renamed; candidates keep their names
1576        assert_eq!(by_name["api"].kind, kinds::COMPONENT);
1577        assert!(by_name.contains_key("api"), "candidate name unchanged");
1578        // components inside a deployment unit carry the additive parent attr
1579        assert_eq!(by_name["api"].attributes["parent"], serde_json::json!("api"));
1580        assert!(
1581            !by_name["web"].attributes.contains_key("parent"),
1582            "no parent outside a deployment unit"
1583        );
1584        // every compiled component carries both new attributes
1585        for c in &comps {
1586            assert!(c.attributes.contains_key("boundary_kind"), "{}", c.name);
1587            assert!(c.attributes.contains_key("clustering_score"), "{}", c.name);
1588        }
1589    }
1590
1591    #[test]
1592    fn cli_package_dirs_become_cli_boundary_components() {
1593        // Wave 9: a directory holding CLI registrations (cli-subcommand
1594        // entrypoints / cli_flags on its symbols or file) becomes its own
1595        // `cli`-boundary component instead of a generic code-region dir —
1596        // and the CLI package dir out-prefixes the bare top-level dir for
1597        // path assignment.
1598        let dir = tempfile::TempDir::new().unwrap();
1599        let root = dir.path().join("repo");
1600        std::fs::create_dir_all(&root).unwrap();
1601        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1602        let _repo = store.repo_id.clone();
1603
1604        // CLI registrations inside cmd/helm (cobra-style): a symbol owning
1605        // flags and a symbol with a subcommand entrypoint
1606        let (helm_file, helm_syms) =
1607            insert_file_with_symbols(&store, "cmd/helm/helm.go", &["rootCmd", "serveCmd"]);
1608        let mut flag_sym = store.get_entity(&helm_syms[0]).unwrap().unwrap();
1609        flag_sym
1610            .attributes
1611            .insert("file".into(), serde_json::json!("cmd/helm/helm.go"));
1612        flag_sym
1613            .attributes
1614            .insert("cli_flags".into(), serde_json::json!(["--port", "--env"]));
1615        store.insert_entity(&flag_sym, &["cmd/helm/helm.go".into()]).unwrap();
1616        let mut ep_sym = store.get_entity(&helm_syms[1]).unwrap().unwrap();
1617        ep_sym
1618            .attributes
1619            .insert("file".into(), serde_json::json!("cmd/helm/helm.go"));
1620        ep_sym.attributes
1621            .insert("entrypoints".into(), serde_json::json!(["cli-subcommand"]));
1622        store.insert_entity(&ep_sym, &["cmd/helm/helm.go".into()]).unwrap();
1623
1624        // plain files elsewhere: pkg/util.go (generic) + root README
1625        let (pkg_file, _) = insert_file_with_symbols(&store, "pkg/util.go", &["Util"]);
1626        let _ = (helm_file, pkg_file);
1627
1628        let graph = RealityGraph::load(&store).unwrap();
1629        let comps = compile_components(&graph, &store, &[], &[]).unwrap();
1630        let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
1631            comps.iter().map(|c| (c.name.as_str(), c)).collect();
1632
1633        let cli = by_name["cmd/helm"];
1634        assert_eq!(cli.attributes["boundary_kind"], serde_json::json!("cli"));
1635        assert_eq!(
1636            cli.attributes["layer"],
1637            serde_json::json!("component"),
1638            "cli components are authoritative components, not code regions"
1639        );
1640        // the cli dir owns its registration file, not the generic dir
1641        let impl_paths = cli.attributes["implementation"]["paths"]
1642            .as_array()
1643            .map(|a| {
1644                a.iter()
1645                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
1646                    .collect::<Vec<_>>()
1647            })
1648            .unwrap_or_default();
1649        assert_eq!(impl_paths, vec!["cmd/helm"]);
1650        // sibling dir stays a plain code-region component
1651        assert_eq!(
1652            by_name["pkg"].attributes["boundary_kind"],
1653            serde_json::json!("code-region")
1654        );
1655        assert_eq!(by_name["root"].attributes["boundary_kind"], serde_json::json!("root"));
1656    }
1657
1658    #[test]
1659    fn cli_evidence_in_root_dir_promotes_root_boundary() {
1660        // Root-level CLI registrations (e.g. a single-package CLI repo)
1661        // promote the root component to the cli boundary: the whole dir is
1662        // the CLI package, so it must not read as a bare root fallback.
1663        let dir = tempfile::TempDir::new().unwrap();
1664        let root = dir.path().join("repo");
1665        std::fs::create_dir_all(&root).unwrap();
1666        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1667        let _repo = store.repo_id.clone();
1668        let (_, syms) = insert_file_with_symbols(&store, "cli.py", &["main"]);
1669        let mut sym = store.get_entity(&syms[0]).unwrap().unwrap();
1670        sym.attributes.insert("file".into(), serde_json::json!("cli.py"));
1671        sym.attributes
1672            .insert("cli_flags".into(), serde_json::json!(["--verbose"]));
1673        store.insert_entity(&sym, &["cli.py".into()]).unwrap();
1674
1675        let graph = RealityGraph::load(&store).unwrap();
1676        let comps = compile_components(&graph, &store, &[], &[]).unwrap();
1677        let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
1678            comps.iter().map(|c| (c.name.as_str(), c)).collect();
1679        assert_eq!(
1680            by_name["root"].attributes["boundary_kind"],
1681            serde_json::json!("cli")
1682        );
1683    }
1684
1685    #[test]
1686    fn hierarchy_clusterer_builds_layer_stack() {
1687        // Ontology phase: 5-node synthetic graph. a<->b merge at 6
1688        // (shared store + call) into subsystem s1; c<->d merge at 6 into
1689        // subsystem s2; s1<->s2 merge at >= 12 (route ownership +3,
1690        // entrypoints +3, import cohesion +2, call +2, events +3 = 13)
1691        // into a service. e stays an unmerged leaf.
1692        let dir = tempfile::TempDir::new().unwrap();
1693        let root = dir.path().join("repo");
1694        std::fs::create_dir_all(&root).unwrap();
1695        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1696        let repo = store.repo_id.clone();
1697
1698        let mut intent: Vec<(String, serde_json::Value)> = Vec::new();
1699        for name in ["a", "b", "c", "d", "e"] {
1700            intent.push((
1701                "component".to_string(),
1702                serde_json::json!({"name": name, "paths": [name]}),
1703            ));
1704        }
1705
1706        // files + symbols per component
1707        let (_fa, sa) = insert_file_with_symbols(&store, "a/x.py", &["a_main"]);
1708        let (fb, sb) = insert_file_with_symbols(&store, "b/x.py", &["b_worker"]);
1709        let (fc, sc) = insert_file_with_symbols(&store, "c/x.py", &["c_main"]);
1710        let (_fd, sd) = insert_file_with_symbols(&store, "d/x.py", &["d_worker"]);
1711        let (_fe, _se) = insert_file_with_symbols(&store, "e/x.py", &["e_standalone"]);
1712        let _ = (sa[0].clone(), sb[0].clone(), sc[0].clone(), sd[0].clone());
1713
1714        // shared stores: a+b write db; c+d write db2 (intra +4 each)
1715        for (i, (syms, store_name)) in [
1716            (sa.clone(), "db"),
1717            (sb.clone(), "db"),
1718            (sc.clone(), "db2"),
1719            (sd.clone(), "db2"),
1720        ]
1721        .into_iter()
1722        .enumerate()
1723        {
1724            let store_ent = scc_core::entity_id(&repo, kinds::DATA_STORE, store_name);
1725            store
1726                .insert_entity(
1727                    &scc_core::Entity::new(store_ent.clone(), kinds::DATA_STORE, store_name),
1728                    &["x.py".into()],
1729                )
1730                .unwrap();
1731            store
1732                .insert_relationship(
1733                    &Relationship::new(
1734                        format!("rel:w:{store_name}:{i}"),
1735                        syms[0].clone(),
1736                        scc_core::predicates::WRITES,
1737                        store_ent,
1738                        Provenance::Extracted,
1739                    ),
1740                    "x.py",
1741                )
1742                .unwrap();
1743        }
1744        // calls: a->b and c->d only (intra +2 each; NO cross calls, so no
1745        // cross pair can reach the pass-1 threshold of 6 on its own)
1746        store
1747            .insert_relationship(
1748                &Relationship::new(
1749                    "rel:call_ab",
1750                    sa[0].clone(),
1751                    scc_core::predicates::CALLS,
1752                    sb[0].clone(),
1753                    Provenance::Extracted,
1754                ),
1755                "a/x.py",
1756            )
1757            .unwrap();
1758        store
1759            .insert_relationship(
1760                &Relationship::new(
1761                    "rel:call_cd",
1762                    sc[0].clone(),
1763                    scc_core::predicates::CALLS,
1764                    sd[0].clone(),
1765                    Provenance::Extracted,
1766                ),
1767                "c/x.py",
1768            )
1769            .unwrap();
1770        // routes owned by a and d only (cross a-d +3)
1771        for (i, sym) in [sa[0].clone(), sd[0].clone()].iter().enumerate() {
1772            let route = scc_core::entity_id(&repo, kinds::ROUTE, &format!("get-/r{i}"));
1773            store
1774                .insert_entity(
1775                    scc_core::Entity::new(route.clone(), kinds::ROUTE, format!("get-/r{i}"))
1776                    .attr("method", serde_json::json!("GET"))
1777                        .attr("path", serde_json::json!(format!("/r{i}"))),
1778                    &["x.py".into()],
1779                )
1780                .unwrap();
1781            store
1782                .insert_relationship(
1783                    &Relationship::new(
1784                        format!("rel:h{i}"),
1785                        sym.clone(),
1786                        scc_core::predicates::HANDLES,
1787                        route,
1788                        Provenance::Extracted,
1789                    ),
1790                    "x.py",
1791                )
1792                .unwrap();
1793        }
1794        // cli entrypoints on b and d only (cross b-d +3)
1795        for (i, sym) in [sb[0].clone(), sd[0].clone()].iter().enumerate() {
1796            let mut e = store.get_entity(sym).unwrap().unwrap();
1797            e.attributes
1798                .insert("entrypoints".into(), serde_json::json!(["cli-subcommand"]));
1799            store.insert_entity(&e, &["x.py".into()]).unwrap();
1800            let _ = i;
1801        }
1802        // events: c and d publish (intra c-d +3)
1803        for (i, sym) in [sc[0].clone(), sd[0].clone()].iter().enumerate() {
1804            let topic = scc_core::entity_id(&repo, kinds::TOPIC, &format!("topic{i}"));
1805            store
1806                .insert_entity(
1807                    &scc_core::Entity::new(topic.clone(), kinds::TOPIC, format!("topic{i}")),
1808                    &["x.py".into()],
1809                )
1810                .unwrap();
1811            store
1812                .insert_relationship(
1813                    &Relationship::new(
1814                        format!("rel:p{i}"),
1815                        sym.clone(),
1816                        scc_core::predicates::PUBLISHES,
1817                        topic,
1818                        Provenance::Extracted,
1819                    ),
1820                    "x.py",
1821                )
1822                .unwrap();
1823        }
1824        // shared configuration read: a and c (cross a-c +4)
1825        let cfg = scc_core::entity_id(&repo, kinds::CONFIGURATION, "MODE");
1826        store
1827            .insert_entity(
1828                    &scc_core::Entity::new(cfg.clone(), kinds::CONFIGURATION, "MODE"),
1829                &["x.py".into()],
1830            )
1831            .unwrap();
1832        for sym in [sa[0].clone(), sc[0].clone()] {
1833            store
1834                .insert_relationship(
1835                    &Relationship::new(
1836                        format!("rel:cfg:{}", sym),
1837                        cfg.clone(),
1838                        scc_core::predicates::CONFIGURED_BY,
1839                        sym,
1840                        Provenance::Extracted,
1841                    ),
1842                    "x.py",
1843                )
1844                .unwrap();
1845        }
1846        // import cohesion: b/x.py imports c/x.py (cross b-c +2)
1847        store
1848            .insert_relationship(
1849                &Relationship::new(
1850                    "rel:imp_bc",
1851                    fb.clone(),
1852                    scc_core::predicates::IMPORTS,
1853                    fc.clone(),
1854                    Provenance::Extracted,
1855                ),
1856                "b/x.py",
1857            )
1858            .unwrap();
1859
1860        let graph = RealityGraph::load(&store).unwrap();
1861        let comps = compile_components(&graph, &store, &intent, &[]).unwrap();
1862
1863        // expected weights (semantic clustering signal set):
1864        //   intra: a-b = 4(shared store)+2(call) = 6 -> one component "a+b"
1865        //          c-d = 4(shared store)+2(call) = 6 -> one component "c+d"
1866        //          (c/d publish DIFFERENT topics — same-topic event edge
1867        //          does not fire)
1868        //   cross: a-c = 4(shared config); b-d = 2(cli prior — the repo
1869        //          classifies Cli on the cli-subcommand entrypoints, and
1870        //          command regions cohere +PRIOR_WEIGHT) — all < 6, so
1871        //          pass 1 keeps the pairs apart; pass-2 SUM = 4+2 = 6
1872        //          < 12 -> NO service
1873        //   e: isolated leaf; root: empty synthetic region
1874
1875        // flat components ARE the clustering result: merged pairs, no
1876        // directory shells
1877        let names: std::collections::BTreeSet<&str> =
1878            comps.iter().map(|c| c.name.as_str()).collect();
1879        for n in ["a+b", "c+d", "e", "root"] {
1880            assert!(names.contains(n), "clustering result keeps {n}: {names:?}");
1881        }
1882        assert!(!names.contains("a"), "merged pair has no a shell: {names:?}");
1883        assert!(!names.contains("b"), "merged pair has no b shell: {names:?}");
1884        assert!(!names.contains("c"), "merged pair has no c shell: {names:?}");
1885        assert!(!names.contains("d"), "merged pair has no d shell: {names:?}");
1886        let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
1887            comps.iter().map(|c| (c.name.as_str(), c)).collect();
1888
1889        // no containers: cross evidence (6) is below SERVICE_THRESHOLD (12)
1890        let services = store.entities_by_kind(kinds::SERVICE).unwrap();
1891        assert_eq!(services.len(), 0, "no service at sum 6: {services:?}");
1892        let subsystems = store.entities_by_kind(kinds::SUBSYSTEM).unwrap();
1893        assert_eq!(subsystems.len(), 0, "no subsystem containers anymore: {subsystems:?}");
1894
1895        // layers: multi-region merges are evidence-backed components; a
1896        // declared singleton keeps the component layer; root stays bare
1897        assert_eq!(by_name["a+b"].attributes["layer"], serde_json::json!("component"));
1898        assert_eq!(by_name["c+d"].attributes["layer"], serde_json::json!("component"));
1899        assert_eq!(by_name["e"].attributes["layer"], serde_json::json!("component"));
1900        assert_eq!(by_name["root"].attributes["layer"], serde_json::json!("code_region"));
1901        // merged clusters carry BOTH member dirs (path/package stay as
1902        // priors — the implementation paths are the union)
1903        let ab_paths = by_name["a+b"].attributes["implementation"]["paths"]
1904            .as_array()
1905            .unwrap();
1906        assert_eq!(
1907            ab_paths,
1908            &vec![serde_json::json!("a"), serde_json::json!("b")],
1909            "merged component paths: {ab_paths:?}"
1910        );
1911
1912        // determinism: a second identical compile reproduces the clustering
1913        let graph2 = RealityGraph::load(&store).unwrap();
1914        let comps2 = compile_components(&graph2, &store, &intent, &[]).unwrap();
1915        assert_eq!(comps2.len(), comps.len());
1916        let names2: std::collections::BTreeSet<&str> =
1917            comps2.iter().map(|c| c.name.as_str()).collect();
1918        assert_eq!(names2, names, "cluster names stable across recompiles");
1919        for (a, b) in comps.iter().zip(comps2.iter()) {
1920            assert_eq!(a.attributes.get("layer"), b.attributes.get("layer"), "{}", a.name);
1921            assert_eq!(a.attributes.get("parent"), b.attributes.get("parent"), "{}", a.name);
1922        }
1923        let services2 = store.entities_by_kind(kinds::SERVICE).unwrap();
1924        assert_eq!(services2.len(), 0, "no container accumulation");
1925    }
1926
1927    #[test]
1928    fn clustering_score_deterministic_and_ranked() {
1929        // Wave 5 §28 weights: shared data ownership +4, entrypoint
1930        // ownership +4, route ownership +3, internal call cohesion +3 —
1931        // and a bare directory fallback scores only +1.
1932        let dir = tempfile::TempDir::new().unwrap();
1933        let root = dir.path().join("repo");
1934        std::fs::create_dir_all(&root).unwrap();
1935        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1936        let repo = store.repo_id.clone();
1937
1938        let (_f1, api_syms) =
1939            insert_file_with_symbols(&store, "api/routes.py", &["handle_a", "handle_b"]);
1940        let (_f2, api_helpers) = insert_file_with_symbols(&store, "api/helpers.py", &["helper"]);
1941        let (_f3, _web_syms) = insert_file_with_symbols(&store, "web/app.py", &["web_index"]);
1942
1943        let store_ent = scc_core::entity_id(&repo, kinds::DATA_STORE, "db");
1944        store
1945            .insert_entity(
1946                    &scc_core::Entity::new(store_ent.clone(), kinds::DATA_STORE, "db"),
1947                &["api/routes.py".into()],
1948            )
1949            .unwrap();
1950        let routes_file = scc_core::entity_id(&repo, kinds::FILE, "api/routes.py");
1951        for (i, sym) in ["handle_a", "handle_b"].iter().enumerate() {
1952            let route = scc_core::entity_id(&repo, kinds::ROUTE, &format!("GET /api/{i}"));
1953            store
1954                .insert_entity(
1955                    scc_core::Entity::new(route.clone(), kinds::ROUTE, format!("GET /api/{i}"))
1956                    .attr("method", serde_json::json!("GET"))
1957                        .attr("path", serde_json::json!(format!("/api/{i}"))),
1958                    &["api/routes.py".into()],
1959                )
1960                .unwrap();
1961            // the route entity lives in the candidate's file (route ownership)
1962            store
1963                .insert_relationship(
1964                    &Relationship::new(
1965                        format!("rel:route_contains_{i}"),
1966                        routes_file.clone(),
1967                        scc_core::predicates::CONTAINS,
1968                        route.clone(),
1969                        Provenance::Extracted,
1970                    ),
1971                    "api/routes.py",
1972                )
1973                .unwrap();
1974            let sym_id = scc_core::symbol_id(&repo, "api/routes.py", sym);
1975            // the handler symbol owns the route (entrypoint ownership)
1976            store
1977                .insert_relationship(
1978                    &Relationship::new(
1979                        format!("rel:handles_{i}"),
1980                        sym_id.clone(),
1981                        scc_core::predicates::HANDLES,
1982                        route.clone(),
1983                        Provenance::Extracted,
1984                    ),
1985                    "api/routes.py",
1986                )
1987                .unwrap();
1988            // two distinct symbols write the same store (shared data ownership)
1989            store
1990                .insert_relationship(
1991                    &Relationship::new(
1992                        format!("rel:writes_{i}"),
1993                        sym_id,
1994                        scc_core::predicates::WRITES,
1995                        store_ent.clone(),
1996                        Provenance::Extracted,
1997                    ),
1998                    "api/routes.py",
1999                )
2000                .unwrap();
2001        }
2002        // internal call cohesion: handle_a -> helper, both inside api
2003        store
2004            .insert_relationship(
2005                &Relationship::new(
2006                    "rel:call_internal",
2007                    api_syms[0].clone(),
2008                    scc_core::predicates::CALLS,
2009                    api_helpers[0].clone(),
2010                    Provenance::Extracted,
2011                ),
2012                "api/routes.py",
2013            )
2014            .unwrap();
2015
2016        let intent = vec![(
2017            "component".to_string(),
2018            serde_json::json!({"name": "api", "paths": ["api"]}),
2019        )];
2020
2021        let graph = RealityGraph::load(&store).unwrap();
2022        let comps = compile_components(&graph, &store, &intent, &[]).unwrap();
2023        let graph2 = RealityGraph::load(&store).unwrap();
2024        let comps2 = compile_components(&graph2, &store, &intent, &[]).unwrap();
2025        let score = |c: &scc_core::Entity| c.attributes["clustering_score"].as_f64().unwrap();
2026        for (a, b) in comps.iter().zip(comps2.iter()) {
2027            assert_eq!(
2028                a.attributes["clustering_score"],
2029                b.attributes["clustering_score"],
2030                "scores must be deterministic for {}",
2031                a.name
2032            );
2033        }
2034        let api = comps.iter().find(|c| c.name == "api").unwrap();
2035        let web = comps.iter().find(|c| c.name == "web").unwrap();
2036        assert_eq!(score(api), 14.0, "{:?}", api.attributes);
2037        assert_eq!(score(web), 1.0, "bare directory: +1 only");
2038        assert!(score(api) > score(web), "evidence-rich candidate outranks a bare dir");
2039        assert_eq!(api.attributes["boundary_kind"], serde_json::json!("declared"));
2040        assert_eq!(web.attributes["boundary_kind"], serde_json::json!("code-region"));
2041    }
2042
2043    #[test]
2044    fn manifest_parsing_is_deterministic() {
2045        // Cargo workspace member + exclude arrays, multi-line and inline
2046        let ws = "[workspace]\nmembers = [\n  \"crates/a\",\n  \"crates/b\",\n]\nexclude = [\"crates/a\"]\n";
2047        assert_eq!(
2048            toml_string_array(ws, "members"),
2049            vec!["crates/a", "crates/b"]
2050        );
2051        assert_eq!(toml_string_array(ws, "exclude"), vec!["crates/a"]);
2052        // `exclude_members` must not match the `members` key
2053        let trick = "[workspace]\nexclude_members = [\"x\"]\nmembers = [\"crates/a\"]\n";
2054        assert_eq!(toml_string_array(trick, "members"), vec!["crates/a"]);
2055        // [package] name inside a crate manifest
2056        let pkg = "[package]\nname = \"grep-cli\"\nversion = \"0.1.0\"\n";
2057        assert_eq!(toml_package_name(pkg).as_deref(), Some("grep-cli"));
2058        // `name` outside the [package] table is ignored
2059        let deps = "[dependencies]\nname = \"x\"\n";
2060        assert_eq!(toml_package_name(deps), None);
2061        // go.work: block + inline use directives, comments ignored
2062        let gow = "go 1.22.0\n\nuse (\n\t./cmd/app\n\t./internal/lib\n\t// a comment\n)\n\nuse ./third\n";
2063        assert_eq!(
2064            gowork_use_dirs(gow),
2065            vec!["cmd/app", "internal/lib", "third"]
2066        );
2067        // `user` must not parse as a `use` directive
2068        assert_eq!(gowork_use_dirs("user = \"x\"\nuse ./only\n"), vec!["only"]);
2069        assert!(gowork_use_dirs("go 1.22\n\nmodule = \"nouse\"\n").is_empty());
2070    }
2071
2072    #[test]
2073    // trace:exempt reason=unit-test
2074    fn cargo_workspace_members_compile_to_package_components() {
2075        // EPIC-040 compiler gap: workspace crates become per-crate
2076        // components, not one top-dir blob. A synthetic Cargo workspace
2077        // (members via a `crates/*` glob, two crates) yields one
2078        // package-boundary component per crate and routes member files
2079        // into their crate — never into a single "crates" component.
2080        // Wave 13: the region hierarchy starts one level below the package
2081        // dir, so a crate whose only content is `src/` produces the
2082        // `crates/alpha/src` region (the crate's code shell).
2083        let dir = tempfile::TempDir::new().unwrap();
2084        let root = dir.path().join("repo");
2085        std::fs::create_dir_all(&root).unwrap();
2086        std::fs::create_dir_all(root.join("crates/alpha/src")).unwrap();
2087        std::fs::create_dir_all(root.join("crates/beta/src")).unwrap();
2088        std::fs::write(
2089            root.join("Cargo.toml"),
2090            "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"top\"\n",
2091        )
2092        .unwrap();
2093        std::fs::write(
2094            root.join("crates/alpha/Cargo.toml"),
2095            "[package]\nname = \"alpha\"\n",
2096        )
2097        .unwrap();
2098        std::fs::write(
2099            root.join("crates/beta/Cargo.toml"),
2100            "[package]\nname = \"beta\"\n",
2101        )
2102        .unwrap();
2103
2104        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2105        let (fa, sa) =
2106            insert_file_with_symbols(&store, "crates/alpha/src/lib.rs", &["alpha_run"]);
2107        let (fb, sb) =
2108            insert_file_with_symbols(&store, "crates/beta/src/lib.rs", &["beta_run"]);
2109        let (_fr, _sr) = insert_file_with_symbols(&store, "README.md", &["readme"]);
2110        let _ = (&fa, &fb, &sa, &sb);
2111
2112        let graph = RealityGraph::load(&store).unwrap();
2113        let comps = compile_components(&graph, &store, &[], &[]).unwrap();
2114        let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
2115            comps.iter().map(|c| (c.name.as_str(), c)).collect();
2116
2117        assert!(
2118            by_name.contains_key("crates/alpha/src"),
2119            "per-crate component: {comps:?}"
2120        );
2121        assert!(
2122            by_name.contains_key("crates/beta/src"),
2123            "per-crate component: {comps:?}"
2124        );
2125        assert_eq!(
2126            by_name["crates/alpha/src"].attributes["boundary_kind"].as_str(),
2127            Some(BOUNDARY_PACKAGE)
2128        );
2129        assert_eq!(
2130            by_name["crates/beta/src"].attributes["boundary_kind"].as_str(),
2131            Some(BOUNDARY_PACKAGE)
2132        );
2133        assert_eq!(
2134            by_name["crates/alpha/src"].attributes["implementation"]["paths"],
2135            json!(["crates/alpha/src"])
2136        );
2137        assert_eq!(
2138            by_name["crates/beta/src"].attributes["implementation"]["paths"],
2139            json!(["crates/beta/src"])
2140        );
2141        // member symbols land in their crate's component
2142        let alpha_syms = by_name["crates/alpha/src"].attributes["implementation"]["symbols"]
2143            .as_array()
2144            .unwrap();
2145        assert!(alpha_syms.iter().any(|s| s == "alpha_run"));
2146        let beta_syms = by_name["crates/beta/src"].attributes["implementation"]["symbols"]
2147            .as_array()
2148            .unwrap();
2149        assert!(beta_syms.iter().any(|s| s == "beta_run"));
2150        // the top-level dir is NOT a single component: any "crates"
2151        // fallback exists only as an empty shell, never holding members
2152        if let Some(crates) = by_name.get("crates") {
2153            let syms = crates.attributes["implementation"]["symbols"]
2154                .as_array()
2155                .unwrap();
2156            assert!(syms.is_empty(), "'crates' must not swallow members: {syms:?}");
2157        }
2158        // longest-prefix matching routes member files to their crate
2159        let cands = vec![
2160            ComponentCandidate::new("crates/alpha/src", vec!["crates/alpha/src".into()], BOUNDARY_PACKAGE),
2161            ComponentCandidate::new("crates/beta/src", vec!["crates/beta/src".into()], BOUNDARY_PACKAGE),
2162            ComponentCandidate::new("crates", vec!["crates".into()], BOUNDARY_CODE_REGION),
2163        ];
2164        assert_eq!(component_for_path("crates/alpha/src/lib.rs", &cands), "crates/alpha/src");
2165        assert_eq!(component_for_path("crates/beta/src/lib.rs", &cands), "crates/beta/src");
2166    }
2167
2168    #[test]
2169    fn gowork_modules_compile_to_package_components() {
2170        // go.work `use (...)` modules become package-boundary components
2171        // too (go is not modeled by the config extractors; components.rs
2172        // parses the workspace file directly).
2173        let dir = tempfile::TempDir::new().unwrap();
2174        let root = dir.path().join("repo");
2175        std::fs::create_dir_all(&root).unwrap();
2176        for d in ["cmd/app", "internal/lib", "third"] {
2177            std::fs::create_dir_all(root.join(d)).unwrap();
2178        }
2179        std::fs::write(
2180            root.join("go.work"),
2181            "go 1.22.0\n\nuse (\n\t./cmd/app\n\t./internal/lib\n)\n\nuse ./third\n",
2182        )
2183        .unwrap();
2184
2185        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2186        let (_fa, sa) = insert_file_with_symbols(&store, "cmd/app/main.go", &["app_main"]);
2187        let (_fb, sb) = insert_file_with_symbols(&store, "internal/lib/lib.go", &["lib_fn"]);
2188        let (_fc, sc) = insert_file_with_symbols(&store, "third/x.go", &["third_fn"]);
2189        let _ = (&sa, &sb, &sc);
2190
2191        let graph = RealityGraph::load(&store).unwrap();
2192        let comps = compile_components(&graph, &store, &[], &[]).unwrap();
2193        let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
2194            comps.iter().map(|c| (c.name.as_str(), c)).collect();
2195
2196        for n in ["app", "lib", "third"] {
2197            assert!(by_name.contains_key(n), "module component missing: {comps:?}");
2198            assert_eq!(
2199                by_name[n].attributes["boundary_kind"].as_str(),
2200                Some(BOUNDARY_PACKAGE),
2201                "{n}"
2202            );
2203        }
2204        assert_eq!(
2205            by_name["app"].attributes["implementation"]["paths"],
2206            json!(["cmd/app"])
2207        );
2208        assert_eq!(
2209            by_name["lib"].attributes["implementation"]["paths"],
2210            json!(["internal/lib"])
2211        );
2212        let app_syms = by_name["app"].attributes["implementation"]["symbols"]
2213            .as_array()
2214            .unwrap();
2215        assert!(app_syms.iter().any(|s| s == "app_main"));
2216        let lib_syms = by_name["lib"].attributes["implementation"]["symbols"]
2217            .as_array()
2218            .unwrap();
2219        assert!(lib_syms.iter().any(|s| s == "lib_fn"));
2220        // no single top-level "cmd" blob holds the app module
2221        if let Some(cmd) = by_name.get("cmd") {
2222            let syms = cmd.attributes["implementation"]["symbols"]
2223                .as_array()
2224                .unwrap();
2225            assert!(syms.is_empty(), "'cmd' must not swallow modules: {syms:?}");
2226        }
2227    }
2228}