376 lines
13 KiB
Rust
376 lines
13 KiB
Rust
|
|
//! Multi-consumer config coherence verification.
|
||
|
|
//!
|
||
|
|
//! For each section in a project's `config_surface`, this module compares the
|
||
|
|
//! fields present in the exported NCL JSON against the fields declared by each
|
||
|
|
//! consumer. A field absent from all consumers is "unclaimed" — it exists in
|
||
|
|
//! the config but nothing reads it.
|
||
|
|
//!
|
||
|
|
//! Coherence is checked from two directions:
|
||
|
|
//! - **NCL-only fields**: present in NCL, not claimed by any consumer.
|
||
|
|
//! - **Consumer-only fields**: a consumer declares a field that doesn't exist
|
||
|
|
//! in the NCL export. The consumer either references a renamed/removed field
|
||
|
|
//! or the NCL contract is incomplete.
|
||
|
|
|
||
|
|
use std::collections::{BTreeMap, BTreeSet};
|
||
|
|
use std::path::Path;
|
||
|
|
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use tracing::warn;
|
||
|
|
|
||
|
|
use crate::cache::NclCache;
|
||
|
|
use crate::registry::{ConfigSection, ConfigSurface};
|
||
|
|
|
||
|
|
/// Merge optional NCL `_meta_*` record fields into a quickref section object.
|
||
|
|
/// Only sets `rationale` when the section object currently has an empty value.
|
||
|
|
fn merge_meta_into_section(
|
||
|
|
obj: &mut serde_json::Map<String, serde_json::Value>,
|
||
|
|
meta_val: &serde_json::Value,
|
||
|
|
) {
|
||
|
|
if let Some(rationale) = meta_val.get("rationale").and_then(|v| v.as_str()) {
|
||
|
|
if obj["rationale"].as_str().unwrap_or("").is_empty() {
|
||
|
|
obj["rationale"] = serde_json::Value::String(rationale.to_owned());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(alt) = meta_val.get("alternatives_rejected") {
|
||
|
|
obj.insert("alternatives_rejected".to_owned(), alt.clone());
|
||
|
|
}
|
||
|
|
if let Some(constraints) = meta_val.get("constraints") {
|
||
|
|
obj.insert("constraints".to_owned(), constraints.clone());
|
||
|
|
}
|
||
|
|
if let Some(see_also) = meta_val.get("see_also") {
|
||
|
|
obj.insert("see_also".to_owned(), see_also.clone());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Status of a section's coherence check.
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "snake_case")]
|
||
|
|
pub enum CoherenceStatus {
|
||
|
|
/// All fields are claimed by at least one consumer, and no consumer
|
||
|
|
/// references a field absent from the NCL export.
|
||
|
|
Ok,
|
||
|
|
/// Some fields are unclaimed or a consumer references missing fields —
|
||
|
|
/// worth reviewing but not necessarily a bug.
|
||
|
|
Warning,
|
||
|
|
/// The NCL export failed; coherence could not be checked.
|
||
|
|
Error,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Per-consumer coherence result within a section.
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct ConsumerCoherenceReport {
|
||
|
|
pub consumer_id: String,
|
||
|
|
pub kind: String,
|
||
|
|
/// Fields the consumer declared but are absent in the NCL export.
|
||
|
|
pub missing_in_ncl: Vec<String>,
|
||
|
|
/// Fields in the NCL export that this consumer doesn't declare.
|
||
|
|
/// Non-empty when the consumer has an explicit field list (not "reads
|
||
|
|
/// all").
|
||
|
|
pub extra_in_ncl: Vec<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Full coherence report for one config section.
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct SectionCoherenceReport {
|
||
|
|
pub section_id: String,
|
||
|
|
/// All top-level keys in the NCL export for this section (excluding
|
||
|
|
/// `_meta_*` and `_overrides_meta` keys).
|
||
|
|
pub ncl_fields: Vec<String>,
|
||
|
|
pub consumers: Vec<ConsumerCoherenceReport>,
|
||
|
|
/// Fields present in NCL but claimed by no consumer.
|
||
|
|
pub unclaimed_fields: Vec<String>,
|
||
|
|
pub status: CoherenceStatus,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Coherence report for an entire project.
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct ProjectCoherenceReport {
|
||
|
|
pub project_slug: String,
|
||
|
|
pub sections: Vec<SectionCoherenceReport>,
|
||
|
|
pub has_config_surface: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl ProjectCoherenceReport {
|
||
|
|
/// Overall status: worst status across all sections.
|
||
|
|
pub fn overall_status(&self) -> CoherenceStatus {
|
||
|
|
if self
|
||
|
|
.sections
|
||
|
|
.iter()
|
||
|
|
.any(|s| s.status == CoherenceStatus::Error)
|
||
|
|
{
|
||
|
|
CoherenceStatus::Error
|
||
|
|
} else if self
|
||
|
|
.sections
|
||
|
|
.iter()
|
||
|
|
.any(|s| s.status == CoherenceStatus::Warning)
|
||
|
|
{
|
||
|
|
CoherenceStatus::Warning
|
||
|
|
} else {
|
||
|
|
CoherenceStatus::Ok
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Run coherence check for all sections of a project's config surface.
|
||
|
|
///
|
||
|
|
/// `section_filter` — if `Some`, only check this section id.
|
||
|
|
pub async fn check_project(
|
||
|
|
slug: &str,
|
||
|
|
surface: &ConfigSurface,
|
||
|
|
project_root: &Path,
|
||
|
|
cache: &NclCache,
|
||
|
|
import_path: Option<&str>,
|
||
|
|
section_filter: Option<&str>,
|
||
|
|
) -> ProjectCoherenceReport {
|
||
|
|
let sections_to_check: Vec<&ConfigSection> = surface
|
||
|
|
.sections
|
||
|
|
.iter()
|
||
|
|
.filter(|s| section_filter.is_none_or(|f| s.id == f))
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
let mut section_reports = Vec::with_capacity(sections_to_check.len());
|
||
|
|
|
||
|
|
for section in sections_to_check {
|
||
|
|
let report = check_section(section, surface, project_root, cache, import_path).await;
|
||
|
|
section_reports.push(report);
|
||
|
|
}
|
||
|
|
|
||
|
|
ProjectCoherenceReport {
|
||
|
|
project_slug: slug.to_owned(),
|
||
|
|
sections: section_reports,
|
||
|
|
has_config_surface: true,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn check_section(
|
||
|
|
section: &ConfigSection,
|
||
|
|
surface: &ConfigSurface,
|
||
|
|
project_root: &Path,
|
||
|
|
cache: &NclCache,
|
||
|
|
import_path: Option<&str>,
|
||
|
|
) -> SectionCoherenceReport {
|
||
|
|
let ncl_path = project_root.join(&surface.config_root).join(§ion.file);
|
||
|
|
|
||
|
|
let ncl_export = cache.export(&ncl_path, import_path).await;
|
||
|
|
let (json, _) = match ncl_export {
|
||
|
|
Ok(pair) => pair,
|
||
|
|
Err(e) => {
|
||
|
|
warn!(
|
||
|
|
section = %section.id,
|
||
|
|
path = %ncl_path.display(),
|
||
|
|
error = %e,
|
||
|
|
"config coherence: nickel export failed"
|
||
|
|
);
|
||
|
|
return SectionCoherenceReport {
|
||
|
|
section_id: section.id.clone(),
|
||
|
|
ncl_fields: vec![],
|
||
|
|
consumers: vec![],
|
||
|
|
unclaimed_fields: vec![],
|
||
|
|
status: CoherenceStatus::Error,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Collect top-level keys for this section from the export.
|
||
|
|
// A section NCL file may export { server = { ... }, _meta_server = {...} }
|
||
|
|
// We want the section key matching section.id; if the whole file is the
|
||
|
|
// section value, use all keys.
|
||
|
|
let ncl_fields: BTreeSet<String> = extract_section_fields(&json, §ion.id);
|
||
|
|
|
||
|
|
// Build consumer reports.
|
||
|
|
let mut all_claimed: BTreeSet<String> = BTreeSet::new();
|
||
|
|
let mut consumer_reports = Vec::with_capacity(section.consumers.len());
|
||
|
|
|
||
|
|
for consumer in §ion.consumers {
|
||
|
|
let consumer_fields: BTreeSet<String> = if consumer.fields.is_empty() {
|
||
|
|
// Empty field list means the consumer claims all NCL fields.
|
||
|
|
all_claimed.extend(ncl_fields.iter().cloned());
|
||
|
|
consumer_reports.push(ConsumerCoherenceReport {
|
||
|
|
consumer_id: consumer.id.clone(),
|
||
|
|
kind: format!("{:?}", consumer.kind),
|
||
|
|
missing_in_ncl: vec![],
|
||
|
|
extra_in_ncl: vec![],
|
||
|
|
});
|
||
|
|
continue;
|
||
|
|
} else {
|
||
|
|
consumer.fields.iter().cloned().collect()
|
||
|
|
};
|
||
|
|
|
||
|
|
all_claimed.extend(consumer_fields.iter().cloned());
|
||
|
|
|
||
|
|
let missing_in_ncl: Vec<String> =
|
||
|
|
consumer_fields.difference(&ncl_fields).cloned().collect();
|
||
|
|
let extra_in_ncl: Vec<String> = ncl_fields.difference(&consumer_fields).cloned().collect();
|
||
|
|
|
||
|
|
consumer_reports.push(ConsumerCoherenceReport {
|
||
|
|
consumer_id: consumer.id.clone(),
|
||
|
|
kind: format!("{:?}", consumer.kind),
|
||
|
|
missing_in_ncl,
|
||
|
|
extra_in_ncl,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
let unclaimed_fields: Vec<String> = ncl_fields.difference(&all_claimed).cloned().collect();
|
||
|
|
|
||
|
|
let has_missing = consumer_reports
|
||
|
|
.iter()
|
||
|
|
.any(|c| !c.missing_in_ncl.is_empty());
|
||
|
|
let status = if !unclaimed_fields.is_empty() || has_missing {
|
||
|
|
CoherenceStatus::Warning
|
||
|
|
} else {
|
||
|
|
CoherenceStatus::Ok
|
||
|
|
};
|
||
|
|
|
||
|
|
SectionCoherenceReport {
|
||
|
|
section_id: section.id.clone(),
|
||
|
|
ncl_fields: ncl_fields.into_iter().collect(),
|
||
|
|
consumers: consumer_reports,
|
||
|
|
unclaimed_fields,
|
||
|
|
status,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Extract the field names for a section from the NCL export JSON.
|
||
|
|
///
|
||
|
|
/// If the JSON has a top-level key matching `section_id`, returns the keys of
|
||
|
|
/// that sub-object. Otherwise treats the entire top-level object as the section
|
||
|
|
/// fields. Strips `_meta_*` and `_overrides_meta` keys from the result.
|
||
|
|
fn extract_section_fields(json: &serde_json::Value, section_id: &str) -> BTreeSet<String> {
|
||
|
|
let obj = if let Some(sub) = json.get(section_id).and_then(|v| v.as_object()) {
|
||
|
|
sub.keys().cloned().collect()
|
||
|
|
} else if let Some(top) = json.as_object() {
|
||
|
|
top.keys()
|
||
|
|
.filter(|k| !k.starts_with("_meta_") && *k != "_overrides_meta")
|
||
|
|
.cloned()
|
||
|
|
.collect()
|
||
|
|
} else {
|
||
|
|
BTreeSet::new()
|
||
|
|
};
|
||
|
|
obj
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Generate a quickref document for a project's config surface.
|
||
|
|
///
|
||
|
|
/// Combines: NCL export values + manifest metadata (descriptions, rationales,
|
||
|
|
/// consumers) + override history from `_overrides_meta` + coherence status.
|
||
|
|
pub async fn build_quickref(
|
||
|
|
slug: &str,
|
||
|
|
surface: &ConfigSurface,
|
||
|
|
project_root: &Path,
|
||
|
|
cache: &NclCache,
|
||
|
|
import_path: Option<&str>,
|
||
|
|
section_filter: Option<&str>,
|
||
|
|
) -> serde_json::Value {
|
||
|
|
let entry_point = surface.entry_point_path(project_root);
|
||
|
|
let full_export = cache.export(&entry_point, import_path).await.ok();
|
||
|
|
|
||
|
|
let coherence = check_project(
|
||
|
|
slug,
|
||
|
|
surface,
|
||
|
|
project_root,
|
||
|
|
cache,
|
||
|
|
import_path,
|
||
|
|
section_filter,
|
||
|
|
)
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let coherence_by_id: BTreeMap<String, &SectionCoherenceReport> = coherence
|
||
|
|
.sections
|
||
|
|
.iter()
|
||
|
|
.map(|s| (s.section_id.clone(), s))
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
let sections: Vec<serde_json::Value> = surface
|
||
|
|
.sections
|
||
|
|
.iter()
|
||
|
|
.filter(|s| section_filter.is_none_or(|f| s.id == f))
|
||
|
|
.map(|section| {
|
||
|
|
let current_values = full_export
|
||
|
|
.as_ref()
|
||
|
|
.and_then(|(json, _)| json.get(§ion.id))
|
||
|
|
.cloned()
|
||
|
|
.unwrap_or(serde_json::Value::Null);
|
||
|
|
|
||
|
|
// Extract _meta_{section} from the section's own NCL file.
|
||
|
|
let meta_key = format!("_meta_{}", section.id);
|
||
|
|
let section_ncl_path =
|
||
|
|
project_root.join(&surface.config_root).join(§ion.file);
|
||
|
|
let meta = tokio::task::block_in_place(|| {
|
||
|
|
// Use a sync export via the cached path — avoids async recursion.
|
||
|
|
std::process::Command::new("nickel")
|
||
|
|
.args(["export", "--format", "json"])
|
||
|
|
.arg(§ion_ncl_path)
|
||
|
|
.current_dir(project_root)
|
||
|
|
.output()
|
||
|
|
.ok()
|
||
|
|
.and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())
|
||
|
|
.and_then(|j| j.get(&meta_key).cloned())
|
||
|
|
});
|
||
|
|
|
||
|
|
// Extract override history from _overrides_meta if present.
|
||
|
|
let overrides = current_values
|
||
|
|
.as_object()
|
||
|
|
.and(full_export.as_ref())
|
||
|
|
.and_then(|(j, _)| {
|
||
|
|
j.get("_overrides_meta")
|
||
|
|
.and_then(|m| m.get("entries"))
|
||
|
|
.cloned()
|
||
|
|
})
|
||
|
|
.unwrap_or(serde_json::Value::Array(vec![]));
|
||
|
|
|
||
|
|
let coh = coherence_by_id.get(§ion.id);
|
||
|
|
let coherence_summary = serde_json::json!({
|
||
|
|
"unclaimed_fields": coh.map(|c| c.unclaimed_fields.as_slice()).unwrap_or(&[]),
|
||
|
|
"status": coh.map(|c| format!("{:?}", c.status)).unwrap_or_else(|| "unknown".into()),
|
||
|
|
});
|
||
|
|
|
||
|
|
let consumers: Vec<serde_json::Value> = section
|
||
|
|
.consumers
|
||
|
|
.iter()
|
||
|
|
.map(|c| {
|
||
|
|
serde_json::json!({
|
||
|
|
"id": c.id,
|
||
|
|
"kind": format!("{:?}", c.kind),
|
||
|
|
"ref": c.reference,
|
||
|
|
"fields": c.fields,
|
||
|
|
})
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
let mut s = serde_json::json!({
|
||
|
|
"id": section.id,
|
||
|
|
"file": section.file,
|
||
|
|
"mutable": section.mutable,
|
||
|
|
"description": section.description,
|
||
|
|
"rationale": section.rationale,
|
||
|
|
"contract": section.contract,
|
||
|
|
"current_values": current_values,
|
||
|
|
"overrides": overrides,
|
||
|
|
"consumers": consumers,
|
||
|
|
"coherence": coherence_summary,
|
||
|
|
});
|
||
|
|
|
||
|
|
if let Some(meta_val) = meta {
|
||
|
|
if let Some(obj) = s.as_object_mut() {
|
||
|
|
merge_meta_into_section(obj, &meta_val);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
s
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
serde_json::json!({
|
||
|
|
"project": slug,
|
||
|
|
"generated_at": std::time::SystemTime::now()
|
||
|
|
.duration_since(std::time::UNIX_EPOCH)
|
||
|
|
.map(|d| d.as_secs())
|
||
|
|
.unwrap_or(0),
|
||
|
|
"config_root": surface.config_root.display().to_string(),
|
||
|
|
"entry_point": surface.entry_point,
|
||
|
|
"kind": format!("{:?}", surface.kind),
|
||
|
|
"sections": sections,
|
||
|
|
"overall_coherence": format!("{:?}", coherence.overall_status()),
|
||
|
|
})
|
||
|
|
}
|