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
358propagating_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
382propagating_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        }
500    }
501
502    fn conformant_entity(mem: &str, slug: &str) -> Entity {
503        let mut e = entity(mem, slug, "doc");
504        e.sections.insert("body".to_string(), "content".to_string());
505        e.metadata.insert(
506            "status".to_string(),
507            MetadataValue::String("open".to_string()),
508        );
509        e
510    }
511
512    fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
513        entries
514            .iter()
515            .map(|(v, s)| (v.to_string(), s.clone()))
516            .collect()
517    }
518
519    fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
520        findings.iter().map(|f| f.code.as_str()).collect()
521    }
522
523    #[test]
524    fn clean_mem_produces_no_findings() {
525        let schema = lint_schema();
526        let mut store = Store::new();
527        let a = conformant_entity("lv", "alpha");
528        let mut b = conformant_entity("lv", "beta");
529        b.relationships
530            .push(Relationship::new("IMPLEMENTS", a.id.clone()));
531        store.upsert(a.id.clone(), a);
532        store.upsert(b.id.clone(), b);
533        let schemas = schemas_for(&[("lv", schema.clone())]);
534        let findings = conformance_findings(&store, "lv", &schema, &schemas);
535        assert!(findings.is_empty(), "got: {:?}", codes(&findings));
536    }
537
538    #[test]
539    fn missing_required_section_and_field_carry_write_time_codes() {
540        let schema = lint_schema();
541        let mut store = Store::new();
542        // No body section, no status field — both required.
543        let e = entity("lv", "broken", "doc");
544        let id = e.id.to_string();
545        store.upsert(e.id.clone(), e);
546        let schemas = schemas_for(&[("lv", schema.clone())]);
547        let findings = conformance_findings(&store, "lv", &schema, &schemas);
548        let cs = codes(&findings);
549        assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
550        assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
551        for f in &findings {
552            assert_eq!(f.id, id);
553            assert_eq!(f.axis, IntegrityAxis::Conformance);
554        }
555        // Detail mirrors the write-time recovery payload.
556        let section_finding = findings
557            .iter()
558            .find(|f| f.code == "MISSING_REQUIRED_SECTION")
559            .unwrap();
560        assert_eq!(
561            section_finding.detail["sections"][0]["key"].as_str(),
562            Some("body")
563        );
564        let field_finding = findings
565            .iter()
566            .find(|f| f.code == "REQUIRED_FIELD_UNSET")
567            .unwrap();
568        assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
569    }
570
571    #[test]
572    fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
573        let schema = lint_schema();
574        let mut store = Store::new();
575        let mut e = conformant_entity("lv", "drifted");
576        e.metadata.insert(
577            "status".to_string(),
578            MetadataValue::String("banana".to_string()),
579        );
580        e.metadata
581            .insert("wat".to_string(), MetadataValue::String("x".to_string()));
582        e.sections.insert("bogus".to_string(), "text".to_string());
583        store.upsert(e.id.clone(), e);
584        let schemas = schemas_for(&[("lv", schema.clone())]);
585        let findings = conformance_findings(&store, "lv", &schema, &schemas);
586        let cs = codes(&findings);
587        assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
588        assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
589        assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
590        let enum_finding = findings
591            .iter()
592            .find(|f| f.code == "INVALID_ENUM_VALUE")
593            .unwrap();
594        assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
595        assert_eq!(
596            enum_finding.detail["allowed"]
597                .as_array()
598                .unwrap()
599                .iter()
600                .map(|v| v.as_str().unwrap())
601                .collect::<Vec<_>>(),
602            vec!["open", "closed"]
603        );
604    }
605
606    #[test]
607    fn unknown_type_short_circuits_with_unknown_entity_type() {
608        let schema = lint_schema();
609        let mut store = Store::new();
610        let e = entity("lv", "mystery", "ghost");
611        store.upsert(e.id.clone(), e);
612        let schemas = schemas_for(&[("lv", schema.clone())]);
613        let findings = conformance_findings(&store, "lv", &schema, &schemas);
614        assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
615        assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
616    }
617
618    #[test]
619    fn invalid_rel_type_and_shape_surface() {
620        let schema = lint_schema();
621        let mut store = Store::new();
622        let mut req_target = conformant_entity("lv", "target");
623        req_target.entity_type = "req".to_string();
624        // `req` has no required section/field constraints (plain type).
625        req_target.metadata.clear();
626        req_target.sections.clear();
627        let mut e = conformant_entity("lv", "edges");
628        e.relationships
629            .push(Relationship::new("UNDECLARED", req_target.id.clone()));
630        // IMPLEMENTS pins doc → doc; the target is a `req`.
631        e.relationships
632            .push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
633        store.upsert(req_target.id.clone(), req_target);
634        store.upsert(e.id.clone(), e);
635        let schemas = schemas_for(&[("lv", schema.clone())]);
636        let findings = conformance_findings(&store, "lv", &schema, &schemas);
637        let cs = codes(&findings);
638        assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
639        assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
640    }
641
642    #[test]
643    fn cross_mem_edges_lint_like_the_write_path() {
644        let schema = lint_schema();
645        let other = other_schema();
646        let mut store = Store::new();
647        let mut requirement = entity("tv", "goal", "requirement");
648        requirement
649            .sections
650            .insert("body".to_string(), "x".to_string());
651        let mut task = entity("tv", "chore", "task");
652        task.sections.insert("body".to_string(), "x".to_string());
653
654        let mut e = conformant_entity("lv", "linker");
655        // Declared domain + matching target type → clean.
656        e.relationships
657            .push(Relationship::new("ADDRESSES", requirement.id.clone()));
658        // Declared domain, target type drifted off `target_types` →
659        // the write-time shape code resurfaces at lint time.
660        e.relationships
661            .push(Relationship::new("ADDRESSES", task.id.clone()));
662        // Rel-type absent from the cross-mem entry entirely.
663        e.relationships
664            .push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
665        store.upsert(requirement.id.clone(), requirement);
666        store.upsert(task.id.clone(), task);
667        store.upsert(e.id.clone(), e);
668        let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
669        let findings = conformance_findings(&store, "lv", &schema, &schemas);
670        let cs = codes(&findings);
671        assert_eq!(
672            cs,
673            vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
674            "declared+conformant edge must stay silent; got: {cs:?}"
675        );
676    }
677
678    #[test]
679    fn stub_entities_are_skipped() {
680        let schema = lint_schema();
681        let mut store = Store::new();
682        let mut stub = entity("lv", "ghost-stub", "");
683        stub.stub = true;
684        store.upsert(stub.id.clone(), stub);
685        let schemas = schemas_for(&[("lv", schema.clone())]);
686        let findings = conformance_findings(&store, "lv", &schema, &schemas);
687        assert!(findings.is_empty());
688    }
689
690    #[test]
691    fn other_mems_are_out_of_scope() {
692        let schema = lint_schema();
693        let mut store = Store::new();
694        let e = entity("elsewhere", "broken", "doc");
695        store.upsert(e.id.clone(), e);
696        let schemas = schemas_for(&[("lv", schema.clone())]);
697        let findings = conformance_findings(&store, "lv", &schema, &schemas);
698        assert!(findings.is_empty());
699    }
700
701    #[test]
702    fn findings_are_deterministic_and_id_ordered() {
703        let schema = lint_schema();
704        let mut store = Store::new();
705        // Insert in non-lexical order; several findings per entity.
706        for slug in ["zeta", "alpha", "mid"] {
707            let e = entity("lv", slug, "doc");
708            store.upsert(e.id.clone(), e);
709        }
710        let schemas = schemas_for(&[("lv", schema.clone())]);
711        let first = conformance_findings(&store, "lv", &schema, &schemas);
712        let second = conformance_findings(&store, "lv", &schema, &schemas);
713        let a = serde_json::to_string(&first).unwrap();
714        let b = serde_json::to_string(&second).unwrap();
715        assert_eq!(a, b, "two runs must be byte-identical");
716        let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
717        let mut sorted = ids.clone();
718        sorted.sort();
719        assert_eq!(ids, sorted, "findings must be in lexical id order");
720    }
721
722    #[test]
723    fn lint_against_target_schema_differs_from_pin() {
724        // The caller picks the effective schema: the same entity lints
725        // clean against the `other` schema's `task` type but fails
726        // against `lint-src` (which has no `task` type) — the
727        // `target_schema` selector semantics.
728        let pin = lint_schema();
729        let target = other_schema();
730        let mut store = Store::new();
731        let mut e = entity("lv", "shifting", "task");
732        e.sections.insert("body".to_string(), "x".to_string());
733        store.upsert(e.id.clone(), e);
734        let schemas = schemas_for(&[("lv", pin.clone())]);
735        let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
736        assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
737        let against_target = conformance_findings(&store, "lv", &target, &schemas);
738        assert!(
739            against_target.is_empty(),
740            "got: {:?}",
741            codes(&against_target)
742        );
743    }
744}