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                // The linter reports every gate as its own finding
212                // (the RequiredFieldUnset finding below), so a
213                // pre-announcement here would duplicate it.
214                pre_announced_missing_fields: Vec::new(),
215            },
216        ));
217    }
218
219    // Metadata — unknown keys, enum violations, malformed typed values.
220    // Engine-managed keys (`mem`, `id`, `type`) are skipped exactly
221    // as the write path treats them (read-only, never caller-supplied).
222    let mut supplied: IndexMap<String, String> = IndexMap::new();
223    for (key, value) in &entity.metadata {
224        let raw = value.to_frontmatter_string();
225        supplied.insert(key.clone(), raw.clone());
226        if READ_ONLY_METADATA_KEYS.iter().any(|k| k == key) {
227            continue;
228        }
229        if let Err(v) = parse_metadata_value(key, &raw, type_def) {
230            findings.push(IntegrityFinding::conformance(
231                &entity.id,
232                &EngineError::Validation(v),
233            ));
234        }
235    }
236
237    // Required metadata fields the schema does not auto-fill — one
238    // finding per entity mirroring the create path's accumulator.
239    let missing_fields = missing_required_fields(type_def, &supplied);
240    if let Some(first) = missing_fields.first() {
241        findings.push(IntegrityFinding::conformance(
242            &entity.id,
243            &EngineError::RequiredFieldUnset {
244                field: first.key.clone(),
245                entity_type: entity.entity_type.clone(),
246                field_description: Some(first.description.clone()),
247                enum_values: first.enum_values.clone(),
248                type_write_rules: type_def.write_rules.clone(),
249                on_create: true,
250                missing: missing_fields.clone(),
251            },
252        ));
253    }
254
255    // Relationships — routed exactly as the write path routes them:
256    // same schema *name* on both ends (any version pair) consults the
257    // intra-mem vocabulary of the effective schema; a different name
258    // consults its `cross_mem_relationships`. An unmounted target
259    // mem falls back to the intra path, mirroring the relate path.
260    let (src_name, src_version) = schema.id();
261    for rel in &entity.relationships {
262        let target_mem = rel.target.mem();
263        let target_schema = if target_mem == entity.mem {
264            None
265        } else {
266            mem_schemas.get(target_mem)
267        };
268        let cross_mem_different = target_schema.map(|t| t.id().0 != src_name).unwrap_or(false);
269        let target_type = store
270            .get(&rel.target)
271            .map(|e| e.entity_type.clone())
272            .filter(|t| !t.is_empty());
273
274        if cross_mem_different {
275            let target = target_schema.expect("Some when cross_mem_different");
276            let (t_name, t_version) = target.id();
277            let target_ref = SchemaRef::new(t_name, t_version.clone());
278            match validate_cross_mem_edge(
279                &rel.rel_type,
280                &entity.entity_type,
281                target_type.as_deref(),
282                schema,
283                &target_ref,
284            ) {
285                CrossMemRelCheck::Ok => {}
286                CrossMemRelCheck::EdgeNotDeclared => {
287                    findings.push(IntegrityFinding::conformance(
288                        &entity.id,
289                        &EngineError::CrossMemEdgeNotDeclared {
290                            source_schema: format!("{src_name}@{src_version}"),
291                            target_schema: target_ref.as_display(),
292                            rel_type: rel.rel_type.clone(),
293                            from_id: entity.id.to_string(),
294                            to_id: rel.target.to_string(),
295                        },
296                    ));
297                }
298                CrossMemRelCheck::Invalid(v) => {
299                    findings.push(IntegrityFinding::conformance(
300                        &entity.id,
301                        &EngineError::Validation(v),
302                    ));
303                }
304            }
305        } else {
306            match validate_rel_type(&rel.rel_type, schema) {
307                // Open-mode schemas admit unknown names at write time
308                // (warning, not refusal) — so they lint clean too.
309                Ok(RelationshipCheck::Ok) | Ok(RelationshipCheck::OpenWarning(_)) => {}
310                Err(v) => {
311                    findings.push(IntegrityFinding::conformance(
312                        &entity.id,
313                        &EngineError::Validation(v),
314                    ));
315                    continue;
316                }
317            }
318            if let Err(v) = validate_rel_shape(
319                &rel.rel_type,
320                &entity.entity_type,
321                target_type.as_deref(),
322                schema,
323            ) {
324                findings.push(IntegrityFinding::conformance(
325                    &entity.id,
326                    &EngineError::Validation(v),
327                ));
328            }
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::entity::{EntityId, MetadataValue, Relationship};
337
338    const TYPE_TAIL: &str = r#"sections:
339  - key: body
340    heading: Body
341    required: true
342    search_weight: 10.0
343    catch_all: false
344    write_rules: []
345  - key: notes
346    heading: Notes
347    required: false
348    search_weight: 1.0
349    catch_all: true
350    write_rules: []
351metadata_fields:
352  - key: status
353    description: Lifecycle state
354    field_type: string
355    enum_values:
356      - open
357      - closed
358title_weight: 100.0
359text_fields:
360  - body
361hierarchy_relationship: _default
362no_self_loop_relationships: []
363updatable_fields:
364  - title
365  - body
366  - notes
367  - status
368health_required_fields:
369  - body
370staleness_threshold_days: 90
371write_rules: []
372"#;
373
374    const PLAIN_TYPE_TAIL: &str = r#"sections:
375  - key: body
376    heading: Body
377    required: false
378    search_weight: 10.0
379    catch_all: true
380    write_rules: []
381metadata_fields: []
382title_weight: 100.0
383text_fields:
384  - body
385hierarchy_relationship: _default
386no_self_loop_relationships: []
387updatable_fields:
388  - title
389  - body
390health_required_fields: []
391staleness_threshold_days: 90
392write_rules: []
393"#;
394
395    /// `lint-src@0.1.0`: strict vocabulary with shape-pinned
396    /// `IMPLEMENTS: doc → doc`, a cross-mem declaration to the
397    /// `other` domain (`ADDRESSES: doc → requirement`), and a `doc`
398    /// type carrying a required `body` section and a required enum
399    /// `status` field with no default.
400    fn lint_schema() -> Arc<Schema> {
401        let manifest = r#"name: lint-src
402version: 0.1.0
403description: linter test schema
404when_to_use: tests
405types:
406  - doc
407  - req
408relationships:
409  mode: strict
410  definitions:
411    - name: IMPLEMENTS
412      description: shape-pinned
413      default_weight: 1.0
414      source_types: [doc]
415      target_types: [doc]
416    - name: _default
417      description: fallback
418      default_weight: 1.0
419cross_mem_relationships:
420  - to_schema: other
421    definitions:
422      - name: ADDRESSES
423        description: outbound
424        default_weight: 1.0
425        source_types: [doc]
426        target_types: [requirement]
427community:
428  resolution: 1.0
429  seed: 42
430"#;
431        Arc::new(
432            memstead_schema::load_schema_from_memory(
433                manifest,
434                &[
435                    (
436                        "doc".to_string(),
437                        format!("name: doc\ndescription: t\nwhen_to_use: tests\n{TYPE_TAIL}"),
438                    ),
439                    (
440                        "req".to_string(),
441                        format!("name: req\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
442                    ),
443                ],
444            )
445            .expect("lint schema loads"),
446        )
447    }
448
449    /// `other@1.0.0`: the cross-mem target domain, declaring a
450    /// `requirement` and a `task` type.
451    fn other_schema() -> Arc<Schema> {
452        let manifest = r#"name: other
453version: 1.0.0
454description: target schema
455when_to_use: tests
456types:
457  - requirement
458  - task
459relationships:
460  mode: strict
461  definitions:
462    - name: _default
463      description: fallback
464      default_weight: 1.0
465community:
466  resolution: 1.0
467  seed: 42
468"#;
469        Arc::new(
470            memstead_schema::load_schema_from_memory(
471                manifest,
472                &[
473                    (
474                        "requirement".to_string(),
475                        format!(
476                            "name: requirement\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"
477                        ),
478                    ),
479                    (
480                        "task".to_string(),
481                        format!("name: task\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
482                    ),
483                ],
484            )
485            .expect("other schema loads"),
486        )
487    }
488
489    fn entity(mem: &str, slug: &str, entity_type: &str) -> Entity {
490        Entity {
491            id: EntityId::new(mem, slug),
492            title: slug.to_string(),
493            entity_type: entity_type.to_string(),
494            mem: mem.to_string(),
495            file_path: format!("{slug}.md"),
496            metadata: IndexMap::new(),
497            sections: IndexMap::new(),
498            relationships: Vec::new(),
499            content_hash: "h".to_string(),
500            stub: false,
501            stub_kind: None,
502            heading_spans: Default::default(),
503            raw_section_headings: Vec::new(),
504        }
505    }
506
507    fn conformant_entity(mem: &str, slug: &str) -> Entity {
508        let mut e = entity(mem, slug, "doc");
509        e.sections.insert("body".to_string(), "content".to_string());
510        e.metadata.insert(
511            "status".to_string(),
512            MetadataValue::String("open".to_string()),
513        );
514        e
515    }
516
517    fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
518        entries
519            .iter()
520            .map(|(v, s)| (v.to_string(), s.clone()))
521            .collect()
522    }
523
524    fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
525        findings.iter().map(|f| f.code.as_str()).collect()
526    }
527
528    #[test]
529    fn clean_mem_produces_no_findings() {
530        let schema = lint_schema();
531        let mut store = Store::new();
532        let a = conformant_entity("lv", "alpha");
533        let mut b = conformant_entity("lv", "beta");
534        b.relationships
535            .push(Relationship::new("IMPLEMENTS", a.id.clone()));
536        store.upsert(a.id.clone(), a);
537        store.upsert(b.id.clone(), b);
538        let schemas = schemas_for(&[("lv", schema.clone())]);
539        let findings = conformance_findings(&store, "lv", &schema, &schemas);
540        assert!(findings.is_empty(), "got: {:?}", codes(&findings));
541    }
542
543    #[test]
544    fn missing_required_section_and_field_carry_write_time_codes() {
545        let schema = lint_schema();
546        let mut store = Store::new();
547        // No body section, no status field — both required.
548        let e = entity("lv", "broken", "doc");
549        let id = e.id.to_string();
550        store.upsert(e.id.clone(), e);
551        let schemas = schemas_for(&[("lv", schema.clone())]);
552        let findings = conformance_findings(&store, "lv", &schema, &schemas);
553        let cs = codes(&findings);
554        assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
555        assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
556        for f in &findings {
557            assert_eq!(f.id, id);
558            assert_eq!(f.axis, IntegrityAxis::Conformance);
559        }
560        // Detail mirrors the write-time recovery payload.
561        let section_finding = findings
562            .iter()
563            .find(|f| f.code == "MISSING_REQUIRED_SECTION")
564            .unwrap();
565        assert_eq!(
566            section_finding.detail["sections"][0]["key"].as_str(),
567            Some("body")
568        );
569        let field_finding = findings
570            .iter()
571            .find(|f| f.code == "REQUIRED_FIELD_UNSET")
572            .unwrap();
573        assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
574    }
575
576    #[test]
577    fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
578        let schema = lint_schema();
579        let mut store = Store::new();
580        let mut e = conformant_entity("lv", "drifted");
581        e.metadata.insert(
582            "status".to_string(),
583            MetadataValue::String("banana".to_string()),
584        );
585        e.metadata
586            .insert("wat".to_string(), MetadataValue::String("x".to_string()));
587        e.sections.insert("bogus".to_string(), "text".to_string());
588        store.upsert(e.id.clone(), e);
589        let schemas = schemas_for(&[("lv", schema.clone())]);
590        let findings = conformance_findings(&store, "lv", &schema, &schemas);
591        let cs = codes(&findings);
592        assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
593        assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
594        assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
595        let enum_finding = findings
596            .iter()
597            .find(|f| f.code == "INVALID_ENUM_VALUE")
598            .unwrap();
599        assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
600        assert_eq!(
601            enum_finding.detail["allowed"]
602                .as_array()
603                .unwrap()
604                .iter()
605                .map(|v| v.as_str().unwrap())
606                .collect::<Vec<_>>(),
607            vec!["open", "closed"]
608        );
609    }
610
611    #[test]
612    fn unknown_type_short_circuits_with_unknown_entity_type() {
613        let schema = lint_schema();
614        let mut store = Store::new();
615        let e = entity("lv", "mystery", "ghost");
616        store.upsert(e.id.clone(), e);
617        let schemas = schemas_for(&[("lv", schema.clone())]);
618        let findings = conformance_findings(&store, "lv", &schema, &schemas);
619        assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
620        assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
621    }
622
623    #[test]
624    fn invalid_rel_type_and_shape_surface() {
625        let schema = lint_schema();
626        let mut store = Store::new();
627        let mut req_target = conformant_entity("lv", "target");
628        req_target.entity_type = "req".to_string();
629        // `req` has no required section/field constraints (plain type).
630        req_target.metadata.clear();
631        req_target.sections.clear();
632        let mut e = conformant_entity("lv", "edges");
633        e.relationships
634            .push(Relationship::new("UNDECLARED", req_target.id.clone()));
635        // IMPLEMENTS pins doc → doc; the target is a `req`.
636        e.relationships
637            .push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
638        store.upsert(req_target.id.clone(), req_target);
639        store.upsert(e.id.clone(), e);
640        let schemas = schemas_for(&[("lv", schema.clone())]);
641        let findings = conformance_findings(&store, "lv", &schema, &schemas);
642        let cs = codes(&findings);
643        assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
644        assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
645    }
646
647    #[test]
648    fn cross_mem_edges_lint_like_the_write_path() {
649        let schema = lint_schema();
650        let other = other_schema();
651        let mut store = Store::new();
652        let mut requirement = entity("tv", "goal", "requirement");
653        requirement
654            .sections
655            .insert("body".to_string(), "x".to_string());
656        let mut task = entity("tv", "chore", "task");
657        task.sections.insert("body".to_string(), "x".to_string());
658
659        let mut e = conformant_entity("lv", "linker");
660        // Declared domain + matching target type → clean.
661        e.relationships
662            .push(Relationship::new("ADDRESSES", requirement.id.clone()));
663        // Declared domain, target type drifted off `target_types` →
664        // the write-time shape code resurfaces at lint time.
665        e.relationships
666            .push(Relationship::new("ADDRESSES", task.id.clone()));
667        // Rel-type absent from the cross-mem entry entirely.
668        e.relationships
669            .push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
670        store.upsert(requirement.id.clone(), requirement);
671        store.upsert(task.id.clone(), task);
672        store.upsert(e.id.clone(), e);
673        let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
674        let findings = conformance_findings(&store, "lv", &schema, &schemas);
675        let cs = codes(&findings);
676        assert_eq!(
677            cs,
678            vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
679            "declared+conformant edge must stay silent; got: {cs:?}"
680        );
681    }
682
683    #[test]
684    fn stub_entities_are_skipped() {
685        let schema = lint_schema();
686        let mut store = Store::new();
687        let mut stub = entity("lv", "ghost-stub", "");
688        stub.stub = true;
689        store.upsert(stub.id.clone(), stub);
690        let schemas = schemas_for(&[("lv", schema.clone())]);
691        let findings = conformance_findings(&store, "lv", &schema, &schemas);
692        assert!(findings.is_empty());
693    }
694
695    #[test]
696    fn other_mems_are_out_of_scope() {
697        let schema = lint_schema();
698        let mut store = Store::new();
699        let e = entity("elsewhere", "broken", "doc");
700        store.upsert(e.id.clone(), e);
701        let schemas = schemas_for(&[("lv", schema.clone())]);
702        let findings = conformance_findings(&store, "lv", &schema, &schemas);
703        assert!(findings.is_empty());
704    }
705
706    #[test]
707    fn findings_are_deterministic_and_id_ordered() {
708        let schema = lint_schema();
709        let mut store = Store::new();
710        // Insert in non-lexical order; several findings per entity.
711        for slug in ["zeta", "alpha", "mid"] {
712            let e = entity("lv", slug, "doc");
713            store.upsert(e.id.clone(), e);
714        }
715        let schemas = schemas_for(&[("lv", schema.clone())]);
716        let first = conformance_findings(&store, "lv", &schema, &schemas);
717        let second = conformance_findings(&store, "lv", &schema, &schemas);
718        let a = serde_json::to_string(&first).unwrap();
719        let b = serde_json::to_string(&second).unwrap();
720        assert_eq!(a, b, "two runs must be byte-identical");
721        let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
722        let mut sorted = ids.clone();
723        sorted.sort();
724        assert_eq!(ids, sorted, "findings must be in lexical id order");
725    }
726
727    #[test]
728    fn lint_against_target_schema_differs_from_pin() {
729        // The caller picks the effective schema: the same entity lints
730        // clean against the `other` schema's `task` type but fails
731        // against `lint-src` (which has no `task` type) — the
732        // `target_schema` selector semantics.
733        let pin = lint_schema();
734        let target = other_schema();
735        let mut store = Store::new();
736        let mut e = entity("lv", "shifting", "task");
737        e.sections.insert("body".to_string(), "x".to_string());
738        store.upsert(e.id.clone(), e);
739        let schemas = schemas_for(&[("lv", pin.clone())]);
740        let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
741        assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
742        let against_target = conformance_findings(&store, "lv", &target, &schemas);
743        assert!(
744            against_target.is_empty(),
745            "got: {:?}",
746            codes(&against_target)
747        );
748    }
749}