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