Skip to main content

memstead_base/ops/
integrity.rs

1//! Integrity linter — read-time conformance findings.
2//!
3//! The engine's schema validation runs at write time as refusals on
4//! `memstead_create` / `memstead_update` / `memstead_relate`. This module runs the
5//! same checks in a read context over the entities already on disk, so
6//! `memstead_health` can report the *conformance* axis: which entities of a
7//! mem would a write refuse under a given schema, and why.
8//!
9//! One validation truth, two contexts: every finding carries the same
10//! typed code (and the same recovery payload, via
11//! [`EngineError::code`] / [`EngineError::details`]) the corresponding
12//! write would refuse with. An entity that lints clean against schema
13//! S is accepted by a write under S, and vice versa — the linter never
14//! invents a parallel conformance vocabulary.
15//!
16//! Determinism: same store state and schema produce the same findings
17//! in the same order, byte for byte. Entities are visited in lexical
18//! id order; within one entity, checks run in a fixed sequence (type,
19//! section keys, required sections, metadata, required fields,
20//! relationships) and map/list iteration follows the entity's own
21//! deterministic on-disk order (`IndexMap` / `Vec`).
22
23use std::collections::HashMap;
24use std::sync::Arc;
25
26use indexmap::IndexMap;
27use memstead_schema::{Schema, SchemaRef};
28use serde::Serialize;
29
30use crate::engine::EngineError;
31use crate::engine::mutation::unknown_type_error;
32use crate::entity::Entity;
33use crate::runtime_validator::{
34    CrossMemRelCheck, READ_ONLY_METADATA_KEYS, RelationshipCheck, missing_required_fields,
35    missing_required_sections, parse_metadata_value, validate_cross_mem_edge, validate_rel_shape,
36    validate_rel_type, validate_section_keys,
37};
38use crate::store::Store;
39
40/// Which integrity axis a finding belongs to. Consistency findings
41/// (graph coherence: orphans, stubs, dangling links) come from the
42/// pre-existing health categories; conformance findings (entity vs
43/// schema) come from this linter.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "lowercase")]
46pub enum IntegrityAxis {
47    Consistency,
48    Conformance,
49}
50
51/// One per-entity integrity finding — the stable wire shape
52/// `{ id, axis, code, detail }`.
53///
54/// `code` is drawn from the write-time typed-code vocabulary
55/// ([`EngineError::code`]) and `detail` mirrors that code's write-time
56/// recovery payload ([`EngineError::details`]).
57#[derive(Debug, Clone, Serialize)]
58pub struct IntegrityFinding {
59    pub id: String,
60    pub axis: IntegrityAxis,
61    pub code: String,
62    pub detail: serde_json::Value,
63}
64
65impl IntegrityFinding {
66    fn conformance(id: &crate::entity::EntityId, err: &EngineError) -> Self {
67        Self {
68            id: id.to_string(),
69            axis: IntegrityAxis::Conformance,
70            code: err.code().to_string(),
71            detail: err.details(),
72        }
73    }
74}
75
76/// Run the conformance axis over every non-stub entity of `mem`,
77/// validating against `schema` (the mem's current pin, or an
78/// arbitrary target schema — the caller chooses the effective schema).
79///
80/// `mem_schemas` maps mem name → pinned schema for *every* mounted
81/// mem; it is consulted only to route relationship checks the same
82/// way the write path routes them (same schema *name* → intra-mem
83/// vocabulary of `schema`; different name → `schema`'s
84/// `cross_mem_relationships`). For cross-mem edges this is the
85/// read-time twin of the write-time `validate_cross_mem_edge` —
86/// including the target-entity type fetch — so target-type drift on
87/// existing edges surfaces here.
88pub fn conformance_findings(
89    store: &Store,
90    mem: &str,
91    schema: &Schema,
92    mem_schemas: &HashMap<String, Arc<Schema>>,
93) -> Vec<IntegrityFinding> {
94    let mut entities: Vec<&Entity> = store
95        .all_entities()
96        .filter(|e| e.mem == mem && !e.stub)
97        .collect();
98    entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
99
100    let mut findings = Vec::new();
101    for entity in entities {
102        lint_entity(store, entity, schema, mem_schemas, &mut findings);
103    }
104    findings
105}
106
107/// Run the consistency axis over `mem`, projecting the pre-existing
108/// graph-coherence checks into the integrity-finding shape: dangling
109/// wiki-links (`DANGLING_LINK`, on the linking entity) and stubs with
110/// their referrers (`ORPHAN_STUB`, on the stub). The category
111/// collectors are the same ones the dedicated health includes use —
112/// `integrity` is a projection, not a second implementation.
113pub fn consistency_findings(store: &Store, mem: &str) -> Vec<IntegrityFinding> {
114    let mut findings = Vec::new();
115    for link in super::health::collect_dangling_links(store, Some(mem)) {
116        findings.push(IntegrityFinding {
117            id: link.from.to_string(),
118            axis: IntegrityAxis::Consistency,
119            code: "DANGLING_LINK".to_string(),
120            detail: serde_json::json!({
121                "from": link.from,
122                "target_id": link.target_id,
123                "target_path": link.target_path,
124                "section": link.section,
125            }),
126        });
127    }
128    for (stub_id, referrers) in crate::graph::query::find_stubs(store) {
129        if stub_id.mem() != mem {
130            continue;
131        }
132        findings.push(IntegrityFinding {
133            id: stub_id.to_string(),
134            axis: IntegrityAxis::Consistency,
135            code: "ORPHAN_STUB".to_string(),
136            detail: serde_json::json!({ "referrers": referrers }),
137        });
138    }
139    // The collectors iterate the HashMap-backed store, so impose the
140    // full order here: id, code, then the rendered detail as the
141    // tiebreak for several same-code findings on one entity.
142    findings.sort_by(|a, b| {
143        a.id.cmp(&b.id)
144            .then_with(|| a.code.cmp(&b.code))
145            .then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
146    });
147    findings
148}
149
150/// Conformance findings for a single entity — the per-entity slice of
151/// [`conformance_findings`], exposed for callers that gate on one
152/// entity's current conformance (the `memstead_update` repair-power gate).
153/// Empty result == the entity is conformant: a write of this entity
154/// under `schema` would be accepted.
155pub fn entity_conformance_findings(
156    store: &Store,
157    entity: &Entity,
158    schema: &Schema,
159    mem_schemas: &HashMap<String, Arc<Schema>>,
160) -> Vec<IntegrityFinding> {
161    let mut findings = Vec::new();
162    lint_entity(store, entity, schema, mem_schemas, &mut findings);
163    findings
164}
165
166fn lint_entity(
167    store: &Store,
168    entity: &Entity,
169    schema: &Schema,
170    mem_schemas: &HashMap<String, Arc<Schema>>,
171    findings: &mut Vec<IntegrityFinding>,
172) {
173    // Type lookup gates everything else: an unknown type means no
174    // type definition to validate sections/metadata against, exactly
175    // as a write of this entity would refuse before any other check.
176    let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
177        findings.push(IntegrityFinding::conformance(
178            &entity.id,
179            &unknown_type_error(schema, &entity.entity_type),
180        ));
181        return;
182    };
183
184    // Section keys — one finding per unknown key (the write path stops
185    // at the first; the linter reports all so one repair pass fixes
186    // the entity).
187    for key in entity.sections.keys() {
188        if let Err(v) = validate_section_keys(std::iter::once(key.as_str()), type_def) {
189            findings.push(IntegrityFinding::conformance(
190                &entity.id,
191                &EngineError::Validation(v),
192            ));
193        }
194    }
195
196    // Required sections — one finding per entity, carrying every
197    // missing section, mirroring the create path's bundled refusal.
198    let missing_sections = missing_required_sections(type_def, &entity.sections);
199    if !missing_sections.is_empty() {
200        let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
201        if !type_def.write_rules.is_empty() {
202            type_guidance.insert(entity.entity_type.clone(), type_def.write_rules.clone());
203        }
204        findings.push(IntegrityFinding::conformance(
205            &entity.id,
206            &EngineError::MissingRequiredSection {
207                entity_type: entity.entity_type.clone(),
208                missing_count: missing_sections.len(),
209                sections: missing_sections,
210                type_guidance,
211            },
212        ));
213    }
214
215    // Metadata — unknown keys, enum violations, malformed typed values.
216    // Engine-managed keys (`mem`, `id`, `type`) are skipped exactly
217    // as the write path treats them (read-only, never caller-supplied).
218    let mut supplied: IndexMap<String, String> = IndexMap::new();
219    for (key, value) in &entity.metadata {
220        let raw = value.to_frontmatter_string();
221        supplied.insert(key.clone(), raw.clone());
222        if READ_ONLY_METADATA_KEYS.iter().any(|k| k == key) {
223            continue;
224        }
225        if let Err(v) = parse_metadata_value(key, &raw, type_def) {
226            findings.push(IntegrityFinding::conformance(
227                &entity.id,
228                &EngineError::Validation(v),
229            ));
230        }
231    }
232
233    // Required metadata fields the schema does not auto-fill — one
234    // finding per entity mirroring the create path's accumulator.
235    let missing_fields = missing_required_fields(type_def, &supplied);
236    if let Some(first) = missing_fields.first() {
237        findings.push(IntegrityFinding::conformance(
238            &entity.id,
239            &EngineError::RequiredFieldUnset {
240                field: first.key.clone(),
241                entity_type: entity.entity_type.clone(),
242                field_description: Some(first.description.clone()),
243                enum_values: first.enum_values.clone(),
244                type_write_rules: type_def.write_rules.clone(),
245                on_create: true,
246                missing: missing_fields.clone(),
247            },
248        ));
249    }
250
251    // Relationships — routed exactly as the write path routes them:
252    // same schema *name* on both ends (any version pair) consults the
253    // intra-mem vocabulary of the effective schema; a different name
254    // consults its `cross_mem_relationships`. An unmounted target
255    // mem falls back to the intra path, mirroring the relate path.
256    let (src_name, src_version) = schema.id();
257    for rel in &entity.relationships {
258        let target_mem = rel.target.mem();
259        let target_schema = if target_mem == entity.mem {
260            None
261        } else {
262            mem_schemas.get(target_mem)
263        };
264        let cross_mem_different = target_schema.map(|t| t.id().0 != src_name).unwrap_or(false);
265        let target_type = store
266            .get(&rel.target)
267            .map(|e| e.entity_type.clone())
268            .filter(|t| !t.is_empty());
269
270        if cross_mem_different {
271            let target = target_schema.expect("Some when cross_mem_different");
272            let (t_name, t_version) = target.id();
273            let target_ref = SchemaRef::new(t_name, t_version.clone());
274            match validate_cross_mem_edge(
275                &rel.rel_type,
276                &entity.entity_type,
277                target_type.as_deref(),
278                schema,
279                &target_ref,
280            ) {
281                CrossMemRelCheck::Ok => {}
282                CrossMemRelCheck::EdgeNotDeclared => {
283                    findings.push(IntegrityFinding::conformance(
284                        &entity.id,
285                        &EngineError::CrossMemEdgeNotDeclared {
286                            source_schema: format!("{src_name}@{src_version}"),
287                            target_schema: target_ref.as_display(),
288                            rel_type: rel.rel_type.clone(),
289                            from_id: entity.id.to_string(),
290                            to_id: rel.target.to_string(),
291                        },
292                    ));
293                }
294                CrossMemRelCheck::Invalid(v) => {
295                    findings.push(IntegrityFinding::conformance(
296                        &entity.id,
297                        &EngineError::Validation(v),
298                    ));
299                }
300            }
301        } else {
302            match validate_rel_type(&rel.rel_type, schema) {
303                // Open-mode schemas admit unknown names at write time
304                // (warning, not refusal) — so they lint clean too.
305                Ok(RelationshipCheck::Ok) | Ok(RelationshipCheck::OpenWarning(_)) => {}
306                Err(v) => {
307                    findings.push(IntegrityFinding::conformance(
308                        &entity.id,
309                        &EngineError::Validation(v),
310                    ));
311                    continue;
312                }
313            }
314            if let Err(v) = validate_rel_shape(
315                &rel.rel_type,
316                &entity.entity_type,
317                target_type.as_deref(),
318                schema,
319            ) {
320                findings.push(IntegrityFinding::conformance(
321                    &entity.id,
322                    &EngineError::Validation(v),
323                ));
324            }
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::entity::{EntityId, MetadataValue, Relationship};
333
334    const TYPE_TAIL: &str = r#"sections:
335  - key: body
336    heading: Body
337    required: true
338    search_weight: 10.0
339    catch_all: false
340    write_rules: []
341  - key: notes
342    heading: Notes
343    required: false
344    search_weight: 1.0
345    catch_all: true
346    write_rules: []
347metadata_fields:
348  - key: status
349    description: Lifecycle state
350    field_type: string
351    enum_values:
352      - open
353      - closed
354title_weight: 100.0
355text_fields:
356  - body
357hierarchy_relationship: _default
358no_self_loop_relationships: []
359updatable_fields:
360  - title
361  - body
362  - notes
363  - status
364health_required_fields:
365  - body
366staleness_threshold_days: 90
367write_rules: []
368"#;
369
370    const PLAIN_TYPE_TAIL: &str = r#"sections:
371  - key: body
372    heading: Body
373    required: false
374    search_weight: 10.0
375    catch_all: true
376    write_rules: []
377metadata_fields: []
378title_weight: 100.0
379text_fields:
380  - body
381hierarchy_relationship: _default
382no_self_loop_relationships: []
383updatable_fields:
384  - title
385  - body
386health_required_fields: []
387staleness_threshold_days: 90
388write_rules: []
389"#;
390
391    /// `lint-src@0.1.0`: strict vocabulary with shape-pinned
392    /// `IMPLEMENTS: doc → doc`, a cross-mem declaration to the
393    /// `other` domain (`ADDRESSES: doc → requirement`), and a `doc`
394    /// type carrying a required `body` section and a required enum
395    /// `status` field with no default.
396    fn lint_schema() -> Arc<Schema> {
397        let manifest = r#"name: lint-src
398version: 0.1.0
399description: linter test schema
400when_to_use: tests
401types:
402  - doc
403  - req
404relationships:
405  mode: strict
406  definitions:
407    - name: IMPLEMENTS
408      description: shape-pinned
409      default_weight: 1.0
410      source_types: [doc]
411      target_types: [doc]
412    - name: _default
413      description: fallback
414      default_weight: 1.0
415cross_mem_relationships:
416  - to_schema: other
417    definitions:
418      - name: ADDRESSES
419        description: outbound
420        default_weight: 1.0
421        source_types: [doc]
422        target_types: [requirement]
423community:
424  resolution: 1.0
425  seed: 42
426"#;
427        Arc::new(
428            memstead_schema::load_schema_from_memory(
429                manifest,
430                &[
431                    (
432                        "doc".to_string(),
433                        format!("name: doc\ndescription: t\nwhen_to_use: tests\n{TYPE_TAIL}"),
434                    ),
435                    (
436                        "req".to_string(),
437                        format!("name: req\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
438                    ),
439                ],
440            )
441            .expect("lint schema loads"),
442        )
443    }
444
445    /// `other@1.0.0`: the cross-mem target domain, declaring a
446    /// `requirement` and a `task` type.
447    fn other_schema() -> Arc<Schema> {
448        let manifest = r#"name: other
449version: 1.0.0
450description: target schema
451when_to_use: tests
452types:
453  - requirement
454  - task
455relationships:
456  mode: strict
457  definitions:
458    - name: _default
459      description: fallback
460      default_weight: 1.0
461community:
462  resolution: 1.0
463  seed: 42
464"#;
465        Arc::new(
466            memstead_schema::load_schema_from_memory(
467                manifest,
468                &[
469                    (
470                        "requirement".to_string(),
471                        format!(
472                            "name: requirement\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"
473                        ),
474                    ),
475                    (
476                        "task".to_string(),
477                        format!("name: task\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
478                    ),
479                ],
480            )
481            .expect("other schema loads"),
482        )
483    }
484
485    fn entity(mem: &str, slug: &str, entity_type: &str) -> Entity {
486        Entity {
487            id: EntityId::new(mem, slug),
488            title: slug.to_string(),
489            entity_type: entity_type.to_string(),
490            mem: mem.to_string(),
491            file_path: format!("{slug}.md"),
492            metadata: IndexMap::new(),
493            sections: IndexMap::new(),
494            relationships: Vec::new(),
495            content_hash: "h".to_string(),
496            stub: false,
497            stub_kind: None,
498            heading_spans: Default::default(),
499            raw_section_headings: Vec::new(),
500        }
501    }
502
503    fn conformant_entity(mem: &str, slug: &str) -> Entity {
504        let mut e = entity(mem, slug, "doc");
505        e.sections.insert("body".to_string(), "content".to_string());
506        e.metadata.insert(
507            "status".to_string(),
508            MetadataValue::String("open".to_string()),
509        );
510        e
511    }
512
513    fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
514        entries
515            .iter()
516            .map(|(v, s)| (v.to_string(), s.clone()))
517            .collect()
518    }
519
520    fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
521        findings.iter().map(|f| f.code.as_str()).collect()
522    }
523
524    #[test]
525    fn clean_mem_produces_no_findings() {
526        let schema = lint_schema();
527        let mut store = Store::new();
528        let a = conformant_entity("lv", "alpha");
529        let mut b = conformant_entity("lv", "beta");
530        b.relationships
531            .push(Relationship::new("IMPLEMENTS", a.id.clone()));
532        store.upsert(a.id.clone(), a);
533        store.upsert(b.id.clone(), b);
534        let schemas = schemas_for(&[("lv", schema.clone())]);
535        let findings = conformance_findings(&store, "lv", &schema, &schemas);
536        assert!(findings.is_empty(), "got: {:?}", codes(&findings));
537    }
538
539    #[test]
540    fn missing_required_section_and_field_carry_write_time_codes() {
541        let schema = lint_schema();
542        let mut store = Store::new();
543        // No body section, no status field — both required.
544        let e = entity("lv", "broken", "doc");
545        let id = e.id.to_string();
546        store.upsert(e.id.clone(), e);
547        let schemas = schemas_for(&[("lv", schema.clone())]);
548        let findings = conformance_findings(&store, "lv", &schema, &schemas);
549        let cs = codes(&findings);
550        assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
551        assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
552        for f in &findings {
553            assert_eq!(f.id, id);
554            assert_eq!(f.axis, IntegrityAxis::Conformance);
555        }
556        // Detail mirrors the write-time recovery payload.
557        let section_finding = findings
558            .iter()
559            .find(|f| f.code == "MISSING_REQUIRED_SECTION")
560            .unwrap();
561        assert_eq!(
562            section_finding.detail["sections"][0]["key"].as_str(),
563            Some("body")
564        );
565        let field_finding = findings
566            .iter()
567            .find(|f| f.code == "REQUIRED_FIELD_UNSET")
568            .unwrap();
569        assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
570    }
571
572    #[test]
573    fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
574        let schema = lint_schema();
575        let mut store = Store::new();
576        let mut e = conformant_entity("lv", "drifted");
577        e.metadata.insert(
578            "status".to_string(),
579            MetadataValue::String("banana".to_string()),
580        );
581        e.metadata
582            .insert("wat".to_string(), MetadataValue::String("x".to_string()));
583        e.sections.insert("bogus".to_string(), "text".to_string());
584        store.upsert(e.id.clone(), e);
585        let schemas = schemas_for(&[("lv", schema.clone())]);
586        let findings = conformance_findings(&store, "lv", &schema, &schemas);
587        let cs = codes(&findings);
588        assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
589        assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
590        assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
591        let enum_finding = findings
592            .iter()
593            .find(|f| f.code == "INVALID_ENUM_VALUE")
594            .unwrap();
595        assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
596        assert_eq!(
597            enum_finding.detail["allowed"]
598                .as_array()
599                .unwrap()
600                .iter()
601                .map(|v| v.as_str().unwrap())
602                .collect::<Vec<_>>(),
603            vec!["open", "closed"]
604        );
605    }
606
607    #[test]
608    fn unknown_type_short_circuits_with_unknown_entity_type() {
609        let schema = lint_schema();
610        let mut store = Store::new();
611        let e = entity("lv", "mystery", "ghost");
612        store.upsert(e.id.clone(), e);
613        let schemas = schemas_for(&[("lv", schema.clone())]);
614        let findings = conformance_findings(&store, "lv", &schema, &schemas);
615        assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
616        assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
617    }
618
619    #[test]
620    fn invalid_rel_type_and_shape_surface() {
621        let schema = lint_schema();
622        let mut store = Store::new();
623        let mut req_target = conformant_entity("lv", "target");
624        req_target.entity_type = "req".to_string();
625        // `req` has no required section/field constraints (plain type).
626        req_target.metadata.clear();
627        req_target.sections.clear();
628        let mut e = conformant_entity("lv", "edges");
629        e.relationships
630            .push(Relationship::new("UNDECLARED", req_target.id.clone()));
631        // IMPLEMENTS pins doc → doc; the target is a `req`.
632        e.relationships
633            .push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
634        store.upsert(req_target.id.clone(), req_target);
635        store.upsert(e.id.clone(), e);
636        let schemas = schemas_for(&[("lv", schema.clone())]);
637        let findings = conformance_findings(&store, "lv", &schema, &schemas);
638        let cs = codes(&findings);
639        assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
640        assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
641    }
642
643    #[test]
644    fn cross_mem_edges_lint_like_the_write_path() {
645        let schema = lint_schema();
646        let other = other_schema();
647        let mut store = Store::new();
648        let mut requirement = entity("tv", "goal", "requirement");
649        requirement
650            .sections
651            .insert("body".to_string(), "x".to_string());
652        let mut task = entity("tv", "chore", "task");
653        task.sections.insert("body".to_string(), "x".to_string());
654
655        let mut e = conformant_entity("lv", "linker");
656        // Declared domain + matching target type → clean.
657        e.relationships
658            .push(Relationship::new("ADDRESSES", requirement.id.clone()));
659        // Declared domain, target type drifted off `target_types` →
660        // the write-time shape code resurfaces at lint time.
661        e.relationships
662            .push(Relationship::new("ADDRESSES", task.id.clone()));
663        // Rel-type absent from the cross-mem entry entirely.
664        e.relationships
665            .push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
666        store.upsert(requirement.id.clone(), requirement);
667        store.upsert(task.id.clone(), task);
668        store.upsert(e.id.clone(), e);
669        let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
670        let findings = conformance_findings(&store, "lv", &schema, &schemas);
671        let cs = codes(&findings);
672        assert_eq!(
673            cs,
674            vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
675            "declared+conformant edge must stay silent; got: {cs:?}"
676        );
677    }
678
679    #[test]
680    fn stub_entities_are_skipped() {
681        let schema = lint_schema();
682        let mut store = Store::new();
683        let mut stub = entity("lv", "ghost-stub", "");
684        stub.stub = true;
685        store.upsert(stub.id.clone(), stub);
686        let schemas = schemas_for(&[("lv", schema.clone())]);
687        let findings = conformance_findings(&store, "lv", &schema, &schemas);
688        assert!(findings.is_empty());
689    }
690
691    #[test]
692    fn other_mems_are_out_of_scope() {
693        let schema = lint_schema();
694        let mut store = Store::new();
695        let e = entity("elsewhere", "broken", "doc");
696        store.upsert(e.id.clone(), e);
697        let schemas = schemas_for(&[("lv", schema.clone())]);
698        let findings = conformance_findings(&store, "lv", &schema, &schemas);
699        assert!(findings.is_empty());
700    }
701
702    #[test]
703    fn findings_are_deterministic_and_id_ordered() {
704        let schema = lint_schema();
705        let mut store = Store::new();
706        // Insert in non-lexical order; several findings per entity.
707        for slug in ["zeta", "alpha", "mid"] {
708            let e = entity("lv", slug, "doc");
709            store.upsert(e.id.clone(), e);
710        }
711        let schemas = schemas_for(&[("lv", schema.clone())]);
712        let first = conformance_findings(&store, "lv", &schema, &schemas);
713        let second = conformance_findings(&store, "lv", &schema, &schemas);
714        let a = serde_json::to_string(&first).unwrap();
715        let b = serde_json::to_string(&second).unwrap();
716        assert_eq!(a, b, "two runs must be byte-identical");
717        let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
718        let mut sorted = ids.clone();
719        sorted.sort();
720        assert_eq!(ids, sorted, "findings must be in lexical id order");
721    }
722
723    #[test]
724    fn lint_against_target_schema_differs_from_pin() {
725        // The caller picks the effective schema: the same entity lints
726        // clean against the `other` schema's `task` type but fails
727        // against `lint-src` (which has no `task` type) — the
728        // `target_schema` selector semantics.
729        let pin = lint_schema();
730        let target = other_schema();
731        let mut store = Store::new();
732        let mut e = entity("lv", "shifting", "task");
733        e.sections.insert("body".to_string(), "x".to_string());
734        store.upsert(e.id.clone(), e);
735        let schemas = schemas_for(&[("lv", pin.clone())]);
736        let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
737        assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
738        let against_target = conformance_findings(&store, "lv", &target, &schemas);
739        assert!(
740            against_target.is_empty(),
741            "got: {:?}",
742            codes(&against_target)
743        );
744    }
745}