use std::collections::{HashMap, HashSet};
use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::Datelike;
use khive_runtime::pack::{PackRegistry, VerbRegistryBuilder};
use khive_runtime::{base_entity_rule_allows, endpoint_matches, KhiveRuntime, RuntimeConfig};
use khive_storage::EdgeRelation;
use khive_types::EdgeEndpointRule;
use serde::Deserialize;
use super::types::{
OutputFormat, RuleResult, ValidateArgs, ValidationReport, ValidationSummary, Violation,
};
pub(super) struct KgTaxonomy {
pub(super) entity_kinds: HashSet<String>,
pub(super) note_kinds: HashSet<String>,
}
pub(super) fn build_taxonomy() -> Result<KgTaxonomy> {
let config = RuntimeConfig {
db_path: None,
default_namespace: khive_runtime::Namespace::parse("kkernel-validate")
.unwrap_or_else(|_| khive_runtime::Namespace::local()),
embedding_model: None,
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).context("building taxonomy registry")?;
let mut builder = VerbRegistryBuilder::new();
let names: Vec<String> = PackRegistry::discovered_names()
.into_iter()
.map(str::to_string)
.collect();
PackRegistry::register_packs(&names, runtime.clone(), &mut builder)
.map_err(|n| anyhow::anyhow!("pack {n:?} declared in inventory but factory missing"))?;
let registry = builder.build().context("building VerbRegistry")?;
let entity_kinds = registry
.all_entity_kinds()
.into_iter()
.map(str::to_string)
.collect();
let note_kinds = registry
.all_note_kinds()
.into_iter()
.map(str::to_string)
.collect();
Ok(KgTaxonomy {
entity_kinds,
note_kinds,
})
}
fn build_pack_edge_rules() -> Result<Vec<EdgeEndpointRule>> {
let config = RuntimeConfig {
db_path: None,
default_namespace: khive_runtime::Namespace::parse("kkernel-validate")
.unwrap_or_else(|_| khive_runtime::Namespace::local()),
embedding_model: None,
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).context("building edge-rules registry")?;
let mut builder = VerbRegistryBuilder::new();
let names: Vec<String> = PackRegistry::discovered_names()
.into_iter()
.map(str::to_string)
.collect();
PackRegistry::register_packs(&names, runtime.clone(), &mut builder)
.map_err(|n| anyhow::anyhow!("pack {n:?} declared in inventory but factory missing"))?;
let registry = builder.build().context("building VerbRegistry")?;
Ok(registry.all_edge_rules())
}
pub(super) fn cmd_validate(args: ValidateArgs) -> Result<()> {
let kg_dir = args.repo.join(".khive/kg");
if !kg_dir.exists() {
bail!(
"KG directory not found: {}. Run `kkernel kg init` first.",
kg_dir.display()
);
}
let entities_path = kg_dir.join("entities.ndjson");
let edges_path = kg_dir.join("edges.ndjson");
let notes_path = kg_dir.join("notes.ndjson");
let entities = count_ndjson_lines(&entities_path).unwrap_or(0);
let edges = count_ndjson_lines(&edges_path).unwrap_or(0);
let rules_path = args.rules.unwrap_or_else(|| kg_dir.join("rules.toml"));
let taxonomy = build_taxonomy()?;
let mut rule_results: Vec<RuleResult> =
structural_checks(&entities_path, &edges_path, ¬es_path, &taxonomy);
if !args.no_rules && rules_path.exists() {
let configurable =
configurable_rule_checks(&entities_path, &edges_path, ¬es_path, &rules_path)?;
rule_results.extend(configurable);
}
let errors: usize = rule_results
.iter()
.filter(|r| r.severity == "error" && !r.passed)
.count();
let warnings: usize = rule_results
.iter()
.filter(|r| r.severity == "warning" && !r.passed)
.count();
let info: usize = rule_results
.iter()
.filter(|r| r.severity == "info" && !r.passed)
.count();
let passed = if args.strict {
errors == 0 && warnings == 0
} else {
errors == 0
};
let summary = ValidationSummary {
errors,
warnings,
info,
entities,
edges,
passed,
};
let report = ValidationReport {
rules: rule_results,
summary,
};
match args.format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&report).expect("serialize ValidationReport");
println!("{json}");
}
OutputFormat::Github => print_github_format(&report),
OutputFormat::Text => print_text_format(&report, args.verbose, args.quiet),
}
if args.fix {
apply_fixes(&args.repo)?;
}
if !report.summary.passed {
std::process::exit(1);
}
Ok(())
}
fn count_ndjson_lines(path: &Path) -> Option<usize> {
let content = std::fs::read_to_string(path).ok()?;
Some(content.lines().filter(|l| !l.trim().is_empty()).count())
}
fn structural_checks(
entities_path: &Path,
edges_path: &Path,
notes_path: &Path,
taxonomy: &KgTaxonomy,
) -> Vec<RuleResult> {
let mut results = vec![
check_schema_compliance(entities_path, edges_path, notes_path),
check_no_duplicate_uuids(entities_path),
check_sort_order(entities_path, edges_path),
check_referential_integrity(entities_path, notes_path, edges_path),
check_valid_entity_kinds(entities_path, &taxonomy.entity_kinds),
check_valid_edge_relations(edges_path),
];
if notes_path.exists() {
results.push(check_valid_note_kinds(notes_path, &taxonomy.note_kinds));
}
results
}
fn schema_violation(file: &str, line_no: usize, message: impl std::fmt::Display) -> Violation {
Violation {
entity_id: None,
entity_name: None,
entity_kind: None,
rule_id: "schema-compliance".into(),
severity: "error",
message: format!("{file} line {line_no}: {message}"),
fixable: false,
}
}
fn check_schema_compliance(
entities_path: &Path,
edges_path: &Path,
notes_path: &Path,
) -> RuleResult {
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(entities_path) {
for (idx, line) in content.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_no = idx + 1;
match serde_json::from_str::<serde_json::Value>(line) {
Ok(v) => {
let missing: Vec<&str> = ["id", "kind", "name"]
.into_iter()
.filter(|f| v.get(f).and_then(|x| x.as_str()).is_none())
.collect();
if !missing.is_empty() {
violations.push(schema_violation(
"entities.ndjson",
line_no,
format!("missing required field(s): {}", missing.join(", ")),
));
}
}
Err(e) => {
violations.push(schema_violation(
"entities.ndjson",
line_no,
format!("invalid JSON: {e}"),
));
}
}
}
}
if let Ok(content) = std::fs::read_to_string(edges_path) {
for (idx, line) in content.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_no = idx + 1;
match serde_json::from_str::<serde_json::Value>(line) {
Ok(v) => {
let missing: Vec<&str> = ["source_id", "target_id", "relation"]
.into_iter()
.filter(|f| v.get(f).and_then(|x| x.as_str()).is_none())
.collect();
if !missing.is_empty() {
violations.push(schema_violation(
"edges.ndjson",
line_no,
format!("missing required field(s): {}", missing.join(", ")),
));
}
}
Err(e) => {
violations.push(schema_violation(
"edges.ndjson",
line_no,
format!("invalid JSON: {e}"),
));
}
}
}
}
if notes_path.exists() {
if let Ok(content) = std::fs::read_to_string(notes_path) {
for (idx, line) in content.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_no = idx + 1;
match serde_json::from_str::<serde_json::Value>(line) {
Ok(v) => {
let missing: Vec<&str> = ["id", "kind"]
.into_iter()
.filter(|f| v.get(f).and_then(|x| x.as_str()).is_none())
.collect();
if !missing.is_empty() {
violations.push(schema_violation(
"notes.ndjson",
line_no,
format!("missing required field(s): {}", missing.join(", ")),
));
}
}
Err(e) => {
violations.push(schema_violation(
"notes.ndjson",
line_no,
format!("invalid JSON: {e}"),
));
}
}
}
}
}
RuleResult {
id: "schema-compliance".into(),
severity: "error",
passed: violations.is_empty(),
violations,
}
}
fn record_prefix(entity_id: Option<&str>, entity_name: Option<&str>) -> String {
match (entity_id, entity_name) {
(Some(id), Some(name)) => format!("[{id} {name:?}] "),
(Some(id), None) => format!("[{id}] "),
(None, Some(name)) => format!("[{name:?}] "),
(None, None) => String::new(),
}
}
pub(super) fn check_valid_entity_kinds(
entities_path: &Path,
valid_kinds: &HashSet<String>,
) -> RuleResult {
let valid_list = {
let mut v: Vec<&str> = valid_kinds.iter().map(String::as_str).collect();
v.sort_unstable();
v.join(" | ")
};
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(entities_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(kind_str) = v.get("kind").and_then(|k| k.as_str()) {
if !valid_kinds.contains(kind_str) {
let id = v
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let name = v.get("name").and_then(|n| n.as_str()).map(str::to_string);
let prefix = record_prefix(
if id.is_empty() { None } else { Some(&id) },
name.as_deref(),
);
violations.push(Violation {
entity_id: if id.is_empty() { None } else { Some(id) },
entity_name: name,
entity_kind: Some(kind_str.to_string()),
rule_id: "valid-entity-kinds".into(),
severity: "error",
message: format!(
"{prefix}unknown entity_kind: {kind_str:?}. \
Valid: {valid_list}"
),
fixable: false,
});
}
}
}
}
}
RuleResult {
id: "valid-entity-kinds".into(),
severity: "error",
passed: violations.is_empty(),
violations,
}
}
pub(super) fn check_valid_note_kinds(
notes_path: &Path,
valid_kinds: &HashSet<String>,
) -> RuleResult {
let valid_list = {
let mut v: Vec<&str> = valid_kinds.iter().map(String::as_str).collect();
v.sort_unstable();
v.join(" | ")
};
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(notes_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(kind_str) = v.get("kind").and_then(|k| k.as_str()) {
if !valid_kinds.contains(kind_str) {
let id = v
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let name = v.get("name").and_then(|n| n.as_str()).map(str::to_string);
let prefix = record_prefix(
if id.is_empty() { None } else { Some(&id) },
name.as_deref(),
);
violations.push(Violation {
entity_id: if id.is_empty() { None } else { Some(id) },
entity_name: name,
entity_kind: Some(kind_str.to_string()),
rule_id: "valid-note-kinds".into(),
severity: "error",
message: format!(
"{prefix}unknown note_kind: {kind_str:?}. \
Valid: {valid_list}"
),
fixable: false,
});
}
}
}
}
}
RuleResult {
id: "valid-note-kinds".into(),
severity: "error",
passed: violations.is_empty(),
violations,
}
}
fn check_valid_edge_relations(edges_path: &Path) -> RuleResult {
let valid_list = {
let mut names = EdgeRelation::VALID_NAMES.to_vec();
names.sort_unstable();
names.join(" | ")
};
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(edges_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(rel_str) = v.get("relation").and_then(|r| r.as_str()) {
if !EdgeRelation::VALID_NAMES.contains(&rel_str) {
let edge_id = v
.get("edge_id")
.and_then(|i| i.as_str())
.or_else(|| v.get("id").and_then(|i| i.as_str()))
.unwrap_or("")
.to_string();
let src = v
.get("source_id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let tgt = v
.get("target_id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let id_display = if !edge_id.is_empty() {
edge_id.clone()
} else if !src.is_empty() && !tgt.is_empty() {
format!("{src}→{tgt}")
} else {
String::new()
};
let prefix = if id_display.is_empty() {
String::new()
} else {
format!("[{id_display}] ")
};
violations.push(Violation {
entity_id: if edge_id.is_empty() {
None
} else {
Some(edge_id)
},
entity_name: None,
entity_kind: None,
rule_id: "valid-edge-relations".into(),
severity: "error",
message: format!(
"{prefix}unknown edge relation: {rel_str:?}. \
Valid: {valid_list}"
),
fixable: false,
});
}
}
}
}
}
RuleResult {
id: "valid-edge-relations".into(),
severity: "error",
passed: violations.is_empty(),
violations,
}
}
fn check_no_duplicate_uuids(entities_path: &Path) -> RuleResult {
let mut seen = std::collections::HashSet::new();
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(entities_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(id) = v.get("id").and_then(|i| i.as_str()) {
if !seen.insert(id.to_string()) {
violations.push(Violation {
entity_id: Some(id.to_string()),
entity_name: v.get("name").and_then(|n| n.as_str()).map(str::to_string),
entity_kind: v.get("kind").and_then(|k| k.as_str()).map(str::to_string),
rule_id: "no-duplicate-uuids".into(),
severity: "error",
message: format!("Duplicate UUID: {id}"),
fixable: false,
});
}
}
}
}
}
RuleResult {
id: "no-duplicate-uuids".into(),
severity: "error",
passed: violations.is_empty(),
violations,
}
}
fn check_sort_order(entities_path: &Path, edges_path: &Path) -> RuleResult {
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(entities_path) {
let ids: Vec<String> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
serde_json::from_str::<serde_json::Value>(l)
.ok()
.and_then(|v| v.get("id")?.as_str().map(str::to_string))
})
.collect();
let mut sorted = ids.clone();
sorted.sort();
if ids != sorted {
violations.push(Violation {
entity_id: None,
entity_name: None,
entity_kind: None,
rule_id: "sort-order".into(),
severity: "warning",
message: "entities.ndjson is not sorted by UUID; run `kkernel kg validate --fix`"
.into(),
fixable: true,
});
}
}
if let Ok(content) = std::fs::read_to_string(edges_path) {
let keys: Vec<(String, String, String)> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
let v: serde_json::Value = serde_json::from_str(l).ok()?;
let s = v.get("source_id")?.as_str()?.to_string();
let t = v.get("target_id")?.as_str()?.to_string();
let r = v.get("relation")?.as_str()?.to_string();
Some((s, t, r))
})
.collect();
let mut sorted = keys.clone();
sorted.sort();
if keys != sorted {
violations.push(Violation {
entity_id: None,
entity_name: None,
entity_kind: None,
rule_id: "sort-order".into(),
severity: "warning",
message:
"edges.ndjson is not sorted by (source, target, relation); run `kkernel kg validate --fix`"
.into(),
fixable: true,
});
}
}
RuleResult {
id: "sort-order".into(),
severity: "warning",
passed: violations.is_empty(),
violations,
}
}
fn collect_ids(path: &Path) -> std::collections::HashSet<String> {
std::fs::read_to_string(path)
.map(|content| {
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
serde_json::from_str::<serde_json::Value>(l)
.ok()
.and_then(|v| v.get("id")?.as_str().map(str::to_string))
})
.collect()
})
.unwrap_or_default()
}
fn collect_edge_ids(path: &Path) -> std::collections::HashSet<String> {
std::fs::read_to_string(path)
.map(|content| {
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
let v: serde_json::Value = serde_json::from_str(l).ok()?;
v.get("edge_id")
.or_else(|| v.get("id"))
.and_then(|i| i.as_str())
.map(str::to_string)
})
.collect()
})
.unwrap_or_default()
}
fn check_referential_integrity(
entities_path: &Path,
notes_path: &Path,
edges_path: &Path,
) -> RuleResult {
let mut violations = Vec::new();
let mut known_ids = collect_ids(entities_path);
known_ids.extend(collect_ids(notes_path));
known_ids.extend(collect_edge_ids(edges_path));
if let Ok(content) = std::fs::read_to_string(edges_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
for field in &["source_id", "target_id"] {
if let Some(id) = v.get(field).and_then(|i| i.as_str()) {
if !known_ids.contains(id) {
violations.push(Violation {
entity_id: Some(id.to_string()),
entity_name: None,
entity_kind: None,
rule_id: "referential-integrity".into(),
severity: "error",
message: format!(
"Edge {} references unknown record: {id}",
if *field == "source_id" {
"source"
} else {
"target"
}
),
fixable: false,
});
}
}
}
}
}
}
RuleResult {
id: "referential-integrity".into(),
severity: "error",
passed: violations.is_empty(),
violations,
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RuleConfig {
id: String,
#[serde(default = "default_severity")]
severity: String,
kind: String,
condition: Option<String>,
require_field: Option<String>,
#[serde(default)]
message: String,
}
fn default_severity() -> String {
"warning".to_owned()
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RulesFile {
#[serde(default)]
rules: Vec<RuleConfig>,
#[serde(default)]
edge_endpoint_types: Option<EdgeEndpointTypesConfig>,
#[serde(default)]
edge_direction_conventions: Option<EdgeDirectionConventionsConfig>,
#[serde(default)]
dangling_refs: Option<DanglingRefsConfig>,
#[serde(default)]
naming_conventions: Option<NamingConventionsConfig>,
#[serde(default)]
citation_date_lint: Option<CitationDateLintConfig>,
}
fn default_enabled() -> bool {
true
}
fn default_severity_error() -> String {
"error".to_owned()
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct EdgeEndpointTypesConfig {
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default = "default_severity_error")]
severity: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DirectionRuleConfig {
relation: String,
#[serde(default)]
forward_source_kinds: Vec<String>,
#[serde(default)]
forward_target_kinds: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct EdgeDirectionConventionsConfig {
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default = "default_severity")]
severity: String,
#[serde(default)]
relations: Vec<DirectionRuleConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DanglingRefsConfig {
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default = "default_severity_error")]
severity: String,
}
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct NamingConventionsOverride {
max_length: Option<usize>,
no_leading_trailing_whitespace: Option<bool>,
no_parenthetical_suffix: Option<bool>,
}
fn default_max_length() -> usize {
200
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct NamingConventionsConfig {
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default = "default_severity")]
severity: String,
#[serde(default = "default_max_length")]
max_length: usize,
#[serde(default = "default_enabled")]
no_leading_trailing_whitespace: bool,
#[serde(default = "default_enabled")]
no_parenthetical_suffix: bool,
#[serde(default)]
kinds: std::collections::BTreeMap<String, NamingConventionsOverride>,
}
fn default_date_lint_fields() -> Vec<String> {
vec![
"year".to_owned(),
"date".to_owned(),
"published_at".to_owned(),
"publication_date".to_owned(),
]
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CitationDateLintConfig {
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default = "default_severity")]
severity: String,
#[serde(default = "default_date_lint_fields")]
fields: Vec<String>,
}
fn severity_static(s: &str) -> &'static str {
match s {
"error" => "error",
"info" => "info",
_ => "warning",
}
}
fn validate_severity(rule_id: &str, s: &str) -> Option<RuleResult> {
match s {
"error" | "warning" | "info" => None,
other => Some(RuleResult {
id: rule_id.to_string(),
severity: "error",
passed: false,
violations: vec![Violation {
entity_id: None,
entity_name: None,
entity_kind: None,
rule_id: rule_id.to_string(),
severity: "error",
message: format!(
"Rule {rule_id:?}: invalid severity {other:?}; \
must be \"error\", \"warning\", or \"info\""
),
fixable: false,
}],
}),
}
}
fn validate_direction_rule_entries(relations: &[DirectionRuleConfig]) -> Result<()> {
for (idx, rule) in relations.iter().enumerate() {
if rule.relation.parse::<EdgeRelation>().is_err() {
bail!(
"edge_direction_conventions.relations[{idx}]: {:?} is not a valid edge relation",
rule.relation
);
}
if rule.forward_source_kinds.is_empty() {
bail!(
"edge_direction_conventions.relations[{idx}] ({:?}): \
forward_source_kinds must be non-empty",
rule.relation
);
}
if rule.forward_target_kinds.is_empty() {
bail!(
"edge_direction_conventions.relations[{idx}] ({:?}): \
forward_target_kinds must be non-empty",
rule.relation
);
}
}
Ok(())
}
pub(super) fn configurable_rule_checks(
entities_path: &Path,
edges_path: &Path,
notes_path: &Path,
rules_path: &Path,
) -> Result<Vec<RuleResult>> {
configurable_rule_checks_impl(entities_path, edges_path, notes_path, rules_path, false)
}
pub(super) fn configurable_rule_checks_partial_view(
entities_path: &Path,
edges_path: &Path,
notes_path: &Path,
rules_path: &Path,
) -> Result<Vec<RuleResult>> {
configurable_rule_checks_impl(entities_path, edges_path, notes_path, rules_path, true)
}
fn configurable_rule_checks_impl(
entities_path: &Path,
edges_path: &Path,
notes_path: &Path,
rules_path: &Path,
skip_dangling_refs_partial_view_finding: bool,
) -> Result<Vec<RuleResult>> {
let ext = rules_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if matches!(ext, "yaml" | "yml") {
bail!(
"rules file {:?} uses YAML format which is not supported in this build. \
Rename it to {}.toml and use TOML format instead.",
rules_path,
rules_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("rules")
);
}
let content = std::fs::read_to_string(rules_path)
.with_context(|| format!("read rules file {}", rules_path.display()))?;
let rules_file: RulesFile = toml::from_str(&content)
.with_context(|| format!("parse rules TOML {}", rules_path.display()))?;
if let Some(cfg) = &rules_file.edge_direction_conventions {
validate_direction_rule_entries(&cfg.relations)
.with_context(|| format!("validate rules TOML {}", rules_path.display()))?;
}
let mut results = Vec::with_capacity(rules_file.rules.len());
for rule in &rules_file.rules {
if let Some(err_result) = validate_severity(&rule.id, &rule.severity) {
results.push(err_result);
continue;
}
let path = match rule.kind.as_str() {
"entity" => entities_path,
"edge" => edges_path,
other => {
results.push(RuleResult {
id: rule.id.clone(),
severity: "error",
passed: false,
violations: vec![Violation {
entity_id: None,
entity_name: None,
entity_kind: None,
rule_id: rule.id.clone(),
severity: "error",
message: format!(
"Rule {:?}: unknown kind {other:?}; must be \"entity\" or \"edge\"",
rule.id
),
fixable: false,
}],
});
continue;
}
};
let violations = evaluate_rule(rule, path);
let sev = severity_static(&rule.severity);
results.push(RuleResult {
id: rule.id.clone(),
severity: sev,
passed: violations.is_empty(),
violations,
});
}
if let Some(cfg) = &rules_file.edge_endpoint_types {
if cfg.enabled {
if let Some(err_result) = validate_severity("edge-endpoint-types", &cfg.severity) {
results.push(err_result);
} else {
let pack_rules = build_pack_edge_rules()
.context("building pack edge-endpoint rules for edge-endpoint-types")?;
results.push(check_edge_endpoint_types(
entities_path,
notes_path,
edges_path,
&pack_rules,
cfg,
));
}
}
}
if let Some(cfg) = &rules_file.edge_direction_conventions {
if cfg.enabled {
if let Some(err_result) = validate_severity("edge-direction-conventions", &cfg.severity)
{
results.push(err_result);
} else {
results.push(check_edge_direction_conventions(
entities_path,
notes_path,
edges_path,
cfg,
));
}
}
}
if let Some(cfg) = &rules_file.dangling_refs {
if cfg.enabled {
if let Some(err_result) = validate_severity("dangling-refs", &cfg.severity) {
results.push(err_result);
} else if !skip_dangling_refs_partial_view_finding {
results.push(check_dangling_refs(
entities_path,
notes_path,
edges_path,
cfg,
));
}
}
}
if let Some(cfg) = &rules_file.naming_conventions {
if cfg.enabled {
if let Some(err_result) = validate_severity("naming-conventions", &cfg.severity) {
results.push(err_result);
} else {
results.push(check_naming_conventions(entities_path, cfg));
}
}
}
if let Some(cfg) = &rules_file.citation_date_lint {
if cfg.enabled {
if let Some(err_result) = validate_severity("citation-date-lint", &cfg.severity) {
results.push(err_result);
} else {
results.push(check_citation_date_lint(entities_path, notes_path, cfg));
}
}
}
Ok(results)
}
fn evaluate_rule(rule: &RuleConfig, path: &Path) -> Vec<Violation> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return vec![],
};
let condition: Option<(&str, &str)> = rule.condition.as_deref().and_then(|c| c.split_once('='));
let sev = severity_static(&rule.severity);
let mut violations = Vec::new();
for line in content.lines().filter(|l| !l.trim().is_empty()) {
let v: serde_json::Value = match serde_json::from_str(line) {
Ok(val) => val,
Err(_) => continue,
};
if let Some((field, expected)) = condition {
if field == "source_id" && expected == "target_id" {
let src = v.get("source_id").and_then(|s| s.as_str()).unwrap_or("");
let tgt = v.get("target_id").and_then(|s| s.as_str()).unwrap_or("");
if src == tgt {
violations.push(Violation {
entity_id: Some(src.to_owned()),
entity_name: None,
entity_kind: v
.get("relation")
.and_then(|r| r.as_str())
.map(str::to_owned),
rule_id: rule.id.clone(),
severity: sev,
message: rule.message.replace("{id}", src),
fixable: false,
});
}
continue;
}
let actual = v.get(field).and_then(|f| f.as_str()).unwrap_or("");
if actual != expected {
continue;
}
}
if let Some(req) = rule.require_field.as_deref() {
let val = v.get(req).and_then(|f| f.as_str()).unwrap_or("");
if val.is_empty() {
let id = v.get("id").and_then(|i| i.as_str()).unwrap_or("");
violations.push(Violation {
entity_id: if id.is_empty() {
None
} else {
Some(id.to_owned())
},
entity_name: v.get("name").and_then(|n| n.as_str()).map(str::to_owned),
entity_kind: v.get("kind").and_then(|k| k.as_str()).map(str::to_owned),
rule_id: rule.id.clone(),
severity: sev,
message: rule.message.replace("{id}", id),
fixable: false,
});
}
}
}
violations
}
struct KindInfo {
substrate: &'static str,
kind: String,
entity_type: Option<String>,
}
fn collect_kind_map(
entities_path: &Path,
notes_path: &Path,
edges_path: &Path,
) -> HashMap<String, KindInfo> {
let mut map = HashMap::new();
if let Ok(content) = std::fs::read_to_string(entities_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let (Some(id), Some(kind)) = (
v.get("id").and_then(|i| i.as_str()),
v.get("kind").and_then(|k| k.as_str()),
) {
map.insert(
id.to_string(),
KindInfo {
substrate: "entity",
kind: kind.to_string(),
entity_type: v
.get("entity_type")
.and_then(|t| t.as_str())
.map(str::to_string),
},
);
}
}
}
}
if let Ok(content) = std::fs::read_to_string(notes_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let (Some(id), Some(kind)) = (
v.get("id").and_then(|i| i.as_str()),
v.get("kind").and_then(|k| k.as_str()),
) {
map.insert(
id.to_string(),
KindInfo {
substrate: "note",
kind: kind.to_string(),
entity_type: None,
},
);
}
}
}
}
for id in collect_edge_ids(edges_path) {
map.entry(id).or_insert(KindInfo {
substrate: "edge",
kind: "edge".to_string(),
entity_type: None,
});
}
map
}
fn pack_rule_allows_kinds(
rules: &[EdgeEndpointRule],
relation: EdgeRelation,
src: &KindInfo,
tgt: &KindInfo,
) -> bool {
rules.iter().any(|r| {
r.relation == relation
&& endpoint_matches(
&r.source,
src.substrate,
&src.kind,
src.entity_type.as_deref(),
)
&& endpoint_matches(
&r.target,
tgt.substrate,
&tgt.kind,
tgt.entity_type.as_deref(),
)
})
}
fn check_edge_endpoint_types(
entities_path: &Path,
notes_path: &Path,
edges_path: &Path,
pack_rules: &[EdgeEndpointRule],
cfg: &EdgeEndpointTypesConfig,
) -> RuleResult {
let kind_map = collect_kind_map(entities_path, notes_path, edges_path);
let sev = severity_static(&cfg.severity);
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(edges_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(src_id) = v.get("source_id").and_then(|s| s.as_str()) else {
continue;
};
let Some(tgt_id) = v.get("target_id").and_then(|s| s.as_str()) else {
continue;
};
let Some(rel_str) = v.get("relation").and_then(|r| r.as_str()) else {
continue;
};
let Ok(relation) = rel_str.parse::<EdgeRelation>() else {
continue;
};
let (Some(src), Some(tgt)) = (kind_map.get(src_id), kind_map.get(tgt_id)) else {
continue;
};
let allowed = if relation == EdgeRelation::Annotates {
src.substrate == "note"
} else if matches!(
relation,
EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
) {
if src.substrate != tgt.substrate || src.substrate == "edge" {
false
} else if src.substrate == "entity" {
base_entity_rule_allows(&src.kind, relation, &tgt.kind)
} else {
true
}
} else {
let base_ok = src.substrate == "entity"
&& tgt.substrate == "entity"
&& base_entity_rule_allows(&src.kind, relation, &tgt.kind);
base_ok || pack_rule_allows_kinds(pack_rules, relation, src, tgt)
};
if !allowed {
violations.push(Violation {
entity_id: Some(src_id.to_string()),
entity_name: None,
entity_kind: Some(src.kind.clone()),
rule_id: "edge-endpoint-types".into(),
severity: sev,
message: format!(
"[{src_id}\u{2192}{tgt_id}] ({} {}) -[{}]-> ({} {}) is not a permitted \
endpoint pairing for this relation",
src.substrate,
src.kind,
relation.as_str(),
tgt.substrate,
tgt.kind
),
fixable: false,
});
}
}
}
RuleResult {
id: "edge-endpoint-types".into(),
severity: sev,
passed: violations.is_empty(),
violations,
}
}
fn check_edge_direction_conventions(
entities_path: &Path,
notes_path: &Path,
edges_path: &Path,
cfg: &EdgeDirectionConventionsConfig,
) -> RuleResult {
let kind_map = collect_kind_map(entities_path, notes_path, edges_path);
let sev = severity_static(&cfg.severity);
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(edges_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(src_id) = v.get("source_id").and_then(|s| s.as_str()) else {
continue;
};
let Some(tgt_id) = v.get("target_id").and_then(|s| s.as_str()) else {
continue;
};
let Some(rel_str) = v.get("relation").and_then(|r| r.as_str()) else {
continue;
};
let (Some(src), Some(tgt)) = (kind_map.get(src_id), kind_map.get(tgt_id)) else {
continue;
};
for rule in &cfg.relations {
if rule.relation != rel_str
|| rule.forward_source_kinds.is_empty()
|| rule.forward_target_kinds.is_empty()
{
continue;
}
let forward = rule.forward_source_kinds.iter().any(|k| k == &src.kind)
&& rule.forward_target_kinds.iter().any(|k| k == &tgt.kind);
if forward {
continue;
}
let reversed = rule.forward_source_kinds.iter().any(|k| k == &tgt.kind)
&& rule.forward_target_kinds.iter().any(|k| k == &src.kind);
if reversed {
violations.push(Violation {
entity_id: Some(src_id.to_string()),
entity_name: None,
entity_kind: Some(src.kind.clone()),
rule_id: "edge-direction-conventions".into(),
severity: sev,
message: format!(
"[{src_id}\u{2192}{tgt_id}] {rel_str} from {} to {} matches the \
reversed direction convention configured for this relation; \
likely inverted",
src.kind, tgt.kind
),
fixable: false,
});
}
}
}
}
RuleResult {
id: "edge-direction-conventions".into(),
severity: sev,
passed: violations.is_empty(),
violations,
}
}
fn check_dangling_refs(
entities_path: &Path,
notes_path: &Path,
edges_path: &Path,
cfg: &DanglingRefsConfig,
) -> RuleResult {
let sev = severity_static(&cfg.severity);
let mut violations = Vec::new();
let mut known_ids = collect_ids(entities_path);
known_ids.extend(collect_ids(notes_path));
known_ids.extend(collect_edge_ids(edges_path));
if let Ok(content) = std::fs::read_to_string(edges_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
for field in &["source_id", "target_id"] {
if let Some(id) = v.get(*field).and_then(|i| i.as_str()) {
if !known_ids.contains(id) {
violations.push(Violation {
entity_id: Some(id.to_string()),
entity_name: None,
entity_kind: None,
rule_id: "dangling-refs".into(),
severity: sev,
message: format!(
"edge {field} {id} not in dataset (validated offline \
within the NDJSON dataset only; no live-graph check \
available in this build)"
),
fixable: false,
});
}
}
}
}
}
}
RuleResult {
id: "dangling-refs".into(),
severity: sev,
passed: violations.is_empty(),
violations,
}
}
fn has_parenthetical_suffix(name: &str) -> bool {
let trimmed = name.trim();
trimmed.ends_with(')') && trimmed.rfind('(').is_some_and(|i| i > 0)
}
fn check_naming_conventions(entities_path: &Path, cfg: &NamingConventionsConfig) -> RuleResult {
let sev = severity_static(&cfg.severity);
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(entities_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(name) = v.get("name").and_then(|n| n.as_str()) else {
continue;
};
let id = v.get("id").and_then(|i| i.as_str()).map(str::to_string);
let kind = v.get("kind").and_then(|k| k.as_str()).map(str::to_string);
let overrides = kind.as_deref().and_then(|k| cfg.kinds.get(k));
let max_length = overrides
.and_then(|o| o.max_length)
.unwrap_or(cfg.max_length);
let no_ws = overrides
.and_then(|o| o.no_leading_trailing_whitespace)
.unwrap_or(cfg.no_leading_trailing_whitespace);
let no_paren = overrides
.and_then(|o| o.no_parenthetical_suffix)
.unwrap_or(cfg.no_parenthetical_suffix);
let prefix = record_prefix(id.as_deref(), Some(name));
if name.trim().is_empty() {
violations.push(Violation {
entity_id: id.clone(),
entity_name: Some(name.to_string()),
entity_kind: kind.clone(),
rule_id: "naming-conventions".into(),
severity: sev,
message: format!("{prefix}name is empty or whitespace-only"),
fixable: false,
});
continue;
}
if no_ws && name != name.trim() {
violations.push(Violation {
entity_id: id.clone(),
entity_name: Some(name.to_string()),
entity_kind: kind.clone(),
rule_id: "naming-conventions".into(),
severity: sev,
message: format!("{prefix}name has leading/trailing whitespace"),
fixable: false,
});
}
if no_paren && has_parenthetical_suffix(name) {
violations.push(Violation {
entity_id: id.clone(),
entity_name: Some(name.to_string()),
entity_kind: kind.clone(),
rule_id: "naming-conventions".into(),
severity: sev,
message: format!(
"{prefix}name carries a parenthetical suffix; use `properties` for \
qualifiers instead of embedding them in `name`"
),
fixable: false,
});
}
if name.chars().count() > max_length {
violations.push(Violation {
entity_id: id.clone(),
entity_name: Some(name.to_string()),
entity_kind: kind.clone(),
rule_id: "naming-conventions".into(),
severity: sev,
message: format!(
"{prefix}name exceeds max length {max_length} ({} chars)",
name.chars().count()
),
fixable: false,
});
}
}
}
RuleResult {
id: "naming-conventions".into(),
severity: sev,
passed: violations.is_empty(),
violations,
}
}
fn future_date_description(
value: &serde_json::Value,
now: &chrono::DateTime<chrono::Utc>,
) -> Option<String> {
let current_year = now.year();
match value {
serde_json::Value::Number(n) => {
let y = n.as_i64()?;
if (1000..=9999).contains(&y) && y > i64::from(current_year) {
Some(format!("year {y} is after the current year {current_year}"))
} else {
None
}
}
serde_json::Value::String(s) => {
let s = s.trim();
if s.len() == 4 && s.chars().all(|c| c.is_ascii_digit()) {
let y: i64 = s.parse().ok()?;
return if y > i64::from(current_year) {
Some(format!("year {y} is after the current year {current_year}"))
} else {
None
};
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
let dt_utc = dt.with_timezone(&chrono::Utc);
return if dt_utc > *now {
Some(format!("date {s} is in the future (validated at {now})"))
} else {
None
};
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
if d > now.date_naive() {
return Some(format!("date {s} is in the future (validated at {now})"));
}
}
None
}
_ => None,
}
}
fn check_citation_date_lint(
entities_path: &Path,
notes_path: &Path,
cfg: &CitationDateLintConfig,
) -> RuleResult {
let sev = severity_static(&cfg.severity);
let now = chrono::Utc::now();
let mut violations = Vec::new();
for path in [entities_path, notes_path] {
if let Ok(content) = std::fs::read_to_string(path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(props) = v.get("properties").and_then(|p| p.as_object()) else {
continue;
};
let id = v.get("id").and_then(|i| i.as_str()).map(str::to_string);
let name = v.get("name").and_then(|n| n.as_str()).map(str::to_string);
let kind = v.get("kind").and_then(|k| k.as_str()).map(str::to_string);
let prefix = record_prefix(id.as_deref(), name.as_deref());
for field in &cfg.fields {
let Some(value) = props.get(field) else {
continue;
};
if let Some(desc) = future_date_description(value, &now) {
violations.push(Violation {
entity_id: id.clone(),
entity_name: name.clone(),
entity_kind: kind.clone(),
rule_id: "citation-date-lint".into(),
severity: sev,
message: format!("{prefix}property {field:?}: {desc}"),
fixable: false,
});
}
}
}
}
}
RuleResult {
id: "citation-date-lint".into(),
severity: sev,
passed: violations.is_empty(),
violations,
}
}
fn apply_fixes(repo: &std::path::Path) -> Result<()> {
let kg_dir = repo.join(".khive/kg");
fix_sort_order(&kg_dir.join("entities.ndjson"), "id")?;
fix_sort_order_edges(&kg_dir.join("edges.ndjson"))?;
eprintln!("~ sort-order: applied fix to entities.ndjson and edges.ndjson");
Ok(())
}
pub(super) fn fix_sort_order(path: &Path, sort_key: &str) -> Result<()> {
if !path.exists() {
return Ok(());
}
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut lines: Vec<serde_json::Value> = Vec::new();
for (idx, l) in content
.lines()
.enumerate()
.filter(|(_, l)| !l.trim().is_empty())
{
let v = serde_json::from_str(l).with_context(|| {
format!(
"{} line {}: refusing to apply --fix over malformed JSON; run `kg validate` for details",
path.display(),
idx + 1
)
})?;
lines.push(v);
}
lines.sort_by(|a, b| {
let ak = a.get(sort_key).and_then(|v| v.as_str()).unwrap_or("");
let bk = b.get(sort_key).and_then(|v| v.as_str()).unwrap_or("");
ak.cmp(bk)
});
let out: String = lines
.iter()
.map(|v| serde_json::to_string(v).unwrap())
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, out + "\n").with_context(|| format!("write {}", path.display()))
}
fn fix_sort_order_edges(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut lines: Vec<serde_json::Value> = Vec::new();
for (idx, l) in content
.lines()
.enumerate()
.filter(|(_, l)| !l.trim().is_empty())
{
let v = serde_json::from_str(l).with_context(|| {
format!(
"{} line {}: refusing to apply --fix over malformed JSON; run `kg validate` for details",
path.display(),
idx + 1
)
})?;
lines.push(v);
}
lines.sort_by(|a, b| {
let ak = (
a.get("source_id").and_then(|v| v.as_str()).unwrap_or(""),
a.get("target_id").and_then(|v| v.as_str()).unwrap_or(""),
a.get("relation").and_then(|v| v.as_str()).unwrap_or(""),
);
let bk = (
b.get("source_id").and_then(|v| v.as_str()).unwrap_or(""),
b.get("target_id").and_then(|v| v.as_str()).unwrap_or(""),
b.get("relation").and_then(|v| v.as_str()).unwrap_or(""),
);
ak.cmp(&bk)
});
let out: String = lines
.iter()
.map(|v| serde_json::to_string(v).unwrap())
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, out + "\n").with_context(|| format!("write {}", path.display()))
}
fn print_text_format(report: &ValidationReport, verbose: bool, quiet: bool) {
if !quiet {
for r in &report.rules {
let symbol = if r.passed {
"\u{2713}"
} else if r.severity == "error" {
"\u{2717}"
} else {
"\u{26a0}"
};
if r.violations.is_empty() {
println!(" {symbol} {}", r.id);
} else {
println!(" {symbol} {}: {} violation(s)", r.id, r.violations.len());
let shown = if verbose {
r.violations.len()
} else {
2.min(r.violations.len())
};
for v in &r.violations[..shown] {
let prefix = record_prefix(v.entity_id.as_deref(), v.entity_name.as_deref());
if prefix.is_empty() || v.message.starts_with(prefix.trim()) {
println!(" - {}", v.message);
} else {
println!(" - {}{}", prefix, v.message);
}
}
if !verbose && r.violations.len() > 2 {
println!(" + {} more (run with --verbose)", r.violations.len() - 2);
}
}
}
}
let s = &report.summary;
println!(
"\nSummary: {} error(s), {} warning(s), {} entities, {} edges",
s.errors, s.warnings, s.entities, s.edges
);
}
fn print_github_format(report: &ValidationReport) {
for r in &report.rules {
for v in &r.violations {
let level = if r.severity == "error" {
"error"
} else {
"warning"
};
println!("::{level} ::{}", v.message);
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use khive_types::EndpointKind;
use tempfile::TempDir;
use super::*;
fn real_taxonomy() -> KgTaxonomy {
build_taxonomy().expect("build_taxonomy must succeed in test environment")
}
fn base_entity_kinds() -> HashSet<String> {
[
"concept", "document", "dataset", "project", "person", "org", "artifact", "service",
"resource",
]
.iter()
.map(|s| s.to_string())
.collect()
}
fn base_note_kinds() -> HashSet<String> {
[
"observation",
"insight",
"question",
"decision",
"reference",
"task",
"memory",
]
.iter()
.map(|s| s.to_string())
.collect()
}
fn make_kg_dir(tmp: &TempDir) -> PathBuf {
let kg_dir = tmp.path().join(".khive/kg");
std::fs::create_dir_all(&kg_dir).unwrap();
kg_dir
}
fn write_entities(kg_dir: &std::path::Path, entities: &[(&str, &str, &str)]) {
let content: String = entities
.iter()
.map(|(id, kind, name)| format!(r#"{{"id":"{id}","kind":"{kind}","name":"{name}"}}"#))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(kg_dir.join("entities.ndjson"), content + "\n").unwrap();
}
fn write_edges(kg_dir: &std::path::Path, edges: &[(&str, &str, &str)]) {
let content: String = edges
.iter()
.map(|(src, tgt, rel)| {
format!(r#"{{"source_id":"{src}","target_id":"{tgt}","relation":"{rel}"}}"#)
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(kg_dir.join("edges.ndjson"), content + "\n").unwrap();
}
fn write_notes(kg_dir: &std::path::Path, notes: &[(&str, &str)]) {
let content: String = notes
.iter()
.map(|(id, kind)| format!(r#"{{"id":"{id}","kind":"{kind}"}}"#))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(kg_dir.join("notes.ndjson"), content + "\n").unwrap();
}
#[test]
fn schema_compliance_rejects_malformed_entities_ndjson() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
std::fs::write(kg_dir.join("entities.ndjson"), "not-valid-json\n").unwrap();
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let taxonomy = KgTaxonomy {
entity_kinds: base_entity_kinds(),
note_kinds: base_note_kinds(),
};
let results = structural_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&taxonomy,
);
let schema_rule = results
.iter()
.find(|r| r.id == "schema-compliance")
.expect("schema-compliance rule must always run");
assert!(
!schema_rule.passed,
"malformed NDJSON must fail schema-compliance"
);
assert!(
schema_rule.violations[0]
.message
.contains("entities.ndjson line 1"),
"violation must point at the malformed line: {}",
schema_rule.violations[0].message
);
}
#[test]
fn schema_compliance_passes_well_formed_kg_and_absent_notes() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
write_edges(&kg_dir, &[]);
let result = check_schema_compliance(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
);
assert!(
result.passed,
"well-formed KG with absent notes.ndjson must pass: {:?}",
result.violations
);
}
#[test]
fn fix_sort_order_refuses_malformed_json() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
let path = kg_dir.join("entities.ndjson");
std::fs::write(&path, "not-valid-json\n").unwrap();
let err = fix_sort_order(&path, "id").expect_err("fix must refuse malformed JSON");
assert!(err.to_string().contains("line 1"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "not-valid-json\n");
}
#[test]
fn duplicate_uuid_detected() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A-dup"),
],
);
let result = check_no_duplicate_uuids(&kg_dir.join("entities.ndjson"));
assert!(!result.passed, "duplicate UUID should fail");
assert_eq!(result.violations.len(), 1);
}
#[test]
fn no_duplicates_passes() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
let result = check_no_duplicate_uuids(&kg_dir.join("entities.ndjson"));
assert!(result.passed);
}
#[test]
fn referential_integrity_catches_missing_target() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let result = check_referential_integrity(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
);
assert!(!result.passed);
assert_eq!(result.violations.len(), 1);
}
#[test]
fn task_note_depends_on_passes_referential_integrity() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
std::fs::write(kg_dir.join("entities.ndjson"), "").unwrap();
write_notes(
&kg_dir,
&[
("task-0001-0000-0000-0000-000000000001", "task"),
("task-0002-0000-0000-0000-000000000002", "task"),
],
);
write_edges(
&kg_dir,
&[(
"task-0001-0000-0000-0000-000000000001",
"task-0002-0000-0000-0000-000000000002",
"depends_on",
)],
);
let result = check_referential_integrity(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
);
assert!(
result.passed,
"task note depends_on must pass referential integrity; violations: {:?}",
result.violations
);
assert!(result.violations.is_empty());
}
#[test]
fn note_annotates_edge_passes_referential_integrity() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
let edges = r#"{"edge_id":"eeeeeeee-0000-0000-0000-000000000001","source_id":"aaaaaaaa-0000-0000-0000-000000000001","target_id":"bbbbbbbb-0000-0000-0000-000000000002","relation":"extends"}
{"source_id":"note-obs-0000-0000-0000-000000000001","target_id":"eeeeeeee-0000-0000-0000-000000000001","relation":"annotates"}
"#;
std::fs::write(kg_dir.join("edges.ndjson"), edges).unwrap();
write_notes(
&kg_dir,
&[("note-obs-0000-0000-0000-000000000001", "observation")],
);
let result = check_referential_integrity(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
);
assert!(
result.passed,
"note annotates edge must pass referential integrity; violations: {:?}",
result.violations
);
assert!(result.violations.is_empty());
}
#[test]
fn configurable_rule_checks_empty_rules_file_returns_no_results() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, "rules = []\n").unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.unwrap();
assert!(results.is_empty(), "no rules → no results");
}
#[test]
fn configurable_rule_checks_require_field_detects_missing_description() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
let entities = r#"{"id":"aaa1","kind":"concept","name":"A","description":"has one"}
{"id":"aaa2","kind":"concept","name":"B"}
"#;
std::fs::write(kg_dir.join("entities.ndjson"), entities).unwrap();
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_toml = r#"
[[rules]]
id = "concept-must-have-description"
severity = "warning"
kind = "entity"
condition = "kind=concept"
require_field = "description"
message = "Concept {id} missing description"
"#;
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, rules_toml).unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.id, "concept-must-have-description");
assert!(
!r.passed,
"rule should fail when a concept lacks description"
);
assert_eq!(r.violations.len(), 1);
assert_eq!(r.violations[0].entity_id.as_deref(), Some("aaa2"));
}
#[test]
fn configurable_rule_checks_self_loop_sentinel_detects_loop() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
let edges = r#"{"source_id":"aaaaaaaa-0000-0000-0000-000000000001","target_id":"aaaaaaaa-0000-0000-0000-000000000001","relation":"extends"}
{"source_id":"aaaaaaaa-0000-0000-0000-000000000001","target_id":"bbbbbbbb-0000-0000-0000-000000000002","relation":"extends"}
"#;
std::fs::write(kg_dir.join("edges.ndjson"), edges).unwrap();
let rules_toml = r#"
[[rules]]
id = "no-self-loops"
severity = "error"
kind = "edge"
condition = "source_id=target_id"
message = "Self-loop detected on {id}"
"#;
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, rules_toml).unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert!(!r.passed);
assert_eq!(r.violations.len(), 1, "exactly one self-loop");
}
#[test]
fn configurable_rule_checks_yaml_extension_returns_error() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_path = tmp.path().join("rules.yaml");
std::fs::write(&rules_path, "rules: []\n").unwrap();
let result = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
);
assert!(result.is_err(), "YAML extension must return an error");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("YAML") || msg.contains("toml"),
"error message should mention TOML: {msg}"
);
}
#[test]
fn configurable_rule_checks_unknown_kind_produces_error_result() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_toml = r#"
[[rules]]
id = "bad-kind"
severity = "error"
kind = "note"
condition = "kind=concept"
require_field = "description"
message = "bad"
"#;
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, rules_toml).unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.unwrap();
assert_eq!(results.len(), 1);
assert!(!results[0].passed);
assert_eq!(results[0].severity, "error");
}
#[test]
fn configurable_rule_checks_invalid_severity_produces_error_result() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_toml = r#"
[[rules]]
id = "bad-severity"
severity = "erorr"
kind = "entity"
require_field = "description"
message = "bad"
"#;
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, rules_toml).unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.unwrap();
assert_eq!(results.len(), 1);
assert!(!results[0].passed, "invalid severity must fail");
assert_eq!(results[0].severity, "error");
assert!(
results[0].violations[0]
.message
.contains("invalid severity"),
"error message should mention invalid severity"
);
}
#[test]
fn sort_order_fix_sorts_entities() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("cccccccc-0000-0000-0000-000000000003", "concept", "C"),
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
fix_sort_order(&kg_dir.join("entities.ndjson"), "id").unwrap();
let result = check_sort_order(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
);
assert!(result.passed, "sort-order should pass after fix");
}
#[test]
fn invalid_entity_kind_is_rejected() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "nonsense", "B"),
],
);
let kinds = base_entity_kinds();
let result = check_valid_entity_kinds(&kg_dir.join("entities.ndjson"), &kinds);
assert!(!result.passed, "invalid entity kind must fail");
assert_eq!(result.violations.len(), 1);
assert!(
result.violations[0].message.contains("nonsense"),
"violation message should name the bad kind: {}",
result.violations[0].message
);
assert!(
result.violations[0].message.contains("concept"),
"violation message should list valid kinds: {}",
result.violations[0].message
);
}
#[test]
fn resource_kind_is_accepted_as_pack_registered() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "resource", "R"),
],
);
let taxonomy = real_taxonomy();
assert!(
taxonomy.entity_kinds.contains("resource"),
"VerbRegistry must include 'resource' from KG pack (ADR-048)"
);
let result =
check_valid_entity_kinds(&kg_dir.join("entities.ndjson"), &taxonomy.entity_kinds);
assert!(
result.passed,
"pack-registered kind 'resource' must pass; violations: {:?}",
result.violations
);
assert!(result.violations.is_empty());
}
#[test]
fn valid_entity_kinds_all_pass() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "document", "B"),
("cccccccc-0000-0000-0000-000000000003", "dataset", "C"),
("dddddddd-0000-0000-0000-000000000004", "project", "D"),
("eeeeeeee-0000-0000-0000-000000000005", "person", "E"),
("ffffffff-0000-0000-0000-000000000006", "org", "F"),
("11111111-0000-0000-0000-000000000007", "artifact", "G"),
("22222222-0000-0000-0000-000000000008", "service", "H"),
],
);
let kinds = base_entity_kinds();
let result = check_valid_entity_kinds(&kg_dir.join("entities.ndjson"), &kinds);
assert!(result.passed, "all 8 canonical kinds must pass");
assert!(result.violations.is_empty());
}
#[test]
fn invalid_note_kind_is_rejected() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_notes(
&kg_dir,
&[("note-0001", "observation"), ("note-0002", "bogus_kind")],
);
let kinds = base_note_kinds();
let result = check_valid_note_kinds(&kg_dir.join("notes.ndjson"), &kinds);
assert!(!result.passed, "invalid note kind must fail");
assert_eq!(result.violations.len(), 1);
assert!(
result.violations[0].message.contains("bogus_kind"),
"violation message should name the bad kind: {}",
result.violations[0].message
);
assert!(
result.violations[0].message.contains("observation"),
"violation message should list valid kinds: {}",
result.violations[0].message
);
}
#[test]
fn valid_note_kinds_all_pass() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_notes(
&kg_dir,
&[
("note-0001", "observation"),
("note-0002", "insight"),
("note-0003", "question"),
("note-0004", "decision"),
("note-0005", "reference"),
("note-0006", "task"),
("note-0007", "memory"),
],
);
let kinds = base_note_kinds();
let result = check_valid_note_kinds(&kg_dir.join("notes.ndjson"), &kinds);
assert!(result.passed, "all registered note kinds must pass");
assert!(result.violations.is_empty());
}
#[test]
fn note_kind_template_note_is_accepted_as_pack_registered() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_notes(&kg_dir, &[("note-0001", "template_note")]);
let taxonomy = real_taxonomy();
assert!(
taxonomy.note_kinds.contains("template_note"),
"VerbRegistry must include 'template_note' from khive-pack-template"
);
let result = check_valid_note_kinds(&kg_dir.join("notes.ndjson"), &taxonomy.note_kinds);
assert!(
result.passed,
"pack-registered note kind 'template_note' must pass; violations: {:?}",
result.violations
);
}
#[test]
fn structural_checks_skips_note_check_when_notes_file_absent() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let taxonomy = real_taxonomy();
let results = structural_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&taxonomy,
);
let ids: Vec<&str> = results.iter().map(|r| r.id.as_str()).collect();
assert!(
!ids.contains(&"valid-note-kinds"),
"valid-note-kinds must not appear when notes.ndjson is absent"
);
}
#[test]
fn structural_checks_includes_note_check_when_notes_file_present() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
write_notes(&kg_dir, &[("note-0001", "observation")]);
let taxonomy = real_taxonomy();
let results = structural_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&taxonomy,
);
let ids: Vec<&str> = results.iter().map(|r| r.id.as_str()).collect();
assert!(
ids.contains(&"valid-note-kinds"),
"valid-note-kinds must appear when notes.ndjson is present"
);
}
#[test]
fn violation_message_includes_entity_id_and_name() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[(
"bbbbbbbb-0000-0000-0000-000000000002",
"nonsense",
"BadEntity",
)],
);
let kinds = base_entity_kinds();
let result = check_valid_entity_kinds(&kg_dir.join("entities.ndjson"), &kinds);
assert!(!result.passed);
let msg = &result.violations[0].message;
assert!(
msg.contains("bbbbbbbb-0000-0000-0000-000000000002"),
"violation message must include entity id: {msg}"
);
assert!(
msg.contains("BadEntity"),
"violation message must include entity name: {msg}"
);
}
#[test]
fn edge_violation_message_includes_source_target() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"not_a_real_relation",
)],
);
let result = check_valid_edge_relations(&kg_dir.join("edges.ndjson"));
assert!(!result.passed);
let msg = &result.violations[0].message;
assert!(
msg.contains("aaaaaaaa-0000-0000-0000-000000000001"),
"edge violation message must include source_id: {msg}"
);
assert!(
msg.contains("bbbbbbbb-0000-0000-0000-000000000002"),
"edge violation message must include target_id: {msg}"
);
}
#[test]
fn invalid_edge_relation_is_rejected() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
write_edges(
&kg_dir,
&[
(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
),
(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"not_a_real_relation",
),
],
);
let result = check_valid_edge_relations(&kg_dir.join("edges.ndjson"));
assert!(!result.passed, "invalid edge relation must fail");
assert_eq!(result.violations.len(), 1);
assert!(
result.violations[0].message.contains("not_a_real_relation"),
"violation message should name the bad relation: {}",
result.violations[0].message
);
assert!(
result.violations[0].message.contains("extends"),
"violation message should list valid relations: {}",
result.violations[0].message
);
}
#[test]
fn valid_edge_relations_all_pass() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
write_edges(
&kg_dir,
&[
(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
),
(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"variant_of",
),
],
);
let result = check_valid_edge_relations(&kg_dir.join("edges.ndjson"));
assert!(result.passed, "valid edge relations must pass");
assert!(result.violations.is_empty());
}
#[test]
fn structural_checks_include_taxonomy_rules() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "nonsense", "Bad")],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"aaaaaaaa-0000-0000-0000-000000000001",
"not_valid",
)],
);
let taxonomy = real_taxonomy();
let results = structural_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&taxonomy,
);
let ids: Vec<&str> = results.iter().map(|r| r.id.as_str()).collect();
assert!(
ids.contains(&"valid-entity-kinds"),
"structural_checks must include valid-entity-kinds"
);
assert!(
ids.contains(&"valid-edge-relations"),
"structural_checks must include valid-edge-relations"
);
let entity_kind_result = results
.iter()
.find(|r| r.id == "valid-entity-kinds")
.unwrap();
assert!(!entity_kind_result.passed, "nonsense kind must fail");
let edge_rel_result = results
.iter()
.find(|r| r.id == "valid-edge-relations")
.unwrap();
assert!(!edge_rel_result.passed, "invalid relation must fail");
}
#[test]
fn edge_endpoint_types_passes_base_allowed_pair() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let cfg = EdgeEndpointTypesConfig {
enabled: true,
severity: "error".into(),
};
let result = check_edge_endpoint_types(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&[],
&cfg,
);
assert!(
result.passed,
"concept -[extends]-> concept is base-allowed: {:?}",
result.violations
);
}
#[test]
fn edge_endpoint_types_rejects_disallowed_pair() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "person", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "person", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let cfg = EdgeEndpointTypesConfig {
enabled: true,
severity: "error".into(),
};
let result = check_edge_endpoint_types(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&[],
&cfg,
);
assert!(
!result.passed,
"person -[extends]-> person is not in the base allowlist"
);
assert_eq!(result.violations.len(), 1);
assert_eq!(result.severity, "error");
}
#[test]
fn edge_endpoint_types_severity_config_downgrades_to_warning() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "person", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "person", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let cfg = EdgeEndpointTypesConfig {
enabled: true,
severity: "warning".into(),
};
let result = check_edge_endpoint_types(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&[],
&cfg,
);
assert!(!result.passed);
assert_eq!(result.severity, "warning");
assert_eq!(result.violations[0].severity, "warning");
}
#[test]
fn edge_endpoint_types_accepts_pack_extended_note_to_note_pair() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
std::fs::write(kg_dir.join("entities.ndjson"), "").unwrap();
write_notes(
&kg_dir,
&[
("task-0001-0000-0000-0000-000000000001", "task"),
("task-0002-0000-0000-0000-000000000002", "task"),
],
);
write_edges(
&kg_dir,
&[(
"task-0001-0000-0000-0000-000000000001",
"task-0002-0000-0000-0000-000000000002",
"depends_on",
)],
);
let pack_rules = vec![EdgeEndpointRule {
relation: EdgeRelation::DependsOn,
source: EndpointKind::NoteOfKind("task"),
target: EndpointKind::NoteOfKind("task"),
}];
let cfg = EdgeEndpointTypesConfig {
enabled: true,
severity: "error".into(),
};
let result = check_edge_endpoint_types(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&pack_rules,
&cfg,
);
assert!(
result.passed,
"pack-extended task->task depends_on must pass: {:?}",
result.violations
);
}
#[test]
fn edge_endpoint_types_rejects_entity_annotates_edge_endpoint() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
("cccccccc-0000-0000-0000-000000000003", "concept", "C"),
],
);
std::fs::write(
kg_dir.join("edges.ndjson"),
[
r#"{"edge_id":"edge-0000-0000-0000-000000000099","source_id":"aaaaaaaa-0000-0000-0000-000000000001","target_id":"bbbbbbbb-0000-0000-0000-000000000002","relation":"extends"}"#,
r#"{"source_id":"cccccccc-0000-0000-0000-000000000003","target_id":"edge-0000-0000-0000-000000000099","relation":"annotates"}"#,
]
.join("\n")
+ "\n",
)
.unwrap();
let cfg = EdgeEndpointTypesConfig {
enabled: true,
severity: "error".into(),
};
let result = check_edge_endpoint_types(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&[],
&cfg,
);
assert!(
!result.passed,
"entity -[annotates]-> edge must fail (annotates source must be a note)"
);
assert_eq!(result.violations.len(), 1);
}
#[test]
fn edge_endpoint_types_rejects_edge_as_endpoint_of_extends() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
("cccccccc-0000-0000-0000-000000000003", "concept", "C"),
],
);
std::fs::write(
kg_dir.join("edges.ndjson"),
[
r#"{"edge_id":"edge-0000-0000-0000-000000000099","source_id":"aaaaaaaa-0000-0000-0000-000000000001","target_id":"bbbbbbbb-0000-0000-0000-000000000002","relation":"extends"}"#,
r#"{"source_id":"edge-0000-0000-0000-000000000099","target_id":"cccccccc-0000-0000-0000-000000000003","relation":"extends"}"#,
]
.join("\n")
+ "\n",
)
.unwrap();
let cfg = EdgeEndpointTypesConfig {
enabled: true,
severity: "error".into(),
};
let result = check_edge_endpoint_types(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&[],
&cfg,
);
assert!(
!result.passed,
"edge -[extends]-> entity must fail (extends requires entity endpoints)"
);
assert_eq!(result.violations.len(), 1);
}
#[test]
fn edge_endpoint_types_accepts_note_annotates_edge_endpoint() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
write_notes(
&kg_dir,
&[("note0001-0000-0000-0000-000000000001", "observation")],
);
std::fs::write(
kg_dir.join("edges.ndjson"),
[
r#"{"edge_id":"edge-0000-0000-0000-000000000099","source_id":"aaaaaaaa-0000-0000-0000-000000000001","target_id":"bbbbbbbb-0000-0000-0000-000000000002","relation":"extends"}"#,
r#"{"source_id":"note0001-0000-0000-0000-000000000001","target_id":"edge-0000-0000-0000-000000000099","relation":"annotates"}"#,
]
.join("\n")
+ "\n",
)
.unwrap();
let cfg = EdgeEndpointTypesConfig {
enabled: true,
severity: "error".into(),
};
let result = check_edge_endpoint_types(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&[],
&cfg,
);
assert!(
result.passed,
"note -[annotates]-> edge must pass: {:?}",
result.violations
);
}
fn direction_cfg(severity: &str) -> EdgeDirectionConventionsConfig {
EdgeDirectionConventionsConfig {
enabled: true,
severity: severity.to_owned(),
relations: vec![DirectionRuleConfig {
relation: "introduced_by".into(),
forward_source_kinds: vec!["concept".into(), "artifact".into()],
forward_target_kinds: vec!["document".into(), "person".into()],
}],
}
}
#[test]
fn edge_direction_conventions_passes_forward_direction() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "document", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"introduced_by",
)],
);
let cfg = direction_cfg("warning");
let result = check_edge_direction_conventions(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&cfg,
);
assert!(
result.passed,
"concept -[introduced_by]-> document is the forward direction: {:?}",
result.violations
);
}
#[test]
fn edge_direction_conventions_flags_reversed_direction() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "document", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"introduced_by",
)],
);
let cfg = direction_cfg("warning");
let result = check_edge_direction_conventions(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&cfg,
);
assert!(
!result.passed,
"document -[introduced_by]-> concept matches the reversed pattern"
);
assert_eq!(result.violations.len(), 1);
assert_eq!(result.severity, "warning");
}
#[test]
fn edge_direction_conventions_severity_config_escalates_to_error() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "document", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"introduced_by",
)],
);
let cfg = direction_cfg("error");
let result = check_edge_direction_conventions(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&cfg,
);
assert!(!result.passed);
assert_eq!(result.severity, "error");
assert_eq!(result.violations[0].severity, "error");
}
#[test]
fn dangling_refs_passes_when_all_endpoints_resolve() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let cfg = DanglingRefsConfig {
enabled: true,
severity: "error".into(),
};
let result = check_dangling_refs(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&cfg,
);
assert!(result.passed, "{:?}", result.violations);
}
#[test]
fn dangling_refs_flags_unresolved_target_and_names_it_not_in_dataset() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let cfg = DanglingRefsConfig {
enabled: true,
severity: "error".into(),
};
let result = check_dangling_refs(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&cfg,
);
assert!(!result.passed);
assert_eq!(result.violations.len(), 1);
assert!(
result.violations[0].message.contains("not in dataset"),
"message must distinguish dataset-scoped resolution: {}",
result.violations[0].message
);
}
#[test]
fn dangling_refs_severity_config_downgrades_to_info() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let cfg = DanglingRefsConfig {
enabled: true,
severity: "info".into(),
};
let result = check_dangling_refs(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&kg_dir.join("edges.ndjson"),
&cfg,
);
assert!(!result.passed);
assert_eq!(result.severity, "info");
}
fn naming_cfg(severity: &str) -> NamingConventionsConfig {
NamingConventionsConfig {
enabled: true,
severity: severity.to_owned(),
max_length: 20,
no_leading_trailing_whitespace: true,
no_parenthetical_suffix: true,
kinds: std::collections::BTreeMap::new(),
}
}
#[test]
fn naming_conventions_passes_clean_name() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "Clean")],
);
let cfg = naming_cfg("warning");
let result = check_naming_conventions(&kg_dir.join("entities.ndjson"), &cfg);
assert!(result.passed, "{:?}", result.violations);
}
#[test]
fn naming_conventions_flags_whitespace_and_parenthetical_suffix() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
let entities = r#"{"id":"aaaaaaaa-0000-0000-0000-000000000001","kind":"concept","name":" Foo (2024 paper) "}"#;
std::fs::write(kg_dir.join("entities.ndjson"), entities.to_owned() + "\n").unwrap();
let cfg = naming_cfg("warning");
let result = check_naming_conventions(&kg_dir.join("entities.ndjson"), &cfg);
assert!(!result.passed);
assert_eq!(result.violations.len(), 2, "{:?}", result.violations);
}
#[test]
fn naming_conventions_flags_empty_name() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
let entities =
r#"{"id":"aaaaaaaa-0000-0000-0000-000000000001","kind":"concept","name":" "}"#;
std::fs::write(kg_dir.join("entities.ndjson"), entities.to_owned() + "\n").unwrap();
let cfg = naming_cfg("warning");
let result = check_naming_conventions(&kg_dir.join("entities.ndjson"), &cfg);
assert!(!result.passed);
assert_eq!(result.violations.len(), 1);
assert!(result.violations[0].message.contains("empty"));
}
#[test]
fn naming_conventions_max_length_kind_override() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"concept",
"TwelveChars!",
)],
);
let mut cfg = naming_cfg("warning");
cfg.kinds.insert(
"concept".to_string(),
NamingConventionsOverride {
max_length: Some(5),
no_leading_trailing_whitespace: None,
no_parenthetical_suffix: None,
},
);
let result = check_naming_conventions(&kg_dir.join("entities.ndjson"), &cfg);
assert!(!result.passed, "per-kind max_length override must apply");
assert_eq!(result.violations.len(), 1);
assert!(result.violations[0].message.contains("max length 5"));
}
#[test]
fn naming_conventions_severity_config_is_error() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
let entities =
r#"{"id":"aaaaaaaa-0000-0000-0000-000000000001","kind":"concept","name":" Bad "}"#;
std::fs::write(kg_dir.join("entities.ndjson"), entities.to_owned() + "\n").unwrap();
let cfg = naming_cfg("error");
let result = check_naming_conventions(&kg_dir.join("entities.ndjson"), &cfg);
assert!(!result.passed);
assert_eq!(result.severity, "error");
}
fn citation_cfg(severity: &str) -> CitationDateLintConfig {
CitationDateLintConfig {
enabled: true,
severity: severity.to_owned(),
fields: vec!["year".into(), "date".into()],
}
}
#[test]
fn citation_date_lint_passes_past_year() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
let entities = r#"{"id":"aaaaaaaa-0000-0000-0000-000000000001","kind":"document","name":"D","properties":{"year":2020}}"#;
std::fs::write(kg_dir.join("entities.ndjson"), entities.to_owned() + "\n").unwrap();
let cfg = citation_cfg("warning");
let result = check_citation_date_lint(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&cfg,
);
assert!(result.passed, "{:?}", result.violations);
}
#[test]
fn citation_date_lint_flags_forward_dated_year() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
let entities = r#"{"id":"aaaaaaaa-0000-0000-0000-000000000001","kind":"document","name":"D","properties":{"year":9999}}"#;
std::fs::write(kg_dir.join("entities.ndjson"), entities.to_owned() + "\n").unwrap();
let cfg = citation_cfg("warning");
let result = check_citation_date_lint(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&cfg,
);
assert!(!result.passed);
assert_eq!(result.violations.len(), 1);
assert!(result.violations[0].message.contains("9999"));
}
#[test]
fn citation_date_lint_flags_forward_dated_iso_date_on_a_note() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
std::fs::write(kg_dir.join("entities.ndjson"), "").unwrap();
let notes = r#"{"id":"note-0001","kind":"observation","properties":{"date":"2999-01-01"}}"#;
std::fs::write(kg_dir.join("notes.ndjson"), notes.to_owned() + "\n").unwrap();
let cfg = citation_cfg("warning");
let result = check_citation_date_lint(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&cfg,
);
assert!(!result.passed, "note properties must be checked too");
assert_eq!(result.violations.len(), 1);
}
#[test]
fn citation_date_lint_severity_config_is_error() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
let entities = r#"{"id":"aaaaaaaa-0000-0000-0000-000000000001","kind":"document","name":"D","properties":{"year":9999}}"#;
std::fs::write(kg_dir.join("entities.ndjson"), entities.to_owned() + "\n").unwrap();
let cfg = citation_cfg("error");
let result = check_citation_date_lint(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("notes.ndjson"),
&cfg,
);
assert!(!result.passed);
assert_eq!(result.severity, "error");
}
#[test]
fn configurable_rule_checks_wires_up_edge_endpoint_types_from_toml() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "person", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "person", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, "[edge_endpoint_types]\nenabled = true\n").unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "edge-endpoint-types");
assert_eq!(results[0].severity, "error");
assert!(!results[0].passed);
}
#[test]
fn configurable_rule_checks_section_absent_means_rule_does_not_run() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, "rules = []\n").unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.unwrap();
assert!(
results.is_empty(),
"no built-in rule-class sections declared → none run: {results:?}"
);
}
#[test]
fn configurable_rule_checks_enabled_false_skips_the_rule() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "person", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "person", "B"),
],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let rules_path = tmp.path().join("rules.toml");
std::fs::write(
&rules_path,
"[edge_endpoint_types]\nenabled = false\nseverity = \"error\"\n",
)
.unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.unwrap();
assert!(results.is_empty(), "enabled = false must skip evaluation");
}
#[test]
fn configurable_rule_checks_invalid_builtin_severity_produces_error_result() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_path = tmp.path().join("rules.toml");
std::fs::write(
&rules_path,
"[naming_conventions]\nseverity = \"catastrophic\"\n",
)
.unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.unwrap();
assert_eq!(results.len(), 1);
assert!(!results[0].passed);
assert_eq!(results[0].severity, "error");
assert!(results[0].violations[0]
.message
.contains("invalid severity"));
}
#[test]
fn configurable_rule_checks_misspelled_key_fails_the_load() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", " Bad ")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, "[naming_conventions]\nsevertiy = \"error\"\n").unwrap();
let err = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.expect_err("a misspelled key must fail the rules.toml load, not silently default");
assert!(
format!("{err:#}").contains("severtiy") || format!("{err:#}").contains("unknown field"),
"error must name the bad key: {err:#}"
);
}
#[test]
fn configurable_rule_checks_malformed_direction_entry_fails_the_load() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_path = tmp.path().join("rules.toml");
std::fs::write(
&rules_path,
"[[edge_direction_conventions.relations]]\n\
relation = \"introduced_by\"\n\
forward_source_kind = [\"concept\"]\n\
forward_target_kinds = [\"document\"]\n",
)
.unwrap();
let err = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.expect_err("a misspelled direction-entry field must fail the rules.toml load");
let msg = format!("{err:#}");
assert!(
msg.contains("forward_source_kind") || msg.contains("unknown field"),
"error must name the bad key: {msg}"
);
}
#[test]
fn configurable_rule_checks_direction_entry_with_empty_kind_list_fails_the_load() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_path = tmp.path().join("rules.toml");
std::fs::write(
&rules_path,
"[[edge_direction_conventions.relations]]\n\
relation = \"introduced_by\"\n\
forward_source_kinds = []\n\
forward_target_kinds = [\"document\"]\n",
)
.unwrap();
let err = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&kg_dir.join("notes.ndjson"),
&rules_path,
)
.expect_err("an empty forward_source_kinds entry must fail the rules.toml load");
assert!(
format!("{err:#}").contains("forward_source_kinds"),
"error must name the empty field: {err:#}"
);
}
}