Skip to main content

majordomus_cli/metadata/
mod.rs

1//! Metadata: the kind schema (`kinds.yaml`) and one JSON Schema per kind, both read at run
2//! time from the tool distribution's share directory and, when a repository adds its own,
3//! from `.ai/repo/knowledge/`; and the YAML helpers shared by every reader. The contract
4//! is documented in `docs/CAPABILITIES.md` and in `share/kinds.yaml` itself.
5
6pub mod frontmatter;
7pub mod yaml;
8
9use std::collections::BTreeMap;
10
11use serde::Deserialize;
12use serde_json::Value;
13
14use crate::error::{Error, Result};
15
16/// The one schema version this executable reads.
17pub const KINDS_SCHEMA: &str = "majordomus-kinds/v1";
18
19/// The file, under the manifest's `knowledge` section, in which a repository adds kinds.
20pub const REPO_KINDS_FILE: &str = "kinds.yaml";
21
22/// The directory, under the manifest's `knowledge` section, in which a repository adds
23/// schemas.
24pub const REPO_SCHEMAS_DIR: &str = "schemas";
25
26/// How a file of a kind is read.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Format {
30    /// Markdown, with or without a YAML front matter block.
31    Markdown,
32    /// A YAML document of the layer's subset.
33    Yaml,
34    /// Read as UTF-8 text with no metadata: source files, test cases.
35    Text,
36}
37
38/// Whether a Markdown kind must, may, or does not carry front matter.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
40#[serde(rename_all = "lowercase")]
41pub enum FrontMatterRule {
42    /// A file without front matter is refused, or read as the fallback kind.
43    Required,
44    /// Front matter is read when present.
45    Optional,
46    #[default]
47    /// Front matter is not looked for.
48    None,
49}
50
51/// A field whose value must be one of the listed ones: an integer (`version: 1`) or a
52/// string (`schema: context/v1`).
53#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct SchemaVersion {
56    /// The field that carries the version.
57    pub field: String,
58    /// The values accepted, integers or strings.
59    pub supported: Vec<serde_json::Value>,
60}
61
62/// One entry of `kinds.yaml`.
63#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct KindSpec {
66    /// How the file is read.
67    pub format: Format,
68    #[serde(default)]
69    /// For markdown, whether front matter must, may, or must not be present.
70    pub front_matter: FrontMatterRule,
71    /// For a markdown kind with optional or required front matter: the kind a file that
72    /// carries none is read as instead (the rules tree's own README is a document).
73    #[serde(default)]
74    pub without_front_matter: Option<String>,
75    /// The document schema the file must satisfy, by identity (`majordomus.rule/v1`,
76    /// which is `schemas/majordomus/rule/rule.v1.*`); absent means no contract.
77    #[serde(default)]
78    pub schema: Option<String>,
79    #[serde(default)]
80    /// The metadata fields joined with `@` that make the identity; empty means the path.
81    pub identity: Vec<String>,
82    #[serde(default)]
83    /// The field holding the title, or `@heading` for the first level-one heading.
84    pub title: Option<String>,
85    #[serde(default)]
86    /// The field holding the one-line description.
87    pub description: Option<String>,
88    #[serde(default)]
89    /// A field that must carry one of the supported values.
90    pub schema_version: Option<SchemaVersion>,
91    /// For a yaml kind: the file's list that holds the objects, one object per item
92    /// (`claims` for `docs/CLAIMS.yaml`). Identity, title and description are then read
93    /// from each item; `schema_version` still applies to the file.
94    #[serde(default)]
95    pub members: Option<String>,
96}
97
98#[derive(Debug, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct KindsFile {
101    schema: String,
102    /// Markdown kinds a file may declare through its front matter `kind:` whatever class
103    /// discovered it: a context document inside the rules tree is a context document.
104    #[serde(default)]
105    declared: Vec<String>,
106    kinds: BTreeMap<String, KindSpec>,
107}
108
109/// The kind schema with every JSON Schema it names compiled.
110#[derive(Debug)]
111pub struct KindSchema {
112    kinds: BTreeMap<String, KindSpec>,
113    declared: Vec<String>,
114    schemas: SchemaSet,
115    sources: Vec<String>,
116}
117
118/// One kinds document and where it came from.
119struct Source {
120    path: String,
121    text: String,
122}
123
124/// Every `.proto` document schema under a schema root, as the JSON Schema each derives.
125/// The proto is the definition; this is the same contract in the language the validator
126/// speaks.
127fn derived_schemas(dir: &std::path::Path, source: &str) -> Result<Vec<(String, Value)>> {
128    crate::proto::read_dir(dir, source)?
129        .into_values()
130        .map(|file| {
131            let schema = crate::proto::project::to_json_schema(&file)?;
132            Ok((file.schema_id, schema))
133        })
134        .collect()
135}
136
137fn rel_or_abs(path: &std::path::Path, root: &std::path::Path) -> String {
138    crate::generate::relative_to(path, root)
139}
140
141/// One JSON Schema, compiled, with where it came from.
142pub struct Schema {
143    /// The schema identity, `<vendor>.<name>/v<n>`, which its path derives.
144    pub name: String,
145    /// Directory the file was read from, repository-relative when inside the repository.
146    pub source: String,
147    /// The schema as parsed: the whole document for a Markdown kind, the metadata itself
148    /// for a YAML one.
149    pub json: Value,
150    /// The whole document.
151    validator: jsonschema::Validator,
152    /// The front matter alone. A Markdown kind's schema describes `{header, body}`, and
153    /// what a caller has in hand at validation time is the front matter, so the header
154    /// half is compiled separately rather than every caller wrapping its value in an
155    /// object. For a YAML kind the two are the same validator's work and this is `None`.
156    header: Option<jsonschema::Validator>,
157}
158
159impl std::fmt::Debug for Schema {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_struct("Schema")
162            .field("name", &self.name)
163            .field("source", &self.source)
164            .finish()
165    }
166}
167
168/// One failed constraint of a validation.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Violation {
171    /// Dotted key path of the offending value; empty for the document itself.
172    pub path: String,
173    /// What the validator said.
174    pub message: String,
175    /// Set when the violation is a key the schema does not allow.
176    pub unknown_keys: Vec<String>,
177}
178
179impl Schema {
180    fn compile(name: &str, source: &str, json: Value) -> Result<Self> {
181        let compile = |value: &Value, what: &str| {
182            jsonschema::validator_for(value).map_err(|e| Error::KindSchema {
183                reason: format!("{source}: {name}: {what} is not a valid JSON Schema: {e}"),
184            })
185        };
186        let validator = compile(&json, "the document schema")?;
187        // A whole-document schema keeps the front-matter contract under `$defs/Header`;
188        // compiling a document that refers to it gives a validator for the header alone
189        // without a second copy of the contract.
190        let header = match (
191            json.get("properties").and_then(|p| p.get("header")),
192            json.get("$defs"),
193        ) {
194            (Some(_), Some(defs)) => Some(compile(
195                &serde_json::json!({
196                    "$schema": "https://json-schema.org/draft/2020-12/schema",
197                    "$ref": "#/$defs/Header",
198                    "$defs": defs,
199                }),
200                "its header",
201            )?),
202            _ => None,
203        };
204        Ok(Schema {
205            name: name.to_string(),
206            source: source.to_string(),
207            json,
208            validator,
209            header,
210        })
211    }
212
213    /// Every violation of the front matter `instance`, in document order; empty when it
214    /// conforms. For a Markdown kind this is the `Header` half of the contract; for a YAML
215    /// kind the metadata is the whole document and this is all of it.
216    pub fn validate(&self, instance: &Value) -> Vec<Violation> {
217        self.violations(self.header.as_ref().unwrap_or(&self.validator), instance)
218    }
219
220    /// Every violation of a whole document — `{"header": ..., "body": ...}` — in document
221    /// order. For a kind whose schema describes no body this is [`Self::validate`].
222    pub fn validate_document(&self, instance: &Value) -> Vec<Violation> {
223        self.violations(&self.validator, instance)
224    }
225
226    fn violations(&self, validator: &jsonschema::Validator, instance: &Value) -> Vec<Violation> {
227        validator
228            .iter_errors(instance)
229            .map(|e| {
230                let path = e
231                    .instance_path()
232                    .as_str()
233                    .trim_start_matches('/')
234                    .replace('/', ".");
235                let unknown_keys = match e.kind() {
236                    jsonschema::error::ValidationErrorKind::AdditionalProperties { unexpected } => {
237                        unexpected
238                            .iter()
239                            .map(|k| {
240                                if path.is_empty() {
241                                    k.clone()
242                                } else {
243                                    format!("{path}.{k}")
244                                }
245                            })
246                            .collect()
247                    }
248                    _ => Vec::new(),
249                };
250                Violation {
251                    path,
252                    message: e.to_string(),
253                    unknown_keys,
254                }
255            })
256            .collect()
257    }
258}
259
260/// Schemas gathered from the distribution and the repository, each name once.
261#[derive(Debug, Default)]
262pub struct SchemaSet {
263    schemas: BTreeMap<String, Schema>,
264}
265
266impl SchemaSet {
267    /// Add every `(name, json)` read from one directory; a name already present is an
268    /// error naming both directories.
269    pub fn extend(&mut self, schemas: Vec<(String, Value)>, from: &str) -> Result<()> {
270        for (name, json) in schemas {
271            // The committed projection of a `.proto` already loaded from its source: the
272            // source wins, and the cache is compared by `generate --check`, not here.
273            if json.get(crate::proto::project::DERIVED_EXTENSION).is_some()
274                && self.schemas.contains_key(&name)
275            {
276                continue;
277            }
278            if let Some(first) = self.schemas.get(&name) {
279                return Err(Error::KindSchema {
280                    reason: format!("schema '{name}' exists in both {} and {from}; a repository adds schemas, it does not redefine them", first.source),
281                });
282            }
283            self.schemas
284                .insert(name.clone(), Schema::compile(&name, from, json)?);
285        }
286        Ok(())
287    }
288
289    /// A schema by name.
290    pub fn get(&self, name: &str) -> Option<&Schema> {
291        self.schemas.get(name)
292    }
293
294    /// Holds no schema?
295    pub fn is_empty(&self) -> bool {
296        self.schemas.is_empty()
297    }
298}
299
300impl KindSchema {
301    /// Load the distribution's kinds and schemas, then the repository's additions. A kind
302    /// or a schema declared by both is an error naming both files; nothing overrides.
303    pub fn load(share: &crate::share::Share, repo: &crate::repository::Repository) -> Result<Self> {
304        let dist_path = share.kinds_path();
305        let dist_text =
306            std::fs::read_to_string(&dist_path).map_err(|e| Error::io(&dist_path, e))?;
307        let mut schemas = SchemaSet::default();
308        let dist_schemas = share.schemas_dir();
309        let dist_source = rel_or_abs(&dist_schemas, repo.root());
310        // The `.proto` is canonical for a Markdown kind, so it is read first and its JSON
311        // Schema derived here. The copy committed beside it is a cache for the readers that
312        // cannot parse proto — the shell tool above all — and `generate --check` is what
313        // keeps it current; loading the cache instead of the source would let a stale file
314        // decide what validates.
315        schemas.extend(derived_schemas(&dist_schemas, &dist_source)?, &dist_source)?;
316        schemas.extend(crate::share::read_schema_dir(&dist_schemas)?, &dist_source)?;
317        let mut sources = vec![Source {
318            path: rel_or_abs(&dist_path, repo.root()),
319            text: dist_text,
320        }];
321        if let Some(knowledge) = repo.section_path("knowledge") {
322            let dir = repo.root().join(&knowledge);
323            let repo_kinds = dir.join(REPO_KINDS_FILE);
324            if repo_kinds.is_file() {
325                let text =
326                    std::fs::read_to_string(&repo_kinds).map_err(|e| Error::io(&repo_kinds, e))?;
327                sources.push(Source {
328                    path: format!("{knowledge}/{REPO_KINDS_FILE}"),
329                    text,
330                });
331            }
332            let repo_schemas = dir.join(REPO_SCHEMAS_DIR);
333            let repo_source = format!("{knowledge}/{REPO_SCHEMAS_DIR}");
334            schemas.extend(derived_schemas(&repo_schemas, &repo_source)?, &repo_source)?;
335            schemas.extend(crate::share::read_schema_dir(&repo_schemas)?, &repo_source)?;
336        }
337        Self::parse_all(&sources, schemas)
338    }
339
340    /// Parse one kinds document with the given schemas (tests, and the distribution alone).
341    pub fn parse(text: &str, schemas: SchemaSet) -> Result<Self> {
342        Self::parse_all(
343            &[Source {
344                path: "kinds.yaml".into(),
345                text: text.to_string(),
346            }],
347            schemas,
348        )
349    }
350
351    fn parse_all(sources: &[Source], schemas: SchemaSet) -> Result<Self> {
352        let mut kinds: BTreeMap<String, KindSpec> = BTreeMap::new();
353        let mut owner: BTreeMap<String, String> = BTreeMap::new();
354        let mut declared: Vec<String> = Vec::new();
355        let mut loaded = Vec::new();
356        for source in sources {
357            let file: KindsFile =
358                yaml::parse_into(&source.text).map_err(|reason| Error::KindSchema {
359                    reason: format!("{}: {reason}", source.path),
360                })?;
361            if file.schema != KINDS_SCHEMA {
362                return Err(Error::KindSchema {
363                    reason: format!(
364                        "{}: schema '{}' is not {KINDS_SCHEMA}",
365                        source.path, file.schema
366                    ),
367                });
368            }
369            for (name, spec) in file.kinds {
370                if let Some(first) = owner.get(&name) {
371                    return Err(Error::KindSchema {
372                        reason: format!("kind '{name}' is declared by both {first} and {}; a repository adds kinds, it does not redefine them", source.path),
373                    });
374                }
375                owner.insert(name.clone(), source.path.clone());
376                kinds.insert(name, spec);
377            }
378            for d in file.declared {
379                if !declared.contains(&d) {
380                    declared.push(d);
381                }
382            }
383            loaded.push(source.path.clone());
384        }
385        for d in &declared {
386            match kinds.get(d) {
387                Some(k)
388                    if k.format == Format::Markdown
389                        && k.front_matter == FrontMatterRule::Required => {}
390                _ => {
391                    return Err(Error::KindSchema {
392                        reason: format!(
393                            "declared kind '{d}' is not a markdown kind that requires front matter"
394                        ),
395                    })
396                }
397            }
398        }
399        for (name, spec) in &kinds {
400            if spec.format != Format::Markdown && spec.front_matter != FrontMatterRule::None {
401                return Err(Error::KindSchema {
402                    reason: format!("kind '{name}': only a markdown kind has a front matter rule"),
403                });
404            }
405            if spec.members.is_some() && spec.format != Format::Yaml {
406                return Err(Error::KindSchema {
407                    reason: format!("kind '{name}': only a yaml kind has members"),
408                });
409            }
410            if spec.format == Format::Text
411                && (spec.schema.is_some() || !spec.identity.is_empty() || spec.members.is_some())
412            {
413                return Err(Error::KindSchema {
414                    reason: format!("kind '{name}': a text kind has no metadata, so no allow-list, identity fields or members"),
415                });
416            }
417            if let Some(fallback) = &spec.without_front_matter {
418                match kinds.get(fallback) {
419                    Some(f) if f.format == Format::Markdown && f.front_matter != FrontMatterRule::Required => {}
420                    _ => {
421                        return Err(Error::KindSchema {
422                            reason: format!("kind '{name}': without_front_matter names '{fallback}', which is not a markdown kind that accepts a file without front matter"),
423                        })
424                    }
425                }
426            }
427            if let Some(schema) = &spec.schema {
428                if !schemas.schemas.contains_key(schema) {
429                    return Err(Error::KindSchema {
430                        reason: format!("kind '{name}' names schema '{schema}', and neither the distribution nor the repository holds it; a schema's identity fixes its path, so this is schemas/{} or the .proto beside it", crate::proto::project::schema_path(schema).unwrap_or_else(|_| format!("<{schema} is not <vendor>.<name>/v<n>>"))),
431                    });
432                }
433            }
434        }
435        Ok(KindSchema {
436            kinds,
437            declared,
438            schemas,
439            sources: loaded,
440        })
441    }
442
443    /// Every schema, by name, distribution first then repository, each name once.
444    pub fn schemas(&self) -> impl Iterator<Item = (&String, &Schema)> {
445        self.schemas.schemas.iter()
446    }
447
448    /// The kinds files this schema was read from, in order, repository-relative when inside
449    /// the repository.
450    pub fn sources(&self) -> &[String] {
451        &self.sources
452    }
453
454    /// A kind by name.
455    pub fn kind(&self, name: &str) -> Option<&KindSpec> {
456        self.kinds.get(name)
457    }
458
459    /// Is `name` a kind a markdown file may declare for itself?
460    pub fn is_declared_kind(&self, name: &str) -> bool {
461        self.declared.iter().any(|d| d == name)
462    }
463
464    /// Every kind with its spec, by name.
465    pub fn kinds(&self) -> impl Iterator<Item = (&String, &KindSpec)> {
466        self.kinds.iter()
467    }
468
469    /// The schema a kind names, if it names one.
470    pub fn schema_for(&self, spec: &KindSpec) -> Option<&Schema> {
471        spec.schema.as_deref().and_then(|n| self.schemas.get(n))
472    }
473
474    /// A schema by name, from either source.
475    pub fn schema(&self, name: &str) -> Option<&Schema> {
476        self.schemas.get(name)
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    /// The distribution beside this crate: `../../share`.
485    fn dist() -> (String, SchemaSet) {
486        let share = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share");
487        let text = std::fs::read_to_string(share.join("kinds.yaml")).unwrap();
488        let mut schemas = SchemaSet::default();
489        schemas
490            .extend(
491                derived_schemas(&share.join("schemas"), "share/schemas").unwrap(),
492                "share/schemas",
493            )
494            .unwrap();
495        schemas
496            .extend(
497                crate::share::read_schema_dir(&share.join("schemas")).unwrap(),
498                "share/schemas",
499            )
500            .unwrap();
501        (text, schemas)
502    }
503
504    #[test]
505    fn the_distribution_schema_parses_and_names_known_schemas() {
506        let (text, schemas) = dist();
507        let schema = KindSchema::parse(&text, schemas).expect("distribution schema");
508        let rule = schema.kind("rule").expect("rule kind");
509        assert_eq!(rule.format, Format::Markdown);
510        assert_eq!(rule.identity, vec!["id", "version"]);
511        assert_eq!(
512            schema.schema_for(rule).map(|s| s.name.as_str()),
513            Some("majordomus.rule/v1")
514        );
515        assert!(schema.kind("document").is_some());
516        assert!(schema.is_declared_kind("context") && !schema.is_declared_kind("rule"));
517        assert_eq!(
518            schema.kind("claim").unwrap().members.as_deref(),
519            Some("claims")
520        );
521        assert_eq!(schema.kind("test").unwrap().format, Format::Text);
522        assert_eq!(
523            schema.kind("rule").unwrap().without_front_matter.as_deref(),
524            Some("document")
525        );
526        assert!(
527            schema.schema("majordomus.manifest/v1").is_some(),
528            "schemas that no kind names are still loaded"
529        );
530    }
531
532    #[test]
533    fn a_schema_validates_and_names_unknown_keys_and_wrong_types() {
534        let (_, schemas) = dist();
535        let rule = schemas.get("majordomus.rule/v1").unwrap();
536        let ok: Value = serde_json::json!({ "id": "p.x", "version": 1, "kind": "rule", "title": "T", "description": "D", "statement": "S", "status": "active", "class": "advisory", "depends_on": [], "tags": ["a"] });
537        assert!(rule.validate(&ok).is_empty());
538        let mut bad = ok.clone();
539        bad["owner"] = serde_json::json!("me");
540        bad["x-majordomus"] = serde_json::json!({ "colour": "red" });
541        let v = rule.validate(&bad);
542        let unknown: Vec<String> = v.iter().flat_map(|v| v.unknown_keys.clone()).collect();
543        assert!(
544            unknown.contains(&"owner".to_string())
545                && unknown.contains(&"x-majordomus.colour".to_string()),
546            "{v:?}"
547        );
548        let mut typed = ok.clone();
549        typed["version"] = serde_json::json!("one");
550        typed["class"] = serde_json::json!("fatal");
551        let v = typed_violations(rule, &typed);
552        assert!(
553            v.iter().any(|x| x.path == "version") && v.iter().any(|x| x.path == "class"),
554            "{v:?}"
555        );
556        let mut redefine = SchemaSet::default();
557        redefine
558            .extend(
559                vec![(
560                    "majordomus.rule/v1".into(),
561                    serde_json::json!({ "type": "object" }),
562                )],
563                "a",
564            )
565            .unwrap();
566        let err = redefine
567            .extend(
568                vec![(
569                    "majordomus.rule/v1".into(),
570                    serde_json::json!({ "type": "object" }),
571                )],
572                "b",
573            )
574            .unwrap_err();
575        assert!(
576            err.to_string()
577                .contains("schema 'majordomus.rule/v1' exists in both a and b"),
578            "{err}"
579        );
580    }
581
582    fn typed_violations(schema: &Schema, v: &Value) -> Vec<Violation> {
583        schema
584            .validate(v)
585            .into_iter()
586            .filter(|x| x.unknown_keys.is_empty())
587            .collect()
588    }
589
590    #[test]
591    fn schema_refusals_name_the_reason() {
592        let (text, _) = dist();
593        let bad = |t: &str| {
594            KindSchema::parse(t, dist().1)
595                .err()
596                .map(|e| e.to_string())
597                .unwrap_or_default()
598        };
599        assert!(
600            bad(&text.replace("majordomus-kinds/v1", "majordomus-kinds/v9"))
601                .contains("is not majordomus-kinds/v1")
602        );
603        assert!(bad(&text.replace(
604            "    identity: [id, version]",
605            "    identity: [id, version]\n    colour: red"
606        ))
607        .contains("colour"));
608        assert!(bad(&text.replace(
609            "without_front_matter: document",
610            "without_front_matter: prompt"
611        ))
612        .contains("without_front_matter"));
613        assert!(
614            bad(&text.replace("declared: [context]", "declared: [profile]"))
615                .contains("declared kind 'profile'")
616        );
617        assert!(bad(&text.replace(
618            "    format: text\n    identity: []\n",
619            "    format: text\n    identity: [id]\n"
620        ))
621        .contains("text kind"));
622        assert!(
623            bad(&text.replace("    schema: majordomus.rule/v1\n", "    schema: nothing\n"))
624                .contains("schema 'nothing'")
625        );
626        let mut invalid = SchemaSet::default();
627        let err = invalid
628            .extend(
629                vec![("x".into(), serde_json::json!({ "type": "nonsense" }))],
630                "d",
631            )
632            .unwrap_err();
633        assert!(err.to_string().contains("not a valid JSON Schema"), "{err}");
634    }
635
636    #[test]
637    fn a_repository_adds_kinds_and_may_not_redefine_one() {
638        let (text, schemas) = dist();
639        let extra = "schema: majordomus-kinds/v1\nkinds:\n  note:\n    format: markdown\n    front_matter: required\n    identity: [id]\n    title: title\n";
640        let sources = [
641            Source {
642                path: "share/kinds.yaml".into(),
643                text: text.clone(),
644            },
645            Source {
646                path: ".ai/repo/knowledge/kinds.yaml".into(),
647                text: extra.into(),
648            },
649        ];
650        let schema = KindSchema::parse_all(&sources, schemas).unwrap();
651        assert!(schema.kind("note").is_some());
652        assert_eq!(
653            schema.sources(),
654            ["share/kinds.yaml", ".ai/repo/knowledge/kinds.yaml"]
655        );
656        let redefine = "schema: majordomus-kinds/v1\nkinds:\n  rule:\n    format: yaml\n";
657        let sources = [
658            Source {
659                path: "share/kinds.yaml".into(),
660                text,
661            },
662            Source {
663                path: ".ai/repo/knowledge/kinds.yaml".into(),
664                text: redefine.into(),
665            },
666        ];
667        let err = KindSchema::parse_all(&sources, dist().1)
668            .unwrap_err()
669            .to_string();
670        assert!(err.contains("kind 'rule' is declared by both share/kinds.yaml and .ai/repo/knowledge/kinds.yaml"), "{err}");
671    }
672}