use std::collections::HashMap;
use std::sync::Arc;
use indexmap::IndexMap;
use memstead_schema::{Schema, SchemaRef};
use serde::Serialize;
use crate::engine::EngineError;
use crate::engine::mutation::unknown_type_error;
use crate::entity::Entity;
use crate::runtime_validator::{
CrossMemRelCheck, READ_ONLY_METADATA_KEYS, RelationshipCheck, missing_required_fields,
missing_required_sections, parse_metadata_value, validate_cross_mem_edge, validate_rel_shape,
validate_rel_type, validate_section_keys,
};
use crate::store::Store;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IntegrityAxis {
Consistency,
Conformance,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct BodyObservation {
pub id: String,
pub code: String,
pub fate: ObservationFate,
pub detail: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ObservationFate {
Absorbed,
Dropped,
}
#[derive(Debug, Clone, Serialize)]
pub struct IntegrityFinding {
pub id: String,
pub axis: IntegrityAxis,
pub code: String,
pub detail: serde_json::Value,
}
impl BodyObservation {
#[cfg(test)]
fn occurrences_is(&self, n: u64) -> bool {
self.detail["occurrences"].as_u64() == Some(n)
}
}
impl IntegrityFinding {
fn conformance(id: &crate::entity::EntityId, err: &EngineError) -> Self {
Self {
id: id.to_string(),
axis: IntegrityAxis::Conformance,
code: err.code().to_string(),
detail: err.details(),
}
}
fn conformance_with_detail(
id: &crate::entity::EntityId,
code: &str,
detail: serde_json::Value,
) -> Self {
Self {
id: id.to_string(),
axis: IntegrityAxis::Conformance,
code: code.to_string(),
detail,
}
}
}
pub(crate) fn swallowed_declared_sections(
body: &str,
type_def: &memstead_schema::TypeDefinition,
) -> Vec<String> {
let declared: std::collections::BTreeSet<&str> = type_def
.sections
.iter()
.map(|s| s.heading.as_str())
.collect();
let mut out = Vec::new();
for line in body.lines() {
if let Some(heading) = line.strip_prefix("## ")
&& declared.contains(heading.trim())
&& !out.iter().any(|h| h == heading.trim())
{
out.push(heading.trim().to_string());
}
}
out
}
pub fn conformance_findings(
store: &Store,
mem: &str,
schema: &Schema,
mem_schemas: &HashMap<String, Arc<Schema>>,
) -> Vec<IntegrityFinding> {
let mut entities: Vec<&Entity> = store
.all_entities()
.filter(|e| e.mem == mem && !e.stub)
.collect();
entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
let mut findings = Vec::new();
for entity in entities {
lint_entity(store, entity, schema, mem_schemas, &mut findings);
}
findings
}
pub fn body_observations(store: &Store, mem: &str, schema: &Schema) -> Vec<BodyObservation> {
let mut entities: Vec<&Entity> = store
.all_entities()
.filter(|e| e.mem == mem && !e.stub)
.collect();
entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
let mut out = Vec::new();
for entity in entities {
let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
continue;
};
observe_entity(entity, type_def, &mut out);
}
out.sort_by(|a, b| {
a.id.cmp(&b.id)
.then_with(|| a.code.cmp(&b.code))
.then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
});
out
}
fn observe_entity(
entity: &Entity,
type_def: &memstead_schema::TypeDefinition,
out: &mut Vec<BodyObservation>,
) {
let known: std::collections::BTreeSet<String> = type_def
.sections
.iter()
.map(|s| s.key.clone())
.chain(std::iter::once("relationships".to_string()))
.collect();
let catch_all = type_def.catch_all_section();
let mut seen: std::collections::BTreeMap<&str, usize> = Default::default();
for heading in &entity.raw_section_headings {
let occurrence = {
let n = seen.entry(heading.as_str()).or_default();
*n += 1;
*n
};
if known.contains(&memstead_schema::derive_section_key(heading)) {
continue;
}
if occurrence > 1 {
continue;
}
let absorbed_into = catch_all.map(|c| c.key.as_str());
let kept = absorbed_into.is_some() && heading_has_body(entity, heading, catch_all);
out.push(BodyObservation {
id: entity.id.to_string(),
code: "ABSORBED_SECTION".to_string(),
fate: if kept {
ObservationFate::Absorbed
} else {
ObservationFate::Dropped
},
detail: serde_json::json!({
"heading": heading,
"entity_type": entity.entity_type,
"absorbed_into": absorbed_into,
"note": if kept {
"the type does not declare this heading; its content is kept \
byte-verbatim in the catch-all section and survives the next write"
} else if absorbed_into.is_some() {
"the type does not declare this heading and its body is empty; the \
catch-all skips empty content, so the next write does NOT keep it"
} else {
"the type does not declare this heading and has no catch-all section, \
so the next write does NOT keep it"
},
}),
});
}
for (heading, count) in seen.iter().filter(|(_, n)| **n > 1) {
out.push(BodyObservation {
id: entity.id.to_string(),
code: "REPEATED_SECTION_HEADING".to_string(),
fate: ObservationFate::Dropped,
detail: serde_json::json!({
"heading": heading,
"occurrences": count,
"note": "section splitting is first-wins: the body under the first \
occurrence is kept and every later body was NOT kept",
}),
});
}
for key in entity.metadata.keys() {
if RESERVED_METADATA.contains(&key.as_str()) || type_def.metadata_field(key).is_some() {
continue;
}
out.push(BodyObservation {
id: entity.id.to_string(),
code: "UNDECLARED_METADATA_KEY".to_string(),
fate: ObservationFate::Dropped,
detail: serde_json::json!({
"key": key,
"entity_type": entity.entity_type,
"note": "the type does not declare this frontmatter key; the generator \
emits only declared fields, so the next write drops it",
}),
});
}
}
const RESERVED_METADATA: &[&str] = &["type", "created_date", "last_modified"];
fn heading_has_body(
entity: &Entity,
heading: &str,
catch_all: Option<&memstead_schema::SectionDef>,
) -> bool {
let Some(c) = catch_all else { return false };
let Some(value) = entity.sections.get(c.key.as_str()) else {
return false;
};
value.lines().any(|line| {
line.strip_prefix("## ")
.is_some_and(|rest| rest.trim() == heading)
})
}
pub fn consistency_findings(
store: &Store,
mem: &str,
grant_allows: &dyn Fn(&str, &str) -> bool,
) -> Vec<IntegrityFinding> {
let mut findings = Vec::new();
for link in super::health::collect_dangling_links(store, Some(mem)) {
findings.push(IntegrityFinding {
id: link.from.to_string(),
axis: IntegrityAxis::Consistency,
code: link.kind.code().to_string(),
detail: serde_json::json!({
"from": link.from,
"target_id": link.target_id,
"target_path": link.target_path,
"section": link.section,
"repair": link.kind.repair(),
}),
});
}
for entity in store.all_entities() {
if entity.mem != mem || entity.stub {
continue;
}
for rel in &entity.relationships {
let to_mem = rel.target.mem();
if to_mem == entity.mem {
continue;
}
if grant_allows(&entity.mem, to_mem) {
continue;
}
findings.push(IntegrityFinding {
id: entity.id.to_string(),
axis: IntegrityAxis::Consistency,
code: "CROSS_MEM_EDGE_UNGRANTED".to_string(),
detail: serde_json::json!({
"from": entity.id,
"target_id": rel.target,
"rel_type": rel.rel_type,
"from_mem": entity.mem,
"to_mem": to_mem,
"cause": "no cross-mem grant permits this pair",
"repair": "grant the pair with `memstead workspace grant-cross-link`, \
or remove the edge with `memstead relate --remove` \
(removal needs no grant)",
}),
});
}
}
for (stub_id, referrers) in crate::graph::query::find_stubs(store) {
if stub_id.mem() != mem {
continue;
}
findings.push(IntegrityFinding {
id: stub_id.to_string(),
axis: IntegrityAxis::Consistency,
code: "ORPHAN_STUB".to_string(),
detail: serde_json::json!({ "referrers": referrers }),
});
}
findings.sort_by(|a, b| {
a.id.cmp(&b.id)
.then_with(|| a.code.cmp(&b.code))
.then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
});
findings
}
pub fn entity_conformance_findings(
store: &Store,
entity: &Entity,
schema: &Schema,
mem_schemas: &HashMap<String, Arc<Schema>>,
) -> Vec<IntegrityFinding> {
let mut findings = Vec::new();
lint_entity(store, entity, schema, mem_schemas, &mut findings);
findings
}
fn lint_entity(
store: &Store,
entity: &Entity,
schema: &Schema,
mem_schemas: &HashMap<String, Arc<Schema>>,
findings: &mut Vec<IntegrityFinding>,
) {
let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
findings.push(IntegrityFinding::conformance(
&entity.id,
&unknown_type_error(schema, &entity.entity_type),
));
return;
};
for (key, value) in &entity.sections {
let Some(fence) = crate::markdown::closing_fence_if_unterminated(value.trim()) else {
continue;
};
let swallowed = swallowed_declared_sections(value, type_def);
findings.push(IntegrityFinding::conformance_with_detail(
&entity.id,
"UNTERMINATED_FENCE",
serde_json::json!({
"section": key,
"fence": fence,
"entity_type": entity.entity_type,
"swallowed_sections": swallowed,
"note": if swallowed.is_empty() {
"this section ends inside an unterminated code fence; no declared section \
follows it in the file yet, but the next write would bury whatever does"
} else {
"these declared sections are NOT empty: their content sits verbatim inside \
the section above, hidden by an unterminated code fence. Supply a corrected \
body for that section; the next write would otherwise close the fence \
around them and make the loss permanent"
},
}),
));
}
for key in entity.sections.keys() {
if let Err(v) = validate_section_keys(std::iter::once(key.as_str()), type_def) {
findings.push(IntegrityFinding::conformance(
&entity.id,
&EngineError::Validation(v),
));
}
}
let missing_sections = missing_required_sections(type_def, &entity.sections);
if !missing_sections.is_empty() {
let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
if !type_def.write_rules.is_empty() {
type_guidance.insert(entity.entity_type.clone(), type_def.write_rules.clone());
}
findings.push(IntegrityFinding::conformance(
&entity.id,
&EngineError::MissingRequiredSection {
entity_type: entity.entity_type.clone(),
missing_count: missing_sections.len(),
sections: missing_sections,
type_guidance,
pre_announced_missing_fields: Vec::new(),
},
));
}
let mut supplied: IndexMap<String, String> = IndexMap::new();
for (key, value) in &entity.metadata {
let raw = value.to_frontmatter_string();
supplied.insert(key.clone(), raw.clone());
if READ_ONLY_METADATA_KEYS.iter().any(|k| k == key) {
continue;
}
if let Err(v) = parse_metadata_value(key, &raw, type_def) {
findings.push(IntegrityFinding::conformance(
&entity.id,
&EngineError::Validation(v),
));
}
}
let missing_fields = missing_required_fields(type_def, &supplied);
if let Some(first) = missing_fields.first() {
findings.push(IntegrityFinding::conformance(
&entity.id,
&EngineError::RequiredFieldUnset {
field: first.key.clone(),
entity_type: entity.entity_type.clone(),
field_description: Some(first.description.clone()),
enum_values: first.enum_values.clone(),
type_write_rules: type_def.write_rules.clone(),
on_create: true,
missing: missing_fields.clone(),
},
));
}
let (src_name, src_version) = schema.id();
for rel in &entity.relationships {
let target_mem = rel.target.mem();
let target_schema = if target_mem == entity.mem {
None
} else {
mem_schemas.get(target_mem)
};
let cross_mem_different = target_schema.map(|t| t.id().0 != src_name).unwrap_or(false);
let target_type = store
.get(&rel.target)
.map(|e| e.entity_type.clone())
.filter(|t| !t.is_empty());
if cross_mem_different {
let target = target_schema.expect("Some when cross_mem_different");
let (t_name, t_version) = target.id();
let target_ref = SchemaRef::new(t_name, t_version.clone());
match validate_cross_mem_edge(
&rel.rel_type,
&entity.entity_type,
target_type.as_deref(),
schema,
&target_ref,
) {
CrossMemRelCheck::Ok => {}
CrossMemRelCheck::EdgeNotDeclared => {
findings.push(IntegrityFinding::conformance(
&entity.id,
&EngineError::CrossMemEdgeNotDeclared {
source_schema: format!("{src_name}@{src_version}"),
target_schema: target_ref.as_display(),
rel_type: rel.rel_type.clone(),
from_id: entity.id.to_string(),
to_id: rel.target.to_string(),
},
));
}
CrossMemRelCheck::Invalid(v) => {
findings.push(IntegrityFinding::conformance(
&entity.id,
&EngineError::Validation(v),
));
}
}
} else {
match validate_rel_type(&rel.rel_type, schema) {
Ok(RelationshipCheck::Ok) | Ok(RelationshipCheck::OpenWarning(_)) => {}
Err(v) => {
findings.push(IntegrityFinding::conformance(
&entity.id,
&EngineError::Validation(v),
));
continue;
}
}
if let Err(v) = validate_rel_shape(
&rel.rel_type,
&entity.entity_type,
target_type.as_deref(),
schema,
) {
findings.push(IntegrityFinding::conformance(
&entity.id,
&EngineError::Validation(v),
));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::{EntityId, MetadataValue, Relationship};
const TYPE_TAIL: &str = r#"sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: false
write_rules: []
- key: notes
heading: Notes
required: false
search_weight: 1.0
catch_all: true
write_rules: []
metadata_fields:
- key: status
description: Lifecycle state
field_type: string
enum_values:
- open
- closed
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: _default
no_self_loop_relationships: []
updatable_fields:
- title
- body
- notes
- status
health_required_fields:
- body
staleness_threshold_days: 90
write_rules: []
"#;
const PLAIN_TYPE_TAIL: &str = r#"sections:
- key: body
heading: Body
required: false
search_weight: 10.0
catch_all: true
write_rules: []
metadata_fields: []
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: _default
no_self_loop_relationships: []
updatable_fields:
- title
- body
health_required_fields: []
staleness_threshold_days: 90
write_rules: []
"#;
fn lint_schema() -> Arc<Schema> {
let manifest = r#"name: lint-src
version: 0.1.0
description: linter test schema
when_to_use: tests
types:
- doc
- req
relationships:
mode: strict
definitions:
- name: IMPLEMENTS
description: shape-pinned
default_weight: 1.0
source_types: [doc]
target_types: [doc]
- name: _default
description: fallback
default_weight: 1.0
cross_mem_relationships:
- to_schema: other
definitions:
- name: ADDRESSES
description: outbound
default_weight: 1.0
source_types: [doc]
target_types: [requirement]
community:
resolution: 1.0
seed: 42
"#;
Arc::new(
memstead_schema::load_schema_from_memory(
manifest,
&[
(
"doc".to_string(),
format!("name: doc\ndescription: t\nwhen_to_use: tests\n{TYPE_TAIL}"),
),
(
"req".to_string(),
format!("name: req\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
),
],
)
.expect("lint schema loads"),
)
}
fn other_schema() -> Arc<Schema> {
let manifest = r#"name: other
version: 1.0.0
description: target schema
when_to_use: tests
types:
- requirement
- task
relationships:
mode: strict
definitions:
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
Arc::new(
memstead_schema::load_schema_from_memory(
manifest,
&[
(
"requirement".to_string(),
format!(
"name: requirement\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"
),
),
(
"task".to_string(),
format!("name: task\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
),
],
)
.expect("other schema loads"),
)
}
fn entity(mem: &str, slug: &str, entity_type: &str) -> Entity {
Entity {
id: EntityId::new(mem, slug),
title: slug.to_string(),
entity_type: entity_type.to_string(),
mem: mem.to_string(),
file_path: format!("{slug}.md"),
metadata: IndexMap::new(),
sections: IndexMap::new(),
relationships: Vec::new(),
content_hash: "h".to_string(),
stub: false,
stub_kind: None,
heading_spans: Default::default(),
raw_section_headings: Vec::new(),
}
}
fn conformant_entity(mem: &str, slug: &str) -> Entity {
let mut e = entity(mem, slug, "doc");
e.sections.insert("body".to_string(), "content".to_string());
e.metadata.insert(
"status".to_string(),
MetadataValue::String("open".to_string()),
);
e
}
fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
entries
.iter()
.map(|(v, s)| (v.to_string(), s.clone()))
.collect()
}
fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
findings.iter().map(|f| f.code.as_str()).collect()
}
#[test]
fn an_absorbed_heading_is_observed_and_never_a_violation() {
let schema = lint_schema();
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.raw_section_headings = vec!["Body".into(), "Field Notes".into()];
e.sections.insert(
"notes".into(),
"## Field Notes\n\nsomething useful\n".into(),
);
let id = e.id.to_string();
store.upsert(e.id.clone(), e);
let obs = body_observations(&store, "lv", &schema);
assert_eq!(obs.len(), 1, "got {obs:?}");
assert_eq!(obs[0].code, "ABSORBED_SECTION");
assert_eq!(obs[0].id, id);
assert_eq!(obs[0].detail["heading"], "Field Notes");
assert_eq!(
obs[0].fate,
ObservationFate::Absorbed,
"the content survives the next write, and the report must say so"
);
let schemas = schemas_for(&[("lv", schema.clone())]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
assert!(
findings.is_empty(),
"healthy catch-all use must not be a violation: {:?}",
codes(&findings)
);
}
#[test]
fn a_bare_undeclared_heading_is_observed_as_dropped() {
let schema = lint_schema();
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
store.upsert(e.id.clone(), e);
let obs = body_observations(&store, "lv", &schema);
assert_eq!(obs.len(), 1, "got {obs:?}");
assert_eq!(obs[0].code, "ABSORBED_SECTION");
assert_eq!(
obs[0].fate,
ObservationFate::Dropped,
"an empty heading is skipped by the catch-all, so it does NOT survive"
);
}
#[test]
fn an_undeclared_metadata_key_is_observed_as_dropped() {
let schema = lint_schema();
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.metadata
.insert("reviewer".into(), MetadataValue::String("ada".into()));
e.metadata
.insert("last_modified".into(), MetadataValue::String("x".into()));
store.upsert(e.id.clone(), e);
let obs = body_observations(&store, "lv", &schema);
assert_eq!(obs.len(), 1, "got {obs:?}");
assert_eq!(obs[0].code, "UNDECLARED_METADATA_KEY");
assert_eq!(obs[0].detail["key"], "reviewer");
assert_eq!(obs[0].fate, ObservationFate::Dropped);
}
#[test]
fn a_repeated_heading_is_observed_in_both_silent_cases() {
let schema = lint_schema();
for (headings, label) in [
(
vec!["Body", "Scratch", "Scratch"],
"undeclared heading twice",
),
(
vec!["Body", "Notes", "Notes"],
"the catch-all's own heading twice",
),
] {
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.raw_section_headings = headings.iter().map(|h| h.to_string()).collect();
e.sections
.insert("notes".into(), "## Scratch\n\nkept\n".into());
store.upsert(e.id.clone(), e);
let obs = body_observations(&store, "lv", &schema);
let repeats: Vec<_> = obs
.iter()
.filter(|o| o.code == "REPEATED_SECTION_HEADING")
.collect();
assert_eq!(repeats.len(), 1, "{label}: got {obs:?}");
assert!(repeats[0].occurrences_is(2), "{label}");
assert_eq!(repeats[0].fate, ObservationFate::Dropped, "{label}");
}
}
#[test]
fn an_ordinary_entity_produces_no_observations() {
let schema = lint_schema();
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.raw_section_headings = vec!["Body".into(), "Notes".into(), "Relationships".into()];
e.sections.insert("notes".into(), "plain prose\n".into());
store.upsert(e.id.clone(), e);
assert!(
body_observations(&store, "lv", &schema).is_empty(),
"declared headings, each once, the relationships block, no undeclared keys"
);
}
#[test]
fn a_repeated_undeclared_heading_claims_survival_only_for_the_first() {
let schema = lint_schema();
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.raw_section_headings = vec!["Body".into(), "Scratch".into(), "Scratch".into()];
e.sections
.insert("notes".into(), "## Scratch\n\nkept\n".into());
store.upsert(e.id.clone(), e);
let obs = body_observations(&store, "lv", &schema);
let absorbed: Vec<_> = obs
.iter()
.filter(|o| o.code == "ABSORBED_SECTION")
.collect();
assert_eq!(
absorbed.len(),
1,
"one per heading, not per occurrence: {obs:?}"
);
assert_eq!(absorbed[0].fate, ObservationFate::Absorbed);
let repeats: Vec<_> = obs
.iter()
.filter(|o| o.code == "REPEATED_SECTION_HEADING")
.collect();
assert_eq!(repeats.len(), 1, "got: {obs:?}");
assert_eq!(repeats[0].detail["occurrences"], 2);
}
#[test]
fn the_auto_managed_relationships_block_is_never_an_observation() {
let schema = lint_schema();
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.raw_section_headings = vec!["Relationships".into()];
store.upsert(e.id.clone(), e);
assert!(
body_observations(&store, "lv", &schema).is_empty(),
"the relationships block is engine-owned, not undeclared content"
);
}
#[test]
fn a_heading_named_inside_prose_is_not_mistaken_for_a_kept_one() {
let schema = lint_schema();
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
e.sections
.insert("notes".into(), "we discussed Scratch at length\n".into());
store.upsert(e.id.clone(), e);
let obs = body_observations(&store, "lv", &schema);
let absorbed: Vec<_> = obs
.iter()
.filter(|o| o.code == "ABSORBED_SECTION")
.collect();
assert_eq!(absorbed.len(), 1, "got: {obs:?}");
assert_eq!(
absorbed[0].fate,
ObservationFate::Dropped,
"a bare heading whose text appears in prose is still dropped"
);
}
#[test]
fn an_unterminated_fence_names_the_sections_it_swallowed() {
let schema = lint_schema();
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.sections.insert(
"body".into(),
"intro\n\n```rust\nfn main() {}\n\n## Notes\n\nthe real notes\n".into(),
);
e.sections.shift_remove("notes");
store.upsert(e.id.clone(), e);
let schemas = schemas_for(&[("lv", schema.clone())]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
let fence: Vec<_> = findings
.iter()
.filter(|f| f.code == "UNTERMINATED_FENCE")
.collect();
assert_eq!(fence.len(), 1, "got: {:?}", codes(&findings));
assert_eq!(fence[0].id, "lv--alpha");
assert_eq!(fence[0].detail["section"], "body");
assert_eq!(fence[0].detail["fence"], "```");
assert_eq!(
fence[0].detail["swallowed_sections"],
serde_json::json!(["Notes"]),
);
assert!(!findings.is_empty());
}
#[test]
fn an_entity_with_no_open_fence_gains_no_fence_finding() {
let schema = lint_schema();
let schemas = schemas_for(&[("lv", schema.clone())]);
for body in [
"just prose",
"prose\n\n```rust\nfn main() {}\n```\n\nmore",
"```md\n## Notes\n```",
] {
let mut store = Store::new();
let mut e = conformant_entity("lv", "alpha");
e.sections.insert("body".into(), body.into());
store.upsert(e.id.clone(), e);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
assert!(
!findings.iter().any(|f| f.code == "UNTERMINATED_FENCE"),
"body {body:?} produced: {:?}",
codes(&findings)
);
}
}
#[test]
fn clean_mem_produces_no_findings() {
let schema = lint_schema();
let mut store = Store::new();
let a = conformant_entity("lv", "alpha");
let mut b = conformant_entity("lv", "beta");
b.relationships
.push(Relationship::new("IMPLEMENTS", a.id.clone()));
store.upsert(a.id.clone(), a);
store.upsert(b.id.clone(), b);
let schemas = schemas_for(&[("lv", schema.clone())]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
assert!(findings.is_empty(), "got: {:?}", codes(&findings));
}
#[test]
fn missing_required_section_and_field_carry_write_time_codes() {
let schema = lint_schema();
let mut store = Store::new();
let e = entity("lv", "broken", "doc");
let id = e.id.to_string();
store.upsert(e.id.clone(), e);
let schemas = schemas_for(&[("lv", schema.clone())]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
let cs = codes(&findings);
assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
for f in &findings {
assert_eq!(f.id, id);
assert_eq!(f.axis, IntegrityAxis::Conformance);
}
let section_finding = findings
.iter()
.find(|f| f.code == "MISSING_REQUIRED_SECTION")
.unwrap();
assert_eq!(
section_finding.detail["sections"][0]["key"].as_str(),
Some("body")
);
let field_finding = findings
.iter()
.find(|f| f.code == "REQUIRED_FIELD_UNSET")
.unwrap();
assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
}
#[test]
fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
let schema = lint_schema();
let mut store = Store::new();
let mut e = conformant_entity("lv", "drifted");
e.metadata.insert(
"status".to_string(),
MetadataValue::String("banana".to_string()),
);
e.metadata
.insert("wat".to_string(), MetadataValue::String("x".to_string()));
e.sections.insert("bogus".to_string(), "text".to_string());
store.upsert(e.id.clone(), e);
let schemas = schemas_for(&[("lv", schema.clone())]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
let cs = codes(&findings);
assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
let enum_finding = findings
.iter()
.find(|f| f.code == "INVALID_ENUM_VALUE")
.unwrap();
assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
assert_eq!(
enum_finding.detail["allowed"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect::<Vec<_>>(),
vec!["open", "closed"]
);
}
#[test]
fn unknown_type_short_circuits_with_unknown_entity_type() {
let schema = lint_schema();
let mut store = Store::new();
let e = entity("lv", "mystery", "ghost");
store.upsert(e.id.clone(), e);
let schemas = schemas_for(&[("lv", schema.clone())]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
}
#[test]
fn invalid_rel_type_and_shape_surface() {
let schema = lint_schema();
let mut store = Store::new();
let mut req_target = conformant_entity("lv", "target");
req_target.entity_type = "req".to_string();
req_target.metadata.clear();
req_target.sections.clear();
let mut e = conformant_entity("lv", "edges");
e.relationships
.push(Relationship::new("UNDECLARED", req_target.id.clone()));
e.relationships
.push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
store.upsert(req_target.id.clone(), req_target);
store.upsert(e.id.clone(), e);
let schemas = schemas_for(&[("lv", schema.clone())]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
let cs = codes(&findings);
assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
}
#[test]
fn cross_mem_edges_lint_like_the_write_path() {
let schema = lint_schema();
let other = other_schema();
let mut store = Store::new();
let mut requirement = entity("tv", "goal", "requirement");
requirement
.sections
.insert("body".to_string(), "x".to_string());
let mut task = entity("tv", "chore", "task");
task.sections.insert("body".to_string(), "x".to_string());
let mut e = conformant_entity("lv", "linker");
e.relationships
.push(Relationship::new("ADDRESSES", requirement.id.clone()));
e.relationships
.push(Relationship::new("ADDRESSES", task.id.clone()));
e.relationships
.push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
store.upsert(requirement.id.clone(), requirement);
store.upsert(task.id.clone(), task);
store.upsert(e.id.clone(), e);
let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
let cs = codes(&findings);
assert_eq!(
cs,
vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
"declared+conformant edge must stay silent; got: {cs:?}"
);
}
#[test]
fn stub_entities_are_skipped() {
let schema = lint_schema();
let mut store = Store::new();
let mut stub = entity("lv", "ghost-stub", "");
stub.stub = true;
store.upsert(stub.id.clone(), stub);
let schemas = schemas_for(&[("lv", schema.clone())]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
assert!(findings.is_empty());
}
#[test]
fn other_mems_are_out_of_scope() {
let schema = lint_schema();
let mut store = Store::new();
let e = entity("elsewhere", "broken", "doc");
store.upsert(e.id.clone(), e);
let schemas = schemas_for(&[("lv", schema.clone())]);
let findings = conformance_findings(&store, "lv", &schema, &schemas);
assert!(findings.is_empty());
}
#[test]
fn findings_are_deterministic_and_id_ordered() {
let schema = lint_schema();
let mut store = Store::new();
for slug in ["zeta", "alpha", "mid"] {
let e = entity("lv", slug, "doc");
store.upsert(e.id.clone(), e);
}
let schemas = schemas_for(&[("lv", schema.clone())]);
let first = conformance_findings(&store, "lv", &schema, &schemas);
let second = conformance_findings(&store, "lv", &schema, &schemas);
let a = serde_json::to_string(&first).unwrap();
let b = serde_json::to_string(&second).unwrap();
assert_eq!(a, b, "two runs must be byte-identical");
let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
let mut sorted = ids.clone();
sorted.sort();
assert_eq!(ids, sorted, "findings must be in lexical id order");
}
#[test]
fn lint_against_target_schema_differs_from_pin() {
let pin = lint_schema();
let target = other_schema();
let mut store = Store::new();
let mut e = entity("lv", "shifting", "task");
e.sections.insert("body".to_string(), "x".to_string());
store.upsert(e.id.clone(), e);
let schemas = schemas_for(&[("lv", pin.clone())]);
let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
let against_target = conformance_findings(&store, "lv", &target, &schemas);
assert!(
against_target.is_empty(),
"got: {:?}",
codes(&against_target)
);
}
}