src/knowledge/symbols.rs
//! The symbol-scan ROW MODEL (re-exported from `nornir-warehouse-trait`), the
//! `call_edges` derivation vocabulary, and the build-free `.rs` FILE DISCOVERY.
//!
//! The `syn` scanner that used to live here was removed with Mímir v2
//! (PLAN-MIMIR-V2 §7.3b): the rustc-driver dump (lane 3) is the one parser.
//! File discovery stays because it is not a parser — a walkdir over every
//! `Cargo.toml`'s `src/ tests/ benches/ examples/`.
use std::path::Path;
use walkdir::WalkDir;
// The symbol-scan MODEL PODs (`SymbolScan`/`SymbolRow`/`CallEdgeRow`/
// `TestDefRow`/`FeatureGateRow`) live in the `nornir-warehouse-trait` edda leaf
// so a consumer can use them without depending on nornir. Re-exported at their
// old path (`crate::knowledge::symbols::…`).
pub use nornir_warehouse_trait::{CallEdgeRow, FeatureGateRow, SymbolRow, SymbolScan, TestDefRow};
/// **Derivation PROVENANCE** of a call/flow edge — *how* it was found, an axis
/// ORTHOGONAL to its semantic kind (`CallEdgeRow.call_kind` / [`crate::arch::ArchEdgeKind`]).
/// Two edges can both be `Calls` yet be found by completely different machinery,
/// and a reader needs to know which: a `syn` AST edge is a name-match (precise
/// about syntax, blind to trait/macro resolution), an ELF binary edge is a
/// *linkage* fact (a real relocation, no source needed), and a resolved
/// call-graph edge is IDE-grade.
///
/// The variants are the derivation methods the code ACTUALLY runs (the wish's
/// "regex" is not one of them — nornir has no regex edge scanner, so inventing
/// the variant would be a fake column):
///
/// - [`Ast`](Self::Ast) — the (removed) build-free `syn` walker: `call`,
/// `method`, `macro` edges + the accessor→table access facts.
/// - [`CallGraph`](Self::CallGraph) — a RESOLVED index (SCIP): trait/macro/type
/// resolution included.
/// - [`Binary`](Self::Binary) — ELF PLT/GOT relocations
/// ([`crate::graph::extract::binary`]): `caller object → callee symbol`.
///
/// `Default` is [`Ast`](Self::Ast): every edge written before this axis existed
/// came from the `syn` scanner (the only producer that persisted to the
/// `call_edges` warehouse table), so a NULL cell means "pre-axis", never
/// "unknown" — the same MEASURED-default contract as the language axis
/// ([`crate::warehouse::iceberg_schema::LANG_DEFAULT`]).
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub enum EdgeDerivation {
Ast,
CallGraph,
Binary,
}
impl Default for EdgeDerivation {
fn default() -> Self {
EdgeDerivation::Ast
}
}
impl EdgeDerivation {
/// The stable wire token stored in the warehouse `derived_by` column and in
/// the serialized [`crate::arch::ArchGraph`]. Never renumber/rename — the
/// column is resolved by this string.
pub fn as_str(self) -> &'static str {
match self {
EdgeDerivation::Ast => "ast",
EdgeDerivation::CallGraph => "callgraph",
EdgeDerivation::Binary => "binary",
}
}
/// Parse a stored token back, defaulting a NULL/empty/unknown cell to the
/// documented [`Default`](Self::default) (`ast`) so a pre-axis row is never a
/// third, undefined provenance state.
pub fn parse(s: &str) -> Self {
match s {
"callgraph" => EdgeDerivation::CallGraph,
"binary" => EdgeDerivation::Binary,
_ => EdgeDerivation::Ast,
}
}
/// Infer the provenance of a [`CallEdgeRow`] from the semantic `call_kind`
/// its producer stamped. This is the ONE channel that is producer-legible
/// today without a per-row field on the (edda-owned) `CallEdgeRow` POD:
///
/// - `plt:<reloc>` — the binary extractor's marker
/// ([`crate::graph::extract::binary::BinaryGraph::to_call_edge_rows`],
/// where "the marker is implicit in `call_kind`") → [`Binary`](Self::Binary).
/// - `call` / `method` / `macro` (and anything else) → [`Ast`](Self::Ast).
///
/// LIMIT (documented, not faked): the SCIP producer stamps `call_kind =
/// "call"`, byte-identical to the `syn` producer's, so `call_kind` alone
/// CANNOT separate [`CallGraph`](Self::CallGraph) from [`Ast`](Self::Ast).
/// Labelling SCIP edges truthfully requires threading `derived_by` on the
/// `CallEdgeRow` model itself (a cross-repo edda change) — the tracked
/// follow-up. This inference is correct for the two producers whose markers
/// DO differ, and never mislabels a binary edge as source.
pub fn for_call_kind(call_kind: &str) -> Self {
if call_kind.starts_with("plt:") {
EdgeDerivation::Binary
} else {
EdgeDerivation::Ast
}
}
}
/// A single discovered scannable `.rs` file with its crate + section. `size` is
/// the on-disk byte length (already stat'd by the discovery walk).
#[derive(Debug, Clone)]
pub struct DiscoveredFile {
pub path: std::path::PathBuf,
pub crate_name: String,
pub section: &'static str,
pub size: u64,
}
/// Public front for the (otherwise private) discovery walk: every scannable `.rs`
/// under `repo_root`, each tagged with its crate + section.
pub fn discover_files(repo_root: &Path) -> Vec<DiscoveredFile> {
let mut files: Vec<(std::path::PathBuf, String, &'static str, u64)> = Vec::new();
discover_repo_files(repo_root, &mut files);
files
.into_iter()
.map(|(path, crate_name, section, size)| DiscoveredFile {
path,
crate_name,
section,
size,
})
.collect()
}
/// Append every scannable `.rs` file of every crate under `repo_root` as
/// `(file, crate, section, size)`. Pure directory walk (no parsing). A
/// vanished/again-unreadable dir entry is skipped (best-effort), never fatal.
fn discover_repo_files(
repo_root: &Path,
files: &mut Vec<(std::path::PathBuf, String, &'static str, u64)>,
) {
for entry in WalkDir::new(repo_root)
.into_iter()
.filter_entry(|e| !is_skipped_dir(&e.file_name().to_string_lossy()))
{
let Ok(entry) = entry else { continue };
if entry.file_name() == "Cargo.toml" {
collect_crate_files(entry.path(), files);
}
}
}
fn is_skipped_dir(name: &str) -> bool {
// `.claude/` holds agent tooling — notably `.claude/worktrees/`, which nests
// FULL checkouts of other branches (each with its own `src/…`). Scanning into
// them double-counts symbols and lets a duplicate file (e.g. a worktree's
// `src/bin/nornir-server.rs`) shadow the canonical one. Prune it like `.git`.
matches!(
name,
"target" | ".git" | "node_modules" | ".nornir" | ".claude"
)
}
/// Collect every scannable `.rs` file of the crate at `cargo_toml` as
/// `(file, crate_name, section, size)` tuples.
fn collect_crate_files(
cargo_toml: &Path,
files: &mut Vec<(std::path::PathBuf, String, &'static str, u64)>,
) {
let Ok(text) = std::fs::read_to_string(cargo_toml) else {
return;
};
let Ok(doc) = text.parse::<toml::Table>().map(toml::Value::Table) else {
return;
};
let Some(crate_name) = doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
else {
return;
};
let Some(crate_dir) = cargo_toml.parent() else {
return;
};
for sub in &["src", "tests", "benches", "examples"] {
let dir = crate_dir.join(sub);
if !dir.is_dir() {
continue;
}
for entry in WalkDir::new(&dir)
.into_iter()
.filter_entry(|e| !is_skipped_dir(&e.file_name().to_string_lossy()))
{
let Ok(entry) = entry else { continue };
if entry.file_type().is_file()
&& entry.path().extension().and_then(|e| e.to_str()) == Some("rs")
{
// Stat the size now (the discovery walk is already touching the
// dir entry) so a consumer can schedule heaviest-file-first.
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
files.push((
entry.path().to_path_buf(),
crate_name.to_string(),
*sub,
size,
));
}
}
}
}
/// The `file` column value for `file` under `repo_root` — the repo-root-relative
/// path.
pub fn rel_path(repo_root: &Path, file: &Path) -> String {
file.strip_prefix(repo_root)
.unwrap_or(file)
.to_string_lossy()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
/// Edges from DIFFERENT derivation paths carry the correct provenance label
/// (the wish's core invariant). The two producers whose `call_kind` markers
/// differ — the `syn` AST scanner (`call`/`method`/`macro`) and the ELF binary
/// extractor (`plt:<reloc>`, see [`crate::graph::extract::binary`]) — map to
/// [`EdgeDerivation::Ast`] and [`EdgeDerivation::Binary`] respectively, and are
/// never confused. Documents the SCIP limit: `scip` stamps `call`, so it is
/// (correctly, not falsely) read as `Ast` until per-row provenance lands on
/// the `CallEdgeRow` model.
#[test]
fn edge_derivation_is_per_producer_marker() {
// syn AST producer markers.
assert_eq!(EdgeDerivation::for_call_kind("call"), EdgeDerivation::Ast);
assert_eq!(EdgeDerivation::for_call_kind("method"), EdgeDerivation::Ast);
assert_eq!(EdgeDerivation::for_call_kind("macro"), EdgeDerivation::Ast);
// ELF binary extractor markers (`format!("plt:{}", reloc)`).
assert_eq!(
EdgeDerivation::for_call_kind("plt:R_X86_64_JUMP_SLOT"),
EdgeDerivation::Binary
);
assert_eq!(
EdgeDerivation::for_call_kind("plt:R_X86_64_GLOB_DAT"),
EdgeDerivation::Binary
);
// Wire tokens round-trip; unknown/pre-axis → the `ast` default.
assert_eq!(EdgeDerivation::Binary.as_str(), "binary");
assert_eq!(EdgeDerivation::CallGraph.as_str(), "callgraph");
assert_eq!(EdgeDerivation::parse("binary"), EdgeDerivation::Binary);
assert_eq!(EdgeDerivation::parse("nonsense"), EdgeDerivation::default());
}
}