pub mod call_edges;
pub mod js_workspace;
pub mod load;
pub mod other_edges;
pub mod routes;
pub mod semantic_edges;
pub mod type_edges;
use crate::models::{ParseResult, ProjectInfo, SourceRoot};
use crate::parsers::{detect_languages, get_parser, language_for_path};
use kglite::api::DirGraph;
use rayon::prelude::*;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use walkdir::WalkDir;
pub(crate) fn class_node_type(kind: &str) -> &'static str {
match kind {
"struct" => "Struct",
"mixin" => "Mixin",
_ => "Class",
}
}
pub(crate) fn timing_enabled(verbose: bool) -> bool {
verbose || std::env::var_os("KGLITE_CODE_TREE_VERBOSE").is_some()
}
#[allow(clippy::too_many_arguments)]
pub fn run_with_options(
input: &Path,
verbose: bool,
include_tests: bool,
save_to: Option<&Path>,
max_loc_per_file: Option<usize>,
include_docs: bool,
) -> Result<Arc<DirGraph>, String> {
run_with_options_stats(
input,
verbose,
include_tests,
save_to,
max_loc_per_file,
include_docs,
)
.map(|(graph, _stats)| graph)
}
#[allow(clippy::too_many_arguments)]
pub fn run_with_options_stats(
input: &Path,
verbose: bool,
include_tests: bool,
save_to: Option<&Path>,
max_loc_per_file: Option<usize>,
include_docs: bool,
) -> Result<(Arc<DirGraph>, call_edges::CallResolutionStats), String> {
let input = input.canonicalize().unwrap_or_else(|_| input.to_path_buf());
let t_manifest = std::time::Instant::now();
let (project_root, mut project_info) = if input.is_file() {
let project_root = input
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| input.clone());
let info =
crate::manifest::try_read_manifest_file(&input, &project_root)?.ok_or_else(|| {
format!(
"Not a recognised manifest file: {}",
input.file_name().and_then(|o| o.to_str()).unwrap_or(""),
)
})?;
(project_root, Some(info))
} else if input.is_dir() {
let info = crate::manifest::try_read_manifest(&input)?;
(input.clone(), info)
} else {
return Err(format!("Not a file or directory: {}", input.display()));
};
if timing_enabled(verbose) {
eprintln!(
"[timing] manifest discovery: {:.3}s",
t_manifest.elapsed().as_secs_f64()
);
}
let mut combined = ParseResult::new();
let mut parsed_any = false;
if let Some(info) = &mut project_info {
if info.source_roots.is_empty() {
if verbose {
eprintln!(
"Manifest: {} ({}) — no source roots declared, scanning whole repo",
info.manifest_path,
info.build_system.as_deref().unwrap_or("")
);
}
} else {
let mut roots: Vec<_> = info.source_roots.clone();
if include_tests {
roots.extend(info.test_roots.iter().cloned());
}
let roots = collapse_covered_roots(roots);
if verbose {
eprintln!(
"Manifest: {} ({})",
info.manifest_path,
info.build_system.as_deref().unwrap_or("")
);
let labels: Vec<String> = roots
.iter()
.map(|r| {
r.path
.strip_prefix(&project_root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| r.path.display().to_string())
})
.collect();
eprintln!("Source roots: {}", labels.join(", "));
}
let t_parse = std::time::Instant::now();
let walk_dirs: Vec<&Path> = roots
.iter()
.map(|root| root.path.as_path())
.filter(|path| path.is_dir())
.collect();
if !walk_dirs.is_empty() {
let result = parse_roots(&walk_dirs, &project_root, verbose, max_loc_per_file);
combined.merge(result);
parsed_any = true;
}
if timing_enabled(verbose) && parsed_any {
eprintln!("[timing] parse: {:.3}s", t_parse.elapsed().as_secs_f64());
}
}
}
if !parsed_any {
if !project_root.is_dir() {
return Err(format!("Not a directory: {}", project_root.display()));
}
let t_parse = std::time::Instant::now();
let result = parse_roots(
&[project_root.as_path()],
&project_root,
verbose,
max_loc_per_file,
);
combined.merge(result);
if timing_enabled(verbose) {
eprintln!("[timing] parse: {:.3}s", t_parse.elapsed().as_secs_f64());
}
}
if !include_tests {
retain_non_test_entities(&mut combined);
}
if let Some(info) = &mut project_info {
reconcile_project_languages(info, &combined);
} else {
project_info = infer_project_info(&project_root, &combined);
}
finalize_and_load(
combined,
project_info,
&project_root,
include_docs,
verbose,
save_to,
)
}
fn collapse_covered_roots(roots: Vec<SourceRoot>) -> Vec<SourceRoot> {
let mut kept: Vec<(PathBuf, SourceRoot)> = Vec::new();
for root in roots {
let path = root
.path
.canonicalize()
.unwrap_or_else(|_| root.path.clone());
if kept.iter().any(|(ancestor, _)| path.starts_with(ancestor)) {
continue;
}
kept.retain(|(candidate, _)| !candidate.starts_with(&path));
kept.push((path, root));
}
kept.into_iter().map(|(_, root)| root).collect()
}
fn retain_non_test_entities(result: &mut ParseResult) {
let test_paths: HashSet<String> = result
.files
.iter()
.filter(|file| file.is_test)
.map(|file| file.path.clone())
.collect();
if test_paths.is_empty() {
return;
}
let removed_types: HashSet<String> = result
.classes
.iter()
.filter(|item| test_paths.contains(&item.file_path))
.map(|item| item.qualified_name.clone())
.chain(
result
.interfaces
.iter()
.filter(|item| test_paths.contains(&item.file_path))
.map(|item| item.qualified_name.clone()),
)
.collect();
result.files.retain(|item| !item.is_test);
result
.functions
.retain(|item| !test_paths.contains(&item.file_path));
result
.classes
.retain(|item| !test_paths.contains(&item.file_path));
result
.enums
.retain(|item| !test_paths.contains(&item.file_path));
result
.interfaces
.retain(|item| !test_paths.contains(&item.file_path));
result
.attributes
.retain(|item| !test_paths.contains(&item.file_path));
result
.constants
.retain(|item| !test_paths.contains(&item.file_path));
result
.elements
.retain(|item| !test_paths.contains(&item.file_path));
result
.selectors
.retain(|item| !test_paths.contains(&item.file_path));
let retained_functions: HashSet<&str> = result
.functions
.iter()
.map(|item| item.qualified_name.as_str())
.collect();
let retained_constants: HashSet<&str> = result
.constants
.iter()
.map(|item| item.qualified_name.as_str())
.collect();
result.control_transfers.retain(|site| {
retained_functions.contains(site.caller.as_str())
&& site
.target
.as_deref()
.is_none_or(|target| retained_functions.contains(target))
});
result.reference_sites.retain(|site| {
retained_functions.contains(site.caller.as_str())
&& retained_constants.contains(site.target.as_str())
});
result.symbol_relationships.retain(|relationship| {
retained_constants.contains(relationship.source.as_str())
&& match relationship.target_kind {
crate::models::SymbolTargetKind::Constant => {
retained_constants.contains(relationship.target.as_str())
}
crate::models::SymbolTargetKind::Function => {
retained_functions.contains(relationship.target.as_str())
}
}
});
let retained_types: HashSet<&str> = result
.classes
.iter()
.map(|item| item.qualified_name.as_str())
.chain(
result
.interfaces
.iter()
.map(|item| item.qualified_name.as_str()),
)
.collect();
result.type_relationships.retain_mut(|relationship| {
relationship
.methods
.retain(|method| !test_paths.contains(&method.file_path));
!removed_types.contains(&relationship.source_type)
|| retained_types.contains(relationship.source_type.as_str())
});
}
fn infer_project_info(project_root: &Path, result: &ParseResult) -> Option<ProjectInfo> {
let name = project_root.file_name().and_then(|name| name.to_str())?;
if name.is_empty() {
return None;
}
let mut info = ProjectInfo {
name: name.to_string(),
manifest_path: crate::models::INFERRED_MANIFEST.to_string(),
..ProjectInfo::default()
};
reconcile_project_languages(&mut info, result);
Some(info)
}
fn reconcile_project_languages(project: &mut ProjectInfo, result: &ParseResult) {
let present: BTreeSet<&str> = result
.files
.iter()
.map(|file| file.language.as_str())
.filter(|language| !language.is_empty())
.collect();
let mut known: HashSet<String> = project.languages.iter().cloned().collect();
for language in crate::parsers::registry::LANGUAGES {
if present.contains(language.id) && known.insert(language.id.to_string()) {
project.languages.push(language.id.to_string());
}
}
for language in present {
if known.insert(language.to_string()) {
project.languages.push(language.to_string());
}
}
}
fn collect_files_by_lang(walk_dir: &Path, verbose: bool) -> BTreeMap<&'static str, Vec<PathBuf>> {
let t_walk = std::time::Instant::now();
let mut by_lang: BTreeMap<&'static str, Vec<PathBuf>> = BTreeMap::new();
for entry in WalkDir::new(walk_dir)
.into_iter()
.filter_entry(crate::manifest::walk_filter)
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
if let Some(lang) = language_for_path(entry.path()) {
by_lang
.entry(lang)
.or_default()
.push(entry.path().to_path_buf());
}
}
if by_lang.contains_key("cpp") {
if let Some(c_files) = by_lang.remove("c") {
let (h_files, c_only): (Vec<PathBuf>, Vec<PathBuf>) = c_files
.into_iter()
.partition(|p| p.extension().and_then(|e| e.to_str()) == Some("h"));
if !h_files.is_empty() {
by_lang.entry("cpp").or_default().extend(h_files);
}
if !c_only.is_empty() {
by_lang.insert("c", c_only);
}
}
}
for files in by_lang.values_mut() {
files.sort();
}
if verbose {
let langs: Vec<&'static str> = by_lang.keys().copied().collect();
eprintln!(
" Detected languages in {}: {:?}",
walk_dir.display(),
langs
);
for lang in &langs {
eprintln!(" Found {} {} files", by_lang[lang].len(), lang);
}
}
if timing_enabled(verbose) {
eprintln!("[timing] walk: {:.3}s", t_walk.elapsed().as_secs_f64());
}
by_lang
}
struct ParseGroup {
parser: Box<dyn crate::parsers::LanguageParser + Send + Sync>,
file_count: usize,
skipped: Vec<crate::models::FileInfo>,
}
fn parse_roots(
walk_dirs: &[&Path],
project_root: &Path,
verbose: bool,
max_loc_per_file: Option<usize>,
) -> ParseResult {
let mut worklist: Vec<(usize, PathBuf)> = Vec::new();
let mut groups: Vec<ParseGroup> = Vec::new();
for walk_dir in walk_dirs {
for (lang, files) in collect_files_by_lang(walk_dir, verbose) {
let Some(parser) = get_parser(lang) else {
continue;
};
let (to_parse, skipped) = match max_loc_per_file {
Some(threshold) => prefilter_oversized(&files, threshold, project_root, lang),
None => (files, Vec::new()),
};
if verbose && !skipped.is_empty() {
eprintln!(
" Skipped {} {} files over max_loc_per_file (threshold {})",
skipped.len(),
lang,
max_loc_per_file.unwrap_or(0)
);
}
let group_index = groups.len();
groups.push(ParseGroup {
parser,
file_count: to_parse.len(),
skipped,
});
worklist.extend(to_parse.into_iter().map(|file| (group_index, file)));
}
}
let t_dispatch = std::time::Instant::now();
let per_file: Vec<ParseResult> = crate::parsers::parser_pool().install(|| {
worklist
.par_iter()
.map(|(group_index, file)| groups[*group_index].parser.parse_file(file, project_root))
.collect()
});
if timing_enabled(verbose) {
eprintln!(
"[timing] parse dispatch: {:.3}s ({} files)",
t_dispatch.elapsed().as_secs_f64(),
worklist.len()
);
}
let mut results = per_file.into_iter();
let mut combined = ParseResult::new();
for group in groups {
let slice: Vec<ParseResult> = results.by_ref().take(group.file_count).collect();
let mut result = group.parser.finalize(slice);
result.files.extend(group.skipped);
combined.merge(result);
}
combined
}
fn prefilter_oversized(
files: &[PathBuf],
threshold: usize,
src_root: &Path,
language: &str,
) -> (Vec<PathBuf>, Vec<crate::models::FileInfo>) {
use std::io::{BufRead, BufReader};
let mut to_parse = Vec::with_capacity(files.len());
let mut skipped = Vec::new();
for fp in files {
let size_bytes = std::fs::metadata(fp).map(|m| m.len() as usize).unwrap_or(0);
if size_bytes <= threshold {
to_parse.push(fp.clone());
continue;
}
let Ok(file) = std::fs::File::open(fp) else {
to_parse.push(fp.clone());
continue;
};
let mut reader = BufReader::new(file);
let mut buf = Vec::new();
let mut loc: usize = 0;
let mut over = false;
while let Ok(n) = reader.read_until(b'\n', &mut buf) {
if n == 0 {
break;
}
loc += 1;
buf.clear();
if loc > threshold {
over = true;
break;
}
}
if over {
while let Ok(n) = reader.read_until(b'\n', &mut buf) {
if n == 0 {
break;
}
loc += 1;
buf.clear();
}
let rel_path = fp.strip_prefix(src_root).unwrap_or(fp);
let filename = fp
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
skipped.push(crate::models::FileInfo {
path: rel_path.display().to_string(),
filename,
loc: loc as u32,
module_path: String::new(),
language: language.to_string(),
submodule_declarations: Vec::new(),
imports: Vec::new(),
exports: Vec::new(),
annotations: None,
is_test: false,
skip_reason: Some("too_large".into()),
});
} else {
to_parse.push(fp.clone());
}
}
(to_parse, skipped)
}
#[allow(clippy::too_many_arguments)]
fn finalize_and_load(
mut combined: ParseResult,
project_info: Option<crate::models::ProjectInfo>,
project_root: &Path,
include_docs: bool,
verbose: bool,
save_to: Option<&Path>,
) -> Result<(Arc<DirGraph>, call_edges::CallResolutionStats), String> {
if verbose {
eprintln!(
"Parsed: {} files, {} functions, {} classes, {} enums, {} interfaces, {} attributes, {} constants",
combined.files.len(),
combined.functions.len(),
combined.classes.len(),
combined.enums.len(),
combined.interfaces.len(),
combined.attributes.len(),
combined.constants.len()
);
}
let t_dedup = std::time::Instant::now();
dedup_by_key(&mut combined.files, |f| f.path.clone());
preserve_overloaded_function_ids(&mut combined.functions, &mut combined.type_relationships);
dedup_by_key(&mut combined.functions, |f| f.qualified_name.clone());
dedup_by_key(&mut combined.classes, |c| c.qualified_name.clone());
dedup_by_key(&mut combined.enums, |e| e.qualified_name.clone());
dedup_by_key(&mut combined.interfaces, |i| i.qualified_name.clone());
dedup_by_key(&mut combined.constants, |c| c.qualified_name.clone());
combined.control_transfers.sort();
combined.control_transfers.dedup();
combined.reference_sites.sort();
combined.reference_sites.dedup();
combined.symbol_relationships.sort();
combined.symbol_relationships.dedup();
if timing_enabled(verbose) {
eprintln!("[timing] dedup: {:.3}s", t_dedup.elapsed().as_secs_f64());
}
let t_load = std::time::Instant::now();
let t_ws = std::time::Instant::now();
let js_workspace = if combined
.files
.iter()
.any(|f| crate::parsers::registry::uses_module_path_imports(&f.language))
{
js_workspace::JsWorkspace::discover(project_root)
} else {
js_workspace::JsWorkspace::default()
};
if timing_enabled(verbose) {
eprintln!(
"[timing] js workspace discovery: {:.3}s",
t_ws.elapsed().as_secs_f64()
);
}
let (mut graph, call_stats) =
load::load_into_graph(&combined, project_info.as_ref(), &js_workspace)?;
if timing_enabled(verbose) {
eprintln!("[timing] load: {:.3}s", t_load.elapsed().as_secs_f64());
}
if include_docs {
let t_docs = std::time::Instant::now();
#[cfg(feature = "docs")]
{
let g =
std::sync::Arc::get_mut(&mut graph).expect("graph is uniquely owned during build");
crate::docs::ingest_and_link(
g,
project_root,
project_info.as_ref().map(|info| info.name.as_str()),
verbose,
)?;
if timing_enabled(verbose) {
eprintln!("[timing] docs: {:.3}s", t_docs.elapsed().as_secs_f64());
}
}
#[cfg(not(feature = "docs"))]
{
let _ = (project_root, t_docs);
return Err("include_docs=true requires the 'okf' Cargo feature".into());
}
}
{
let g = std::sync::Arc::get_mut(&mut graph).expect("graph is uniquely owned during build");
let t_xlang = std::time::Instant::now();
crate::cross_lang::ingest_http_cross_edges(g, project_root, verbose)?;
if timing_enabled(verbose) {
eprintln!(
"[timing] cross-lang: {:.3}s",
t_xlang.elapsed().as_secs_f64()
);
}
}
if let Some(dest) = save_to {
let mut graph = graph;
kglite::api::io::prepare_kgl_write(&mut graph);
let dest_str = dest.to_string_lossy();
kglite::api::io::write_kgl(&graph, &dest_str).map_err(|e| e.to_string())?;
return Ok((graph, call_stats));
}
Ok((graph, call_stats))
}
pub fn run(src_dir: &Path, verbose: bool) -> Result<Arc<DirGraph>, String> {
let mut combined = ParseResult::new();
let languages = detect_languages(src_dir);
if verbose {
eprintln!("Detected languages: {:?}", languages);
}
for lang in languages {
let Some(parser) = get_parser(lang) else {
if verbose {
eprintln!(" (no Rust parser yet for {lang})");
}
continue;
};
if verbose {
eprintln!("Parsing {} files...", lang);
}
let result = parser.parse_directory(src_dir, verbose);
combined.merge(result);
}
dedup_by_key(&mut combined.files, |f| f.path.clone());
dedup_by_key(&mut combined.functions, |f| f.qualified_name.clone());
dedup_by_key(&mut combined.classes, |c| c.qualified_name.clone());
dedup_by_key(&mut combined.enums, |e| e.qualified_name.clone());
dedup_by_key(&mut combined.interfaces, |i| i.qualified_name.clone());
dedup_by_key(&mut combined.constants, |c| c.qualified_name.clone());
combined.control_transfers.sort();
combined.control_transfers.dedup();
combined.reference_sites.sort();
combined.reference_sites.dedup();
combined.symbol_relationships.sort();
combined.symbol_relationships.dedup();
if verbose {
eprintln!(
"Parsed: {} files, {} functions, {} classes, {} enums, {} interfaces, {} attributes, {} constants",
combined.files.len(),
combined.functions.len(),
combined.classes.len(),
combined.enums.len(),
combined.interfaces.len(),
combined.attributes.len(),
combined.constants.len()
);
}
load::load_into_graph(&combined, None, &js_workspace::JsWorkspace::default())
.map(|(graph, _stats)| graph)
}
fn dedup_by_key<T, K, F>(items: &mut Vec<T>, mut key: F)
where
K: Eq + std::hash::Hash,
F: FnMut(&T) -> K,
{
let mut seen: std::collections::HashMap<K, usize> = std::collections::HashMap::new();
for (idx, item) in items.iter().enumerate() {
seen.insert(key(item), idx);
}
if seen.len() == items.len() {
return;
}
let mut keep: Vec<usize> = seen.into_values().collect();
keep.sort_unstable();
let mut out: Vec<T> = Vec::with_capacity(keep.len());
for (idx, item) in std::mem::take(items).into_iter().enumerate() {
if keep.binary_search(&idx).is_ok() {
out.push(item);
}
}
*items = out;
}
const OVERLOAD_ID_SEPARATOR: char = '#';
const SHA256_HEX_LEN: usize = 64;
fn preserve_overloaded_function_ids(
functions: &mut Vec<crate::models::FunctionInfo>,
type_relationships: &mut [crate::models::TypeRelationship],
) {
dedup_by_key(functions, |function| {
(function.qualified_name.clone(), function.signature.clone())
});
let mut signatures_by_id: std::collections::HashMap<&str, HashSet<&str>> =
std::collections::HashMap::new();
for function in functions.iter() {
signatures_by_id
.entry(function.qualified_name.as_str())
.or_default()
.insert(function.signature.as_str());
}
let overloaded: HashSet<String> = signatures_by_id
.into_iter()
.filter_map(|(qualified_name, signatures)| {
(signatures.len() > 1).then(|| qualified_name.to_string())
})
.collect();
if overloaded.is_empty() {
return;
}
let replacements: std::collections::HashMap<(String, String), String> = functions
.iter_mut()
.filter(|function| overloaded.contains(function.qualified_name.as_str()))
.map(|function| {
let base = function.qualified_name.clone();
let signature = function.signature.clone();
let qualified_name = overloaded_qualified_name(&base, &signature);
function.qualified_name.clone_from(&qualified_name);
((base, signature), qualified_name)
})
.collect();
for relationship in type_relationships {
for method in &mut relationship.methods {
if let Some(qualified_name) =
replacements.get(&(method.qualified_name.clone(), method.signature.clone()))
{
method.qualified_name.clone_from(qualified_name);
}
}
}
}
fn overloaded_qualified_name(base: &str, signature: &str) -> String {
use sha2::{Digest, Sha256};
let discriminator: String = Sha256::digest(signature)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("{base}{OVERLOAD_ID_SEPARATOR}{discriminator}")
}
pub(super) fn overload_base_qualified_name(qualified_name: &str) -> Option<&str> {
let (base, discriminator) = qualified_name.rsplit_once(OVERLOAD_ID_SEPARATOR)?;
(discriminator.len() == SHA256_HEX_LEN
&& discriminator.bytes().all(|byte| byte.is_ascii_hexdigit()))
.then_some(base)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ClassInfo, FileInfo, FunctionInfo, TypeRelationship};
use crate::parsers::get_parser;
use kglite::api::GraphRead;
use kglite::datatypes::Value;
#[test]
fn agc_cross_file_pass_stays_scoped_to_one_source_root() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("project");
let root_a = project.join("src/a");
let root_b = project.join("src/b");
std::fs::create_dir_all(&root_a).expect("create root a");
std::fs::create_dir_all(&root_b).expect("create root b");
std::fs::write(root_a.join("MAIN.agc"), "CALLER TC HELPER\n").expect("write caller");
std::fs::write(root_b.join("HELP.agc"), "HELPER TC Q\n").expect("write helper");
let split = parse_roots(&[root_a.as_path(), root_b.as_path()], &project, false, None);
let helper = split
.functions
.iter()
.find(|function| function.qualified_name == "src.HELPER")
.expect("helper parsed");
assert!(
!helper.metadata.contains_key("role_hint"),
"AGC finalize resolved a call across two source roots: {:?}",
helper.metadata
);
std::fs::rename(root_b.join("HELP.agc"), root_a.join("HELP.agc")).expect("move helper");
let single = parse_roots(&[root_a.as_path()], &project, false, None);
let helper = single
.functions
.iter()
.find(|function| function.qualified_name == "src.HELPER")
.expect("helper parsed");
assert_eq!(helper.metadata["role_hint"], "routine");
}
#[test]
fn overlapping_roots_collapse_to_the_covering_ancestor() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("project");
std::fs::create_dir_all(project.join("tests")).expect("create tests dir");
std::fs::write(
project.join("pyproject.toml"),
"[project]\nname = \"demo\"\n[tool.setuptools.packages.find]\nwhere = [\".\"]\n",
)
.expect("write manifest");
let info = crate::manifest::try_read_manifest(&project)
.expect("read manifest")
.expect("manifest exists");
assert_eq!(info.source_roots.len(), 1);
assert_eq!(info.test_roots.len(), 1);
let collapsed = collapse_covered_roots(
info.source_roots
.into_iter()
.chain(info.test_roots)
.collect(),
);
assert_eq!(collapsed.len(), 1);
assert_eq!(collapsed[0].label.as_deref(), Some("setuptools-find"));
}
#[test]
fn test_filter_removes_test_files_and_every_owned_entity() {
let source = FileInfo {
path: "src/app.py".into(),
language: "python".into(),
..FileInfo::default()
};
let test = FileInfo {
path: "tests/test_app.py".into(),
language: "python".into(),
is_test: true,
..FileInfo::default()
};
let source_fn = FunctionInfo {
qualified_name: "app.run".into(),
file_path: source.path.clone(),
..FunctionInfo::default()
};
let test_fn = FunctionInfo {
qualified_name: "test_app.test_run".into(),
file_path: test.path.clone(),
..FunctionInfo::default()
};
let test_class = ClassInfo {
qualified_name: "test_app.Case".into(),
file_path: test.path.clone(),
..ClassInfo::default()
};
let mut result = ParseResult {
files: vec![source, test],
functions: vec![source_fn, test_fn.clone()],
classes: vec![test_class],
type_relationships: vec![TypeRelationship {
source_type: "test_app.Case".into(),
relationship: "inherent".into(),
methods: vec![test_fn],
..TypeRelationship::default()
}],
..ParseResult::default()
};
retain_non_test_entities(&mut result);
assert_eq!(result.files.len(), 1);
assert_eq!(result.functions.len(), 1);
assert!(result.classes.is_empty());
assert!(result.type_relationships.is_empty());
}
#[test]
fn fallback_scan_honours_include_tests_false() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("project");
std::fs::create_dir_all(project.join("tests")).expect("create tests dir");
std::fs::write(project.join("app.py"), "def run():\n return 1\n").expect("write source");
std::fs::write(
project.join("tests/test_app.py"),
"def test_run():\n assert True\n",
)
.expect("write test");
let graph = run_with_options(&project, false, false, None, None, false)
.expect("build fallback project");
let nodes: Vec<(String, String)> = graph
.graph
.node_indices()
.filter_map(|index| graph.node_view(index))
.map(|node| {
(
node.node_type_str(&graph.interner).to_string(),
node.id().to_string(),
)
})
.collect();
assert!(
nodes
.iter()
.any(|(kind, id)| kind == "File" && id.contains("app.py")),
"{nodes:?}"
);
assert!(nodes.iter().all(|(_, id)| !id.contains("test_app")));
}
#[test]
fn build_rooted_at_an_ignored_dir_name_is_not_empty() {
const SOURCE: &str = "def run():\n return 1\n";
fn node_set(root: &Path) -> Vec<(String, String)> {
std::fs::create_dir_all(root).expect("create root");
std::fs::write(root.join("app.py"), SOURCE).expect("write source");
let graph =
run_with_options(root, false, false, None, None, false).expect("build project");
let base = root
.file_name()
.and_then(|n| n.to_str())
.expect("root basename");
let trimmed = base.trim_start_matches('.');
let mut nodes: Vec<(String, String)> = graph
.graph
.node_indices()
.filter_map(|index| graph.node_view(index))
.map(|node| {
(
node.node_type_str(&graph.interner).to_string(),
node.id()
.to_string()
.replace(base, "plain")
.replace(trimmed, "plain"),
)
})
.collect();
nodes.sort();
nodes
}
let temp = tempfile::tempdir().expect("tempdir");
let baseline = node_set(&temp.path().join("plain"));
assert!(
baseline
.iter()
.any(|(kind, id)| kind == "File" && id.contains("app.py")),
"baseline build must see the source file: {baseline:?}"
);
for name in [".hidden", "target", "venv", "node_modules"] {
let root = temp.path().join(name);
assert!(crate::manifest::is_ignored_dir_name(name), "{name}");
let nodes = node_set(&root);
assert!(
!nodes.is_empty(),
"build rooted at `{name}` produced an EMPTY graph"
);
assert!(
!nodes
.iter()
.any(|(kind, id)| kind == "Module" && id == "\"\""),
"build rooted at `{name}` synthesized an empty-id Module node"
);
assert_eq!(
nodes, baseline,
"build rooted at `{name}` differs from an ordinary root"
);
}
}
#[test]
fn malformed_manifest_fails_directory_and_explicit_file_builds() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("project");
std::fs::create_dir(&project).expect("create project");
let manifest = project.join("pyproject.toml");
std::fs::write(&manifest, "[project\nname = broken").expect("write manifest");
for input in [&project, &manifest] {
let error = match run_with_options(input, false, false, None, None, false) {
Ok(_) => panic!("malformed manifest must fail"),
Err(error) => error,
};
assert!(error.contains("pyproject.toml"), "{error}");
}
}
#[test]
fn project_languages_include_parsed_polyglot_files_in_registry_order() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("project");
std::fs::create_dir_all(project.join("demo")).expect("create Python package");
std::fs::write(
project.join("pyproject.toml"),
"[project]\nname = \"demo\"\n[tool.setuptools.packages.find]\nwhere = [\".\"]\n",
)
.expect("write manifest");
std::fs::write(
project.join("demo/__init__.py"),
"def run():\n return 1\n",
)
.expect("write Python");
std::fs::write(
project.join("app.ts"),
"export function render() { return 1; }\n",
)
.expect("write TypeScript");
std::fs::write(project.join("style.css"), ".app { color: red; }\n").expect("write CSS");
let graph = run_with_options(&project, false, false, None, None, false)
.expect("build polyglot project");
let languages = graph
.graph
.node_indices()
.filter_map(|index| graph.node_view(index))
.find(|node| node.node_type_str(&graph.interner) == "Project")
.and_then(|node| node.get_property("languages"))
.expect("Project.languages");
assert_eq!(
languages.as_ref(),
&Value::String("python, typescript, css".into())
);
}
#[test]
fn fallback_parsers_keep_nested_same_stem_module_identity() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("project");
for dir in ["a", "b"] {
std::fs::create_dir_all(project.join(dir)).expect("create fixture dir");
}
let fixtures = [
("html", "html", "<!doctype html><h1>Title</h1>\n", "."),
("css", "css", ".item { color: red; }\n", "."),
("php", "php", "<?php function run() { return 1; }\n", "\\"),
("swift", "swift", "func run() -> Int { return 1 }\n", "."),
("dart", "dart", "int run() { return 1; }\n", "."),
];
for (language, extension, source, separator) in fixtures {
for dir in ["a", "b"] {
std::fs::write(project.join(format!("{dir}/index.{extension}")), source)
.expect("write fixture");
}
let result = get_parser(language)
.expect("registered parser")
.parse_directory(&project, false);
let mut modules: Vec<_> = result
.files
.iter()
.filter(|file| file.filename == format!("index.{extension}"))
.map(|file| file.module_path.clone())
.collect();
modules.sort();
assert_eq!(
modules,
[
format!("project{separator}a{separator}index"),
format!("project{separator}b{separator}index"),
],
"{language}"
);
let leaf_modules: HashSet<_> = load::build_modules(&result.files)
.into_iter()
.map(|module| module.qualified_name)
.collect();
assert!(modules.iter().all(|module| leaf_modules.contains(module)));
let contains = other_edges::build_module_contains_file_edges(&result.files);
assert!(modules
.iter()
.all(|module| contains.iter().any(|edge| &edge.module == module)));
}
}
#[test]
fn nested_html_and_css_imports_resolve_to_sibling_directories() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("project");
for dir in ["a", "b"] {
std::fs::create_dir_all(project.join(dir)).expect("create fixture dir");
std::fs::write(
project.join(format!("{dir}/index.html")),
"<!doctype html><link rel=\"stylesheet\" href=\"theme.css\">\n",
)
.expect("write HTML");
std::fs::write(
project.join(format!("{dir}/index.css")),
"@import \"theme.css\";\n.item { color: red; }\n",
)
.expect("write CSS index");
std::fs::write(
project.join(format!("{dir}/theme.css")),
".theme { color: blue; }\n",
)
.expect("write CSS target");
}
let mut result = get_parser("html")
.expect("HTML parser")
.parse_directory(&project, false);
result.merge(
get_parser("css")
.expect("CSS parser")
.parse_directory(&project, false),
);
let known_modules: HashSet<_> = result
.files
.iter()
.map(|file| file.module_path.clone())
.collect();
let module_pairs: HashSet<_> = other_edges::build_import_edges(
&result.files,
&known_modules,
&js_workspace::JsWorkspace::default(),
)
.into_iter()
.map(|edge| (edge.file_path, edge.module))
.collect();
let module_to_file = result
.files
.iter()
.map(|file| (file.module_path.clone(), file.path.clone()))
.collect();
let file_pairs: HashSet<_> = other_edges::build_file_import_edges(
&result.files,
&module_to_file,
&js_workspace::JsWorkspace::default(),
)
.into_iter()
.map(|edge| (edge.source, edge.target))
.collect();
for dir in ["a", "b"] {
for source in [format!("{dir}/index.html"), format!("{dir}/index.css")] {
assert!(module_pairs.contains(&(source.clone(), format!("project.{dir}.theme"))));
assert!(file_pairs.contains(&(source, format!("{dir}/theme.css"))));
}
}
}
#[test]
fn dart_imports_resolve_with_directory_structure() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("dart_import");
std::fs::create_dir_all(project.join("lib/a")).expect("create lib/a");
std::fs::create_dir_all(project.join("lib/b")).expect("create lib/b");
std::fs::write(
project.join("lib/main.dart"),
"import 'dart:core';\n\
import 'package:dart_import/a/x.dart';\n\
import 'package:dart_import/b/x.dart' as bx;\n\
import 'a/x.dart' show aValue;\n\
int combine() { return aValue() + bx.bValue(); }\n",
)
.expect("write main");
std::fs::write(
project.join("lib/a/x.dart"),
"int aValue() { return 10; }\n",
)
.expect("write a/x");
std::fs::write(
project.join("lib/b/x.dart"),
"int bValue() { return 20; }\n",
)
.expect("write b/x");
let result = get_parser("dart")
.expect("Dart parser")
.parse_directory(&project, false);
let known_modules: HashSet<_> = result
.files
.iter()
.map(|file| file.module_path.clone())
.collect();
let module_pairs: Vec<_> = other_edges::build_import_edges(
&result.files,
&known_modules,
&js_workspace::JsWorkspace::default(),
)
.into_iter()
.map(|edge| (edge.file_path, edge.module))
.collect();
assert_eq!(
module_pairs
.iter()
.filter(|(f, m)| f == "lib/main.dart" && m == "dart_import.lib.a.x")
.count(),
2,
"package + relative imports of a/x: {module_pairs:?}"
);
assert_eq!(
module_pairs
.iter()
.filter(|(f, m)| f == "lib/main.dart" && m == "dart_import.lib.b.x")
.count(),
1,
"package import of b/x: {module_pairs:?}"
);
assert_eq!(module_pairs.len(), 3, "{module_pairs:?}");
let module_to_file = result
.files
.iter()
.map(|file| (file.module_path.clone(), file.path.clone()))
.collect();
let file_edges: Vec<_> = other_edges::build_file_import_edges(
&result.files,
&module_to_file,
&js_workspace::JsWorkspace::default(),
)
.into_iter()
.map(|edge| (edge.source, edge.target, edge.import_count))
.collect();
assert_eq!(
file_edges,
vec![
(
"lib/main.dart".to_string(),
"lib/a/x.dart".to_string(),
2i64
),
(
"lib/main.dart".to_string(),
"lib/b/x.dart".to_string(),
1i64
),
]
);
}
#[test]
fn r_source_resolves_as_file_paths() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("r_proj");
std::fs::create_dir_all(project.join("sub")).expect("create sub");
std::fs::write(
project.join("main.R"),
"source(\"utils.R\")\n\
source(\"sub/helpers.R\")\n\
source(paste0(dir, \"/dyn.R\"))\n\
main <- function() run()\n",
)
.expect("write main");
std::fs::write(project.join("utils.R"), "add_one <- function(x) x + 1\n")
.expect("write utils");
std::fs::write(
project.join("sub/helpers.R"),
"source(\"deep.R\")\nhelp_fn <- function() 1\n",
)
.expect("write helpers");
std::fs::write(project.join("sub/deep.R"), "deep_fn <- function() 2\n")
.expect("write deep");
let result = get_parser("r")
.expect("R parser")
.parse_directory(&project, false);
let known_modules: HashSet<_> = result
.files
.iter()
.map(|file| file.module_path.clone())
.collect();
let module_pairs: HashSet<_> = other_edges::build_import_edges(
&result.files,
&known_modules,
&js_workspace::JsWorkspace::default(),
)
.into_iter()
.map(|edge| (edge.file_path, edge.module))
.collect();
let expected_modules: HashSet<_> = [
("main.R", "r_proj.utils"),
("main.R", "r_proj.sub.helpers"),
("sub/helpers.R", "r_proj.sub.deep"),
]
.into_iter()
.map(|(f, m)| (f.to_string(), m.to_string()))
.collect();
assert_eq!(module_pairs, expected_modules, "{module_pairs:?}");
let module_to_file = result
.files
.iter()
.map(|file| (file.module_path.clone(), file.path.clone()))
.collect();
let file_pairs: HashSet<_> = other_edges::build_file_import_edges(
&result.files,
&module_to_file,
&js_workspace::JsWorkspace::default(),
)
.into_iter()
.map(|edge| (edge.source, edge.target))
.collect();
let expected_files: HashSet<_> = [
("main.R", "utils.R"),
("main.R", "sub/helpers.R"),
("sub/helpers.R", "sub/deep.R"),
]
.into_iter()
.map(|(s, t)| (s.to_string(), t.to_string()))
.collect();
assert_eq!(file_pairs, expected_files, "{file_pairs:?}");
}
#[test]
fn r_library_naming_a_local_module_yields_no_file_edge() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("r_proj");
std::fs::create_dir_all(&project).expect("create project");
std::fs::write(
project.join("main.R"),
"library(stats)\nlibrary(tools)\nmain <- function() pad_left(\"x\", 3)\n",
)
.expect("write main");
std::fs::write(
project.join("tools.R"),
"pad_left <- function(s, n) formatC(s, width = n)\n",
)
.expect("write tools");
let result = get_parser("r")
.expect("R parser")
.parse_directory(&project, false);
let known_modules: HashSet<_> = result
.files
.iter()
.map(|file| file.module_path.clone())
.collect();
let module_pairs: Vec<_> = other_edges::build_import_edges(
&result.files,
&known_modules,
&js_workspace::JsWorkspace::default(),
)
.into_iter()
.map(|edge| (edge.file_path, edge.module))
.collect();
assert!(
module_pairs.is_empty(),
"a package reference must not land on a colliding local module: {module_pairs:?}"
);
let module_to_file = result
.files
.iter()
.map(|file| (file.module_path.clone(), file.path.clone()))
.collect();
let file_pairs: Vec<_> = other_edges::build_file_import_edges(
&result.files,
&module_to_file,
&js_workspace::JsWorkspace::default(),
)
.into_iter()
.map(|edge| (edge.source, edge.target))
.collect();
assert!(
file_pairs.is_empty(),
"a package reference must not manufacture a File→File edge: {file_pairs:?}"
);
}
#[test]
fn overload_identity_keeps_last_exact_duplicate_and_leaves_ordinary_ids_alone() {
let ordinary = FunctionInfo {
name: "run".into(),
qualified_name: "demo.run".into(),
signature: "run()".into(),
..FunctionInfo::default()
};
let first = FunctionInfo {
name: "pick".into(),
qualified_name: "demo.Picker.pick".into(),
signature: "pick(int)".into(),
line_number: 1,
..FunctionInfo::default()
};
let duplicate = FunctionInfo {
line_number: 2,
..first.clone()
};
let second = FunctionInfo {
signature: "pick(string)".into(),
line_number: 3,
..first.clone()
};
let mut functions = vec![ordinary, first, duplicate, second];
let mut relationships = vec![TypeRelationship {
source_type: "demo.Picker".into(),
relationship: "inherent".into(),
methods: functions[1..].to_vec(),
..TypeRelationship::default()
}];
preserve_overloaded_function_ids(&mut functions, &mut relationships);
assert_eq!(functions.len(), 3);
assert_eq!(functions[0].qualified_name, "demo.run");
let overloads: Vec<_> = functions
.iter()
.filter(|function| function.name == "pick")
.collect();
assert_eq!(overloads.len(), 2);
assert!(overloads.iter().any(|function| function.line_number == 2));
assert!(!overloads.iter().any(|function| function.line_number == 1));
for function in overloads {
assert_eq!(
overload_base_qualified_name(&function.qualified_name),
Some("demo.Picker.pick")
);
}
assert!(relationships[0]
.methods
.iter()
.all(
|method| overload_base_qualified_name(&method.qualified_name)
== Some("demo.Picker.pick")
));
}
#[test]
fn polyglot_overloads_have_stable_nodes_and_conservative_calls() {
let fixtures = [
(
"cpp",
"Picker.cpp",
r#"class Picker {
public:
int pick(int value) { return value; }
int pick(double value) { return static_cast<int>(value); }
int run() { return pick(1); }
};
"#,
),
(
"java",
"Picker.java",
r#"class Picker {
int pick(int value) { return value; }
int pick(long value) { return (int) value; }
int run() { return pick(1); }
}
"#,
),
(
"csharp",
"Picker.cs",
r#"class Picker {
public int pick(int value) { return value; }
public int pick(string value) { return value.Length; }
public int run() { return pick(1); }
}
"#,
),
(
"typescript",
"Picker.ts",
r#"class Picker {
pick(value: number) { return value; }
pick(value: string) { return value.length; }
run() { return this.pick(1); }
}
"#,
),
];
for (language, filename, source) in fixtures {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("project");
std::fs::create_dir(&project).expect("create fixture root");
std::fs::write(project.join(filename), source).expect("write overload fixture");
let first = run_with_options(&project, false, false, None, None, false)
.unwrap_or_else(|error| panic!("{language} first build: {error}"));
let second = run_with_options(&project, false, false, None, None, false)
.unwrap_or_else(|error| panic!("{language} second build: {error}"));
let overload_rows = |graph: &DirGraph| {
let mut rows: Vec<(String, String)> = graph
.graph
.node_indices()
.filter_map(|index| graph.node_view(index))
.filter(|node| {
node.node_type_str(&graph.interner) == "Function"
&& node.title().as_ref() == &Value::String("pick".into())
})
.map(|node| {
let signature = match node.get_property_value("signature") {
Some(Value::String(value)) => value,
value => panic!("{language} signature missing: {value:?}"),
};
let qualified_name = match node.id().as_ref() {
Value::String(value) => value.clone(),
value => panic!("{language} function ID is not text: {value:?}"),
};
(qualified_name, signature)
})
.collect();
rows.sort();
rows
};
let first_rows = overload_rows(&first);
let second_rows = overload_rows(&second);
assert_eq!(first_rows.len(), 2, "{language}: {first_rows:?}");
assert_eq!(first_rows, second_rows, "{language} IDs changed");
assert_ne!(first_rows[0].0, first_rows[1].0, "{language}");
assert_ne!(first_rows[0].1, first_rows[1].1, "{language}");
for (qualified_name, _) in &first_rows {
let (_, discriminator) = qualified_name
.rsplit_once(OVERLOAD_ID_SEPARATOR)
.unwrap_or_else(|| {
panic!("{language} ID lacks discriminator: {qualified_name}")
});
assert_eq!(discriminator.len(), SHA256_HEX_LEN, "{language}");
assert!(discriminator.bytes().all(|byte| byte.is_ascii_hexdigit()));
}
let calls_to_overloads = first
.graph
.edge_indices()
.filter(|&edge_index| {
let Some(edge) = first.graph.edge_weight(edge_index) else {
return false;
};
if edge.connection_type_str(&first.interner) != "CALLS" {
return false;
}
let Some((source, target)) = first.graph.edge_endpoints(edge_index) else {
return false;
};
let (Some(source), Some(target)) =
(first.node_view(source), first.node_view(target))
else {
return false;
};
source.title().as_ref() == &Value::String("run".into())
&& target.title().as_ref() == &Value::String("pick".into())
})
.count();
assert_eq!(calls_to_overloads, 2, "{language}");
}
}
fn count_label(graph: &DirGraph, label: &str) -> usize {
graph
.graph
.node_indices()
.filter_map(|index| graph.node_view(index))
.filter(|node| node.node_type_str(&graph.interner) == label)
.count()
}
fn count_conn(graph: &DirGraph, conn: &str) -> usize {
graph
.graph
.edge_indices()
.filter_map(|index| graph.graph.edge_weight(index))
.filter(|edge| edge.connection_type_str(&graph.interner) == conn)
.count()
}
fn sole_project(graph: &DirGraph) -> (String, BTreeMap<String, Value>) {
let mut found: Vec<(String, BTreeMap<String, Value>)> = graph
.graph
.node_indices()
.filter_map(|index| graph.node_view(index))
.filter(|node| node.node_type_str(&graph.interner) == "Project")
.map(|node| {
let id = match &*node.id() {
Value::String(name) => name.clone(),
other => panic!("unexpected :Project id {other:?}"),
};
(
id,
node.properties_cloned(&graph.interner)
.into_iter()
.collect(),
)
})
.collect();
assert_eq!(found.len(), 1, "expected exactly one :Project node");
found.pop().expect("one project")
}
#[test]
fn manifestless_repository_gets_an_inferred_project_anchor() {
let temp = tempfile::tempdir().expect("tempdir");
let project = temp.path().join("plainrepo");
std::fs::create_dir_all(&project).expect("create project dir");
std::fs::write(project.join("app.py"), "def compute():\n return 1\n")
.expect("write source");
std::fs::write(
project.join("README.md"),
"# Plain repo\nCalls `compute` on start.\n",
)
.expect("write doc");
let include_docs = cfg!(feature = "docs");
let graph = run_with_options(&project, false, true, None, None, include_docs)
.expect("build manifestless project");
let (id, props) = sole_project(&graph);
assert_eq!(id, "plainrepo", "project is named after the project root");
assert_eq!(
props.get("manifest"),
Some(&Value::String(crate::models::INFERRED_MANIFEST.to_string())),
"inferred projects carry the sentinel in `manifest`: {props:?}"
);
assert_eq!(
props.get("languages"),
Some(&Value::String("python".to_string())),
"languages are reconciled from the parsed files: {props:?}"
);
let files = count_label(&graph, "File");
assert!(files >= 1, "the source file was parsed");
assert_eq!(
count_conn(&graph, "HAS_SOURCE"),
files,
"every File is owned by the inferred Project"
);
#[cfg(feature = "docs")]
{
let docs = count_label(&graph, "Doc");
assert!(docs >= 1, "the README became a :Doc node");
assert_eq!(
count_conn(&graph, "HAS_DOC"),
docs,
"every Doc is anchored to the Project"
);
}
}
const PROJECT_PROPERTY_COLUMNS: &[&str] = &[
"version",
"description",
"languages",
"authors",
"license",
"repository",
"build_system",
"crate_type",
"manifest",
];
#[test]
fn manifest_backed_project_keeps_the_same_property_columns() {
let temp = tempfile::tempdir().expect("tempdir");
let include_docs = cfg!(feature = "docs");
let inferred_root = temp.path().join("inferred");
std::fs::create_dir_all(&inferred_root).expect("create dir");
std::fs::write(
inferred_root.join("app.py"),
"def compute():\n return 1\n",
)
.expect("write source");
std::fs::write(inferred_root.join("README.md"), "# Demo\nRuns `compute`.\n")
.expect("write doc");
let inferred = run_with_options(&inferred_root, false, true, None, None, include_docs)
.expect("build manifestless project");
let backed_root = temp.path().join("backed");
std::fs::create_dir_all(&backed_root).expect("create dir");
std::fs::write(backed_root.join("app.py"), "def compute():\n return 1\n")
.expect("write source");
std::fs::write(backed_root.join("README.md"), "# Demo\nRuns `compute`.\n")
.expect("write doc");
std::fs::write(
backed_root.join("pyproject.toml"),
"[project]\nname = \"demo\"\nversion = \"1.2.3\"\ndescription = \"demo project\"\n\
license = \"MIT\"\nauthors = [{ name = \"A. Dev\" }]\ndependencies = [\"requests\"]\n\
[project.urls]\nRepository = \"https://example.invalid/demo\"\n\
[build-system]\nbuild-backend = \"setuptools.build_meta\"\n",
)
.expect("write manifest");
let backed = run_with_options(&backed_root, false, true, None, None, include_docs)
.expect("build manifest-backed project");
let (inferred_id, inferred_props) = sole_project(&inferred);
let (backed_id, backed_props) = sole_project(&backed);
assert_eq!(inferred_id, "inferred");
assert_eq!(backed_id, "demo", "manifest-declared name wins");
assert_eq!(
backed_props.get("manifest"),
Some(&Value::String("pyproject.toml".to_string())),
"manifest-backed projects keep the real manifest path: {backed_props:?}"
);
let vocabulary: BTreeSet<&str> = PROJECT_PROPERTY_COLUMNS.iter().copied().collect();
let inferred_keys: BTreeSet<&str> = inferred_props.keys().map(|key| key.as_str()).collect();
let backed_keys: BTreeSet<&str> = backed_props.keys().map(|key| key.as_str()).collect();
assert!(
inferred_keys.is_subset(&vocabulary) && backed_keys.is_subset(&vocabulary),
"a Project property outside the fixed column set: inferred={inferred_keys:?} backed={backed_keys:?}"
);
assert!(
inferred_keys.is_subset(&backed_keys),
"inference must not introduce a Project property column: inferred={inferred_keys:?} backed={backed_keys:?}"
);
#[cfg(feature = "docs")]
{
let docs = count_label(&backed, "Doc");
assert!(docs >= 1, "the README became a :Doc node");
assert_eq!(
count_conn(&backed, "HAS_DOC"),
docs,
"manifest-backed projects anchor their docs too"
);
}
}
}