1use std::collections::{BTreeMap, BTreeSet, HashMap};
42use std::path::{Component, Path, PathBuf};
43
44use chrono::{DateTime, FixedOffset, NaiveDateTime};
45use serde_norway::Value;
46
47use crate::parser::{Schema, Shape};
48use crate::store::Store;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Severity {
54 Error,
56 Warning,
58 Info,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Issue {
67 pub severity: Severity,
69 pub code: &'static str,
71 pub file: PathBuf,
73 pub line: Option<u32>,
75 pub key: Option<String>,
77 pub message: String,
79 pub suggestion: Option<String>,
81 pub related: Vec<PathBuf>,
83}
84
85impl Issue {
86 pub fn is_error(&self) -> bool {
89 matches!(self.severity, Severity::Error)
90 }
91}
92
93pub mod codes {
97 pub const NOT_A_STORE: &str = "NOT_A_STORE";
99 pub const NESTED_STORE: &str = "NESTED_STORE";
101 pub const DB_MD_BAD_TYPE: &str = "DB_MD_BAD_TYPE";
103 pub const DB_MD_MISSING_FIELD: &str = "DB_MD_MISSING_FIELD";
105 pub const DB_MD_UNKNOWN_SECTION: &str = "DB_MD_UNKNOWN_SECTION";
107 pub const DB_MD_SCHEMA_FIELD: &str = "DB_MD_SCHEMA_FIELD";
110 pub const FM_MISSING_TYPE: &str = "FM_MISSING_TYPE";
112 pub const FM_MISSING_CREATED: &str = "FM_MISSING_CREATED";
114 pub const FM_MISSING_UPDATED: &str = "FM_MISSING_UPDATED";
116 pub const FM_UNREADABLE: &str = "FM_UNREADABLE";
118 pub const FM_MALFORMED_YAML: &str = "FM_MALFORMED_YAML";
120 pub const FM_BAD_TIMESTAMP: &str = "FM_BAD_TIMESTAMP";
122 pub const FM_BAD_META_TYPE: &str = "FM_BAD_META_TYPE";
124 pub const FM_BAD_ID: &str = "FM_BAD_ID";
128 pub const FM_IN_BODY: &str = "FM_IN_BODY";
133 pub const SUMMARY_MISSING: &str = "SUMMARY_MISSING";
135 pub const SUMMARY_EMPTY: &str = "SUMMARY_EMPTY";
137 pub const SUMMARY_MULTILINE: &str = "SUMMARY_MULTILINE";
139 pub const SUMMARY_TOO_LONG: &str = "SUMMARY_TOO_LONG";
141 pub const WIKI_LINK_SHORT_FORM: &str = "WIKI_LINK_SHORT_FORM";
143 pub const WIKI_LINK_BROKEN: &str = "WIKI_LINK_BROKEN";
145 pub const WIKI_LINK_AMBIGUOUS: &str = "WIKI_LINK_AMBIGUOUS";
147 pub const WIKI_LINK_HAS_EXTENSION: &str = "WIKI_LINK_HAS_EXTENSION";
149 pub const WIKI_LINK_FLOW_FORM_LIST: &str = "WIKI_LINK_FLOW_FORM_LIST";
151 pub const DUP_ID: &str = "DUP_ID";
153 pub const DUP_UNIQUE_KEY: &str = "DUP_UNIQUE_KEY";
155 pub const SCHEMA_MISSING_REQUIRED: &str = "SCHEMA_MISSING_REQUIRED";
157 pub const SCHEMA_SHAPE_MISMATCH: &str = "SCHEMA_SHAPE_MISMATCH";
159 pub const SCHEMA_LINK_PREFIX_MISMATCH: &str = "SCHEMA_LINK_PREFIX_MISMATCH";
161 pub const SCHEMA_ENUM_VIOLATION: &str = "SCHEMA_ENUM_VIOLATION";
163 pub const POLICY_FROZEN_PAGE: &str = "POLICY_FROZEN_PAGE";
165 pub const POLICY_IGNORED_TYPE_PRESENT: &str = "POLICY_IGNORED_TYPE_PRESENT";
167 pub const POLICY_IGNORED_TYPE_DERIVED: &str = "POLICY_IGNORED_TYPE_DERIVED";
169 pub const LOG_BAD_TIMESTAMP: &str = "LOG_BAD_TIMESTAMP";
171 pub const LOG_UNKNOWN_KIND: &str = "LOG_UNKNOWN_KIND";
173 pub const LOG_OUT_OF_ORDER: &str = "LOG_OUT_OF_ORDER";
175 pub const INDEX_MISSING: &str = "INDEX_MISSING";
177 pub const INDEX_STALE_ENTRY: &str = "INDEX_STALE_ENTRY";
179 pub const INDEX_MISSING_ENTRY: &str = "INDEX_MISSING_ENTRY";
181 pub const INDEX_ORPHAN: &str = "INDEX_ORPHAN";
183 pub const INDEX_WRONG_SCOPE: &str = "INDEX_WRONG_SCOPE";
185 pub const INDEX_SUMMARY_MISMATCH: &str = "INDEX_SUMMARY_MISMATCH";
187 pub const INDEX_JSONL_MISSING: &str = "INDEX_JSONL_MISSING";
189 pub const INDEX_JSONL_DESYNC: &str = "INDEX_JSONL_DESYNC";
192 pub const INDEX_JSONL_STALE: &str = "INDEX_JSONL_STALE";
194 pub const TAGS_MALFORMED: &str = "TAGS_MALFORMED";
196 pub const ASSET_MANIFEST_MALFORMED: &str = "ASSET_MANIFEST_MALFORMED";
198 pub const ASSET_UNDECLARED: &str = "ASSET_UNDECLARED";
201 pub const ASSET_WRAPPER_BROKEN: &str = "ASSET_WRAPPER_BROKEN";
203 pub const ASSET_MANIFEST_ORPHAN: &str = "ASSET_MANIFEST_ORPHAN";
205 pub const ASSET_SUPERSESSION_INVALID: &str = "ASSET_SUPERSESSION_INVALID";
208}
209
210const MAX_SUMMARY_LEN: usize = 200;
212
213const RECOGNIZED_LOG_KINDS: &[&str] = &[
216 "ingest",
217 "create",
218 "update",
219 "delete",
220 "rename",
221 "link",
222 "validate",
223 "index-rebuild",
224 "contradiction",
225];
226
227pub fn validate_working_set(
253 store: &Store,
254 since: Option<DateTime<FixedOffset>>,
255) -> crate::Result<Vec<Issue>> {
256 if !store_marker_present(store) {
257 return Ok(vec![not_a_store_issue(store)]);
258 }
259
260 let cutoff = match since {
261 Some(ts) => Some(ts),
262 None => last_validate_at(store),
263 };
264
265 let changed = changed_objects_since(store, cutoff);
267 if changed.is_empty() && since.is_none() {
268 return validate_content_sweep(store);
269 }
270
271 let changed_targets: Vec<PathBuf> = changed.iter().cloned().collect();
282 let mut working: BTreeSet<PathBuf> = changed;
283 for linker in store.find_links_to_any(&changed_targets)? {
284 working.insert(linker);
285 }
286
287 let mut issues = nested_store_issues(store)?;
288 for rel in &working {
289 if !store.regular_file_exists(rel).unwrap_or(false) {
292 continue;
293 }
294 check_content_file(store, rel, None, &mut issues);
299 }
300 issues.sort_by(issue_order);
301 Ok(issues)
302}
303
304fn validate_content_sweep(store: &Store) -> crate::Result<Vec<Issue>> {
305 let mut issues = nested_store_issues(store)?;
306 for rel in store.walk()? {
307 check_content_file(store, &rel, None, &mut issues);
308 }
309 issues.sort_by(issue_order);
310 Ok(issues)
311}
312
313fn nested_store_issues(store: &Store) -> crate::Result<Vec<Issue>> {
317 let mut issues = Vec::new();
318 for nested in store.nested_store_roots()? {
319 let marker = nested.join("DB.md");
320 push(
321 &mut issues,
322 Severity::Error,
323 codes::NESTED_STORE,
324 &marker,
325 None,
326 None,
327 format!(
328 "`{}` is a db.md store nested inside this store",
329 nested.display()
330 ),
331 Some(
332 "move the nested store outside this store, or run dbmd from the nested root"
333 .to_string(),
334 ),
335 vec![],
336 );
337 }
338 Ok(issues)
339}
340
341pub fn validate_all(store: &Store) -> crate::Result<Vec<Issue>> {
346 if !store_marker_present(store) {
347 return Ok(vec![not_a_store_issue(store)]);
348 }
349
350 let mut issues = nested_store_issues(store)?;
351
352 check_db_md(store, &mut issues);
356
357 let files = store.walk()?;
358
359 let basenames = build_basename_index(&files);
364
365 let mut parsed: Vec<(PathBuf, Parsed)> = Vec::new();
367 for rel in &files {
368 if let Some(p) = check_content_file(store, rel, Some(&basenames), &mut issues) {
369 parsed.push((rel.clone(), p));
370 }
371 }
372
373 check_duplicates(store, &parsed, &mut issues);
375
376 check_indexes(store, &files, &mut issues);
378
379 check_log(store, &mut issues);
381
382 check_assets(store, &parsed, &mut issues);
387
388 issues.sort_by(issue_order);
389 Ok(issues)
390}
391
392struct Parsed {
401 fm: Option<BTreeMap<String, Value>>,
404 fm_yaml: String,
407}
408
409fn check_content_file(
414 store: &Store,
415 rel: &Path,
416 basenames: Option<&BasenameIndex>,
417 issues: &mut Vec<Issue>,
418) -> Option<Parsed> {
419 let text = match store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) {
420 Ok(t) => t,
421 Err(e) => {
422 let detail = if e.kind() == std::io::ErrorKind::InvalidData {
430 "file is not valid UTF-8 text".to_string()
431 } else {
432 format!("file could not be read: {e}")
433 };
434 push(
435 issues,
436 Severity::Error,
437 codes::FM_UNREADABLE,
438 rel,
439 None,
440 None,
441 format!("content file is unreadable: {detail}"),
442 Some(
443 "save the file as UTF-8 text, or remove it if it isn't a db.md content file"
444 .into(),
445 ),
446 vec![],
447 );
448 return None;
449 }
450 };
451
452 let is_content = is_content_file(rel);
453
454 let (fm_yaml, body, fm_end_line) = match split_frontmatter(&text) {
455 Some(split) => split,
456 None => {
457 if is_content {
461 push(
462 issues,
463 Severity::Error,
464 codes::FM_MISSING_TYPE,
465 rel,
466 None,
467 Some("type".into()),
468 "content file has no frontmatter `type:`".into(),
469 Some("add a YAML frontmatter block with `type:`".into()),
470 vec![],
471 );
472 push(
473 issues,
474 Severity::Error,
475 codes::SUMMARY_MISSING,
476 rel,
477 None,
478 Some("summary".into()),
479 "content file has no `summary`".into(),
480 Some("run `dbmd fm init`".into()),
481 vec![],
482 );
483 }
484 return None;
485 }
486 };
487
488 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
490 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
491 Ok(Value::Null) => Some(BTreeMap::new()),
493 Ok(_) => {
494 push(
498 issues,
499 Severity::Error,
500 codes::FM_MALFORMED_YAML,
501 rel,
502 Some(1),
503 None,
504 "frontmatter is not a YAML mapping".into(),
505 Some("repair the frontmatter YAML mapping, then rerun `dbmd validate`".into()),
506 vec![],
507 );
508 None
509 }
510 Err(e) => {
511 push(
514 issues,
515 Severity::Error,
516 codes::FM_MALFORMED_YAML,
517 rel,
518 Some(1),
519 None,
520 format!("frontmatter block isn't valid YAML: {e}"),
521 Some("repair the frontmatter YAML block, then rerun `dbmd validate`".into()),
522 vec![],
523 );
524 None
525 }
526 };
527
528 if let Some(map) = &fm {
529 check_frontmatter(store, rel, map, &fm_yaml, basenames, issues, is_content);
531 }
532
533 if !is_root_meta_file(rel) && !is_index_catalog_file(rel) {
555 check_body_wiki_links(store, rel, &body, fm_end_line, basenames, issues);
556 }
557
558 if is_content && body_opens_with_frontmatter(&body) {
565 push(
566 issues,
567 Severity::Warning,
568 codes::FM_IN_BODY,
569 rel,
570 Some(fm_end_line + 1),
571 None,
572 "the body opens with a second `---` frontmatter block; the record's \
573 frontmatter is the block at the top of the file, so this one is body \
574 text (usually an imported file's own frontmatter left in place)"
575 .into(),
576 Some(
577 "delete the leftover `---…---` block from the body, or move its \
578 fields into the record's frontmatter"
579 .into(),
580 ),
581 vec![],
582 );
583 }
584
585 Some(Parsed { fm, fm_yaml })
586}
587
588fn check_frontmatter(
590 store: &Store,
591 rel: &Path,
592 fm: &BTreeMap<String, Value>,
593 fm_yaml: &str,
594 basenames: Option<&BasenameIndex>,
595 issues: &mut Vec<Issue>,
596 is_content: bool,
597) {
598 let type_ = fm.get("type").and_then(scalar_string);
599
600 if is_content && type_.is_none() {
602 push(
603 issues,
604 Severity::Error,
605 codes::FM_MISSING_TYPE,
606 rel,
607 fm_key_line_or_top(fm_yaml, "type"),
608 Some("type".into()),
609 "content file has no `type:`".into(),
610 Some("add a `type:` field (e.g. `type: contact`)".into()),
611 vec![],
612 );
613 }
614
615 if is_content {
620 if let Some(v) = fm.get("meta-type").filter(|v| !v.is_null()) {
629 match scalar_string(v) {
630 Some(mt) if matches!(mt.as_str(), "fact" | "operational" | "conclusion") => {}
631 Some(mt) => push(
632 issues,
633 Severity::Error,
634 codes::FM_BAD_META_TYPE,
635 rel,
636 fm_key_line_or_top(fm_yaml, "meta-type"),
637 Some("meta-type".into()),
638 format!("`meta-type: {mt}` is not one of fact / operational / conclusion"),
639 Some(
640 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
641 .into(),
642 ),
643 vec![],
644 ),
645 None => push(
646 issues,
647 Severity::Error,
648 codes::FM_BAD_META_TYPE,
649 rel,
650 fm_key_line_or_top(fm_yaml, "meta-type"),
651 Some("meta-type".into()),
652 "`meta-type` is not one of fact / operational / conclusion: expected a scalar \
653 string, found a list or mapping"
654 .to_string(),
655 Some(
656 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
657 .into(),
658 ),
659 vec![],
660 ),
661 }
662 }
663 }
664
665 if is_content {
676 if let Some(v) = fm.get("id").filter(|v| !v.is_null()) {
677 let problem = match scalar_string(v) {
678 Some(id) if id.trim().is_empty() => Some("`id` is empty".to_string()),
679 Some(id) if id.chars().any(char::is_whitespace) => {
680 Some(format!("`id` {id:?} contains whitespace"))
681 }
682 Some(_) => None,
683 None => Some(
684 "`id` is not a scalar (found a list or mapping), so duplicate detection \
685 (DUP_ID) cannot see it"
686 .to_string(),
687 ),
688 };
689 if let Some(message) = problem {
690 push(
691 issues,
692 Severity::Warning,
693 codes::FM_BAD_ID,
694 rel,
695 fm_key_line_or_top(fm_yaml, "id"),
696 Some("id".into()),
697 message,
698 Some(
699 "use one opaque token with no whitespace — the recommended form is a \
700 lowercase ULID (`dbmd write` mints one) — or drop `id` to fall back to \
701 filename identity"
702 .into(),
703 ),
704 vec![],
705 );
706 }
707 }
708 }
709
710 if is_content {
712 check_summary(rel, fm, fm_yaml, issues);
713 }
714
715 if is_content {
719 for (key, missing_code) in [
720 ("created", codes::FM_MISSING_CREATED),
721 ("updated", codes::FM_MISSING_UPDATED),
722 ] {
723 let value = fm.get(key);
728 let missing = value.is_none() || value.is_some_and(Value::is_null);
729 if missing {
730 push(
731 issues,
732 Severity::Error,
733 missing_code,
734 rel,
735 fm_key_line_or_top(fm_yaml, key),
736 Some(key.into()),
737 format!("content file has no `{key}:` timestamp"),
738 Some(format!(
739 "set `{key}` to an RFC3339 timestamp, e.g. 2026-05-27T08:00:00-07:00"
740 )),
741 vec![],
742 );
743 } else if let Some(v) = value {
744 match scalar_string(v) {
750 Some(s) if is_iso8601(&s) => {}
751 Some(s) => push(
752 issues,
753 Severity::Error,
754 codes::FM_BAD_TIMESTAMP,
755 rel,
756 fm_key_line(fm_yaml, key),
757 Some(key.into()),
758 format!("`{key}` is not ISO-8601: {s:?}"),
759 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
760 vec![],
761 ),
762 None => push(
763 issues,
764 Severity::Error,
765 codes::FM_BAD_TIMESTAMP,
766 rel,
767 fm_key_line(fm_yaml, key),
768 Some(key.into()),
769 format!(
770 "`{key}` is not ISO-8601: expected a timestamp string, found a list or mapping"
771 ),
772 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
773 vec![],
774 ),
775 }
776 }
777 }
778 }
779 if let Some(tags) = fm.get("tags") {
781 if !is_flat_scalar_list(tags) {
782 push(
783 issues,
784 Severity::Warning,
785 codes::TAGS_MALFORMED,
786 rel,
787 fm_key_line(fm_yaml, "tags"),
788 Some("tags".into()),
789 "`tags` must be a flat YAML list of short scalar labels".into(),
790 Some("use block form: one `- <tag>` per line".into()),
791 vec![],
792 );
793 }
794 }
795
796 for key in detect_flow_form_link_lists(fm_yaml) {
798 push(
799 issues,
800 Severity::Error,
801 codes::WIKI_LINK_FLOW_FORM_LIST,
802 rel,
803 fm_key_line(fm_yaml, &key),
804 Some(key.clone()),
805 format!("`{key}` uses inline flow form `[[[a]], [[b]]]`"),
806 Some("use YAML block-sequence form: one `- [[...]]` per line".into()),
807 vec![],
808 );
809 }
810
811 let schema_link_keys: BTreeSet<String> =
816 effective_schema(store, type_.as_deref().unwrap_or(""))
817 .map(|s| {
818 s.fields
819 .iter()
820 .filter(|f| f.link_prefix.is_some())
821 .map(|f| f.name.clone())
822 .collect()
823 })
824 .unwrap_or_default();
825 for (key, link) in frontmatter_link_fields_text(fm_yaml, 2) {
826 if schema_link_keys.contains(&key) {
827 continue;
828 }
829 check_wiki_link(
830 store,
831 rel,
832 &link,
833 Some(link.line),
834 Some(&key),
835 basenames,
836 issues,
837 );
838 }
839
840 if let Some(t) = &type_ {
842 if store.config.ignored_types.iter().any(|it| it == t) {
843 push(
844 issues,
845 Severity::Info,
846 codes::POLICY_IGNORED_TYPE_PRESENT,
847 rel,
848 fm_key_line(fm_yaml, "type"),
849 Some("type".into()),
850 format!("file has ignored type `{t}` (per DB.md ## Policies)"),
851 Some(
852 "change the `type`, or remove it from DB.md `### Ignored types` if it should be managed"
853 .into(),
854 ),
855 vec![PathBuf::from("DB.md")],
857 );
858 }
859 let meta_type = fm
865 .get("meta-type")
866 .and_then(scalar_string)
867 .unwrap_or_else(|| "fact".to_string());
868 for link in frontmatter_links_for_key(fm_yaml, "derived_from", 2) {
869 if let Some(hit) =
870 derived_from_ignored_type(store, &meta_type, std::iter::once(link.target.as_str()))
871 {
872 push(
873 issues,
874 Severity::Warning,
875 codes::POLICY_IGNORED_TYPE_DERIVED,
876 rel,
877 Some(link.line),
878 Some("derived_from".into()),
879 format!(
880 "conclusion record derives from ignored-type record `{}` (type `{}`)",
881 hit.target, hit.target_type
882 ),
883 Some(
884 "drop this `derived_from` link, or remove the target type from DB.md `### Ignored types`"
885 .into(),
886 ),
887 vec![
890 PathBuf::from(format!("{}.md", hit.target)),
891 PathBuf::from("DB.md"),
892 ],
893 );
894 }
895 }
896 }
897
898 if let Some(t) = &type_ {
900 if let Some(schema) = effective_schema(store, t) {
901 check_schema(store, rel, fm, fm_yaml, &schema, issues);
902 }
903 }
904}
905
906fn check_summary(rel: &Path, fm: &BTreeMap<String, Value>, fm_yaml: &str, issues: &mut Vec<Issue>) {
908 let line = fm_key_line(fm_yaml, "summary");
909 match fm.get("summary") {
910 None => push(
911 issues,
912 Severity::Error,
913 codes::SUMMARY_MISSING,
914 rel,
915 fm_key_line_or_top(fm_yaml, "summary"),
918 Some("summary".into()),
919 "content file has no `summary`".into(),
920 Some("run `dbmd fm init`".into()),
921 vec![],
922 ),
923 Some(v) => {
924 let s = scalar_string(v).unwrap_or_default();
925 if s.trim().is_empty() {
926 push(
927 issues,
928 Severity::Error,
929 codes::SUMMARY_EMPTY,
930 rel,
931 line,
932 Some("summary".into()),
933 "`summary` is present but empty".into(),
934 Some("write a one-line summary, or run `dbmd fm init`".into()),
935 vec![],
936 );
937 } else if s.contains('\n') {
938 push(
939 issues,
940 Severity::Error,
941 codes::SUMMARY_MULTILINE,
942 rel,
943 line,
944 Some("summary".into()),
945 "`summary` must be one line (contains a newline)".into(),
946 Some("collapse the summary to a single line".into()),
947 vec![],
948 );
949 } else if s.chars().count() > MAX_SUMMARY_LEN {
950 push(
951 issues,
952 Severity::Warning,
953 codes::SUMMARY_TOO_LONG,
954 rel,
955 line,
956 Some("summary".into()),
957 format!(
958 "`summary` is {} chars (> {MAX_SUMMARY_LEN})",
959 s.chars().count()
960 ),
961 Some(format!("trim the summary to ≤ {MAX_SUMMARY_LEN} chars")),
962 vec![],
963 );
964 }
965 }
966 }
967}
968
969fn check_body_wiki_links(
971 store: &Store,
972 rel: &Path,
973 body: &str,
974 fm_end_line: u32,
975 basenames: Option<&BasenameIndex>,
976 issues: &mut Vec<Issue>,
977) {
978 for link in extract_wiki_links(body) {
979 let abs_line = fm_end_line + link.line;
982 check_wiki_link(store, rel, &link, Some(abs_line), None, basenames, issues);
983 }
984}
985
986type BasenameIndex = HashMap<String, Vec<PathBuf>>;
994
995fn build_basename_index(files: &[PathBuf]) -> BasenameIndex {
998 let mut idx: BasenameIndex = HashMap::new();
999 for rel in files {
1000 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
1001 idx.entry(stem.to_string()).or_default().push(rel.clone());
1002 }
1003 }
1004 idx
1005}
1006
1007fn check_wiki_link(
1012 store: &Store,
1013 rel: &Path,
1014 link: &Link,
1015 line: Option<u32>,
1016 key: Option<&str>,
1017 basenames: Option<&BasenameIndex>,
1018 issues: &mut Vec<Issue>,
1019) {
1020 let bare = link.target.trim_end_matches(".md");
1021
1022 if !is_full_store_path(bare) {
1025 if !bare.contains('/') {
1030 if let Some(idx) = basenames {
1031 if let Some(matches) = idx.get(bare) {
1032 if matches.len() >= 2 {
1033 let mut related = matches.clone();
1034 related.sort();
1035 push(
1036 issues,
1037 Severity::Error,
1038 codes::WIKI_LINK_AMBIGUOUS,
1039 rel,
1040 line,
1041 key.map(str::to_string),
1042 format!(
1043 "short-form wiki-link `[[{}]]` matches multiple files",
1044 link.target
1045 ),
1046 Some("use the full store-relative path to disambiguate".into()),
1047 related,
1048 );
1049 return;
1050 }
1051 }
1052 }
1053 }
1054 push(
1055 issues,
1056 Severity::Error,
1057 codes::WIKI_LINK_SHORT_FORM,
1058 rel,
1059 line,
1060 key.map(str::to_string),
1061 format!(
1062 "wiki-link `[[{}]]` is not a full store-relative path",
1063 link.target
1064 ),
1065 short_form_suggestion(bare),
1066 vec![],
1067 );
1068 return;
1070 }
1071
1072 if link.target.ends_with(".md") {
1074 push(
1075 issues,
1076 Severity::Warning,
1077 codes::WIKI_LINK_HAS_EXTENSION,
1078 rel,
1079 line,
1080 key.map(str::to_string),
1081 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1082 Some(format!("drop the extension: [[{bare}]]")),
1083 vec![],
1084 );
1085 }
1086
1087 match resolve_wiki_target(store, bare) {
1092 TargetResolution::Exists => {}
1093 TargetResolution::Missing => push(
1094 issues,
1095 Severity::Error,
1096 codes::WIKI_LINK_BROKEN,
1097 rel,
1098 line,
1099 key.map(str::to_string),
1100 format!("wiki-link target `{bare}` doesn't exist"),
1101 Some(format!(
1102 "create `{bare}.md`, or point the link at an existing file"
1103 )),
1104 vec![],
1105 ),
1106 TargetResolution::Unsafe => push(
1107 issues,
1108 Severity::Error,
1109 codes::WIKI_LINK_BROKEN,
1110 rel,
1111 line,
1112 key.map(str::to_string),
1113 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1114 Some("use a full store-relative path under sources/ or records/".into()),
1115 vec![],
1116 ),
1117 }
1118}
1119
1120fn effective_schema(store: &Store, type_: &str) -> Option<Schema> {
1131 store.config.schemas.get(type_).cloned()
1132}
1133
1134fn check_schema(
1136 store: &Store,
1137 rel: &Path,
1138 fm: &BTreeMap<String, Value>,
1139 fm_yaml: &str,
1140 schema: &Schema,
1141 issues: &mut Vec<Issue>,
1142) {
1143 for spec in &schema.fields {
1144 let present = fm.get(&spec.name);
1145 let line = fm_key_line(fm_yaml, &spec.name);
1146
1147 let is_empty = match present {
1155 None => true,
1156 Some(v) => is_empty_value(v),
1157 };
1158 if spec.required && is_empty {
1159 push(
1160 issues,
1161 Severity::Error,
1162 codes::SCHEMA_MISSING_REQUIRED,
1163 rel,
1164 fm_key_line_or_top(fm_yaml, &spec.name),
1167 Some(spec.name.clone()),
1168 format!("required field `{}` is absent or empty", spec.name),
1169 Some(format!("set `{}` to a non-empty value", spec.name)),
1170 vec![],
1171 );
1172 continue;
1173 }
1174 let Some(value) = present else { continue };
1175
1176 let value_empty = value.is_null()
1182 || scalar_string(value)
1183 .map(|s| s.trim().is_empty())
1184 .unwrap_or(false);
1185 if !spec.required && value_empty {
1186 continue;
1187 }
1188
1189 if let Some(prefix) = &spec.link_prefix {
1192 check_schema_link(store, rel, &spec.name, fm_yaml, prefix, line, issues);
1193 continue; }
1195
1196 if (spec.shape.is_some() || spec.enum_values.is_some()) && scalar_string(value).is_none() {
1203 push(
1204 issues,
1205 Severity::Error,
1206 codes::SCHEMA_SHAPE_MISMATCH,
1207 rel,
1208 line,
1209 Some(spec.name.clone()),
1210 format!(
1211 "`{}` must be a scalar value, found a list or mapping",
1212 spec.name
1213 ),
1214 Some(format!("set `{}` to a single scalar value", spec.name)),
1215 vec![],
1216 );
1217 continue;
1218 }
1219
1220 if let Some(allowed) = &spec.enum_values {
1222 if let Some(s) = scalar_string(value) {
1223 if !allowed.iter().any(|a| a == &s) {
1224 push(
1225 issues,
1226 Severity::Error,
1227 codes::SCHEMA_ENUM_VIOLATION,
1228 rel,
1229 line,
1230 Some(spec.name.clone()),
1231 format!("`{}` value {s:?} not in enum {allowed:?}", spec.name),
1232 Some(format!("use one of: {}", allowed.join(", "))),
1233 vec![],
1234 );
1235 }
1236 }
1237 continue;
1238 }
1239
1240 if let Some(shape) = spec.shape {
1242 check_schema_shape(rel, &spec.name, value, shape, line, issues);
1243 }
1244 }
1245}
1246
1247fn check_schema_link(
1252 store: &Store,
1253 rel: &Path,
1254 field: &str,
1255 fm_yaml: &str,
1256 prefix: &Path,
1257 line: Option<u32>,
1258 issues: &mut Vec<Issue>,
1259) {
1260 let prefix_str = prefix.to_string_lossy();
1261 let prefix_str = prefix_str.trim_end_matches('/');
1262 let suggestion = |target_leaf: &str| {
1263 Some(format!(
1264 "expected `link to {prefix_str}/`; replace with [[{prefix_str}/{target_leaf}]]"
1265 ))
1266 };
1267
1268 let links = frontmatter_links_for_key(fm_yaml, field, 2);
1269 if links.is_empty() {
1270 let raw = frontmatter_raw_value_for_key(fm_yaml, field, 2).unwrap_or_default();
1272 let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
1273 let leaf = slugish(raw);
1274 push(
1275 issues,
1276 Severity::Error,
1277 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1278 rel,
1279 line,
1280 Some(field.to_string()),
1281 format!(
1282 "`{field}` is a plain string {raw:?}, expected a wiki-link under `{prefix_str}/`"
1283 ),
1284 suggestion(&leaf),
1285 vec![],
1286 );
1287 return;
1288 }
1289
1290 for link in links {
1291 if link.target.ends_with(".md") {
1292 let bare = link.target.trim_end_matches(".md");
1293 push(
1294 issues,
1295 Severity::Warning,
1296 codes::WIKI_LINK_HAS_EXTENSION,
1297 rel,
1298 Some(link.line),
1299 Some(field.to_string()),
1300 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1301 Some(format!("drop the extension: [[{bare}]]")),
1302 vec![],
1303 );
1304 }
1305 let bare = link.target.trim_end_matches(".md");
1306 if !path_under_prefix(bare, prefix_str) {
1307 let leaf = bare.rsplit('/').next().unwrap_or(bare);
1308 push(
1309 issues,
1310 Severity::Error,
1311 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1312 rel,
1313 line,
1314 Some(field.to_string()),
1315 format!("`{field}` target `{bare}` is not under `{prefix_str}/`"),
1316 suggestion(leaf),
1317 vec![],
1318 );
1319 } else {
1320 match resolve_wiki_target(store, bare) {
1325 TargetResolution::Exists => {}
1326 TargetResolution::Missing => push(
1327 issues,
1328 Severity::Error,
1329 codes::WIKI_LINK_BROKEN,
1330 rel,
1331 line,
1332 Some(field.to_string()),
1333 format!("wiki-link target `{bare}` doesn't exist"),
1334 Some(format!(
1335 "create `{bare}.md`, or point the link at an existing file"
1336 )),
1337 vec![],
1338 ),
1339 TargetResolution::Unsafe => push(
1340 issues,
1341 Severity::Error,
1342 codes::WIKI_LINK_BROKEN,
1343 rel,
1344 line,
1345 Some(field.to_string()),
1346 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1347 Some("use a full store-relative path under sources/ or records/".into()),
1348 vec![],
1349 ),
1350 }
1351 }
1352 }
1353}
1354
1355fn check_schema_shape(
1357 rel: &Path,
1358 field: &str,
1359 value: &Value,
1360 shape: Shape,
1361 line: Option<u32>,
1362 issues: &mut Vec<Issue>,
1363) {
1364 let s = scalar_string(value).unwrap_or_default();
1365 let ok = match shape {
1366 Shape::String => true, Shape::Int => value.is_i64() || value.is_u64() || s.trim().parse::<i64>().is_ok(),
1368 Shape::Bool => value.is_bool() || matches!(s.trim(), "true" | "false"),
1369 Shape::Date => is_iso8601_date_or_datetime(&s),
1370 Shape::Email => is_email(&s),
1371 Shape::Currency => is_currency(&s),
1372 Shape::Url => is_url(&s),
1373 };
1374 if !ok {
1375 push(
1376 issues,
1377 Severity::Error,
1378 codes::SCHEMA_SHAPE_MISMATCH,
1379 rel,
1380 line,
1381 Some(field.to_string()),
1382 format!("`{field}` value {s:?} doesn't match shape {shape:?}"),
1383 Some(shape_suggestion(shape)),
1384 vec![],
1385 );
1386 }
1387}
1388
1389fn check_duplicates(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
1408 let fm_yaml_of: HashMap<&PathBuf, &str> = parsed
1411 .iter()
1412 .map(|(rel, p)| (rel, p.fm_yaml.as_str()))
1413 .collect();
1414
1415 let mut by_id: HashMap<String, Vec<PathBuf>> = HashMap::new();
1417 for (rel, p) in parsed {
1418 if let Some(map) = &p.fm {
1419 if let Some(id) = map.get("id").and_then(scalar_string) {
1420 if !id.trim().is_empty() {
1421 by_id.entry(id).or_default().push(rel.clone());
1422 }
1423 }
1424 }
1425 }
1426 for (id, files) in &by_id {
1427 if files.len() > 1 {
1428 let (reported, related) = canonical_and_related(files);
1429 let line = fm_yaml_of.get(&reported).and_then(|y| fm_key_line(y, "id"));
1430 push(
1431 issues,
1432 Severity::Error,
1433 codes::DUP_ID,
1434 &reported,
1435 line,
1436 Some("id".into()),
1437 format!("id {id:?} is declared by more than one file"),
1438 Some("give each file a unique `id` (or drop it to derive from the path)".into()),
1439 related,
1440 );
1441 }
1442 }
1443
1444 for (type_name, schema) in &store.config.schemas {
1449 for key_fields in &schema.unique_keys {
1450 soft_dup(parsed, issues, type_name, key_fields, &fm_yaml_of);
1451 }
1452 }
1453}
1454
1455fn soft_dup(
1464 parsed: &[(PathBuf, Parsed)],
1465 issues: &mut Vec<Issue>,
1466 type_: &str,
1467 key_fields: &[String],
1468 fm_yaml_of: &HashMap<&PathBuf, &str>,
1469) {
1470 if key_fields.is_empty() {
1471 return;
1472 }
1473 let mut groups: HashMap<Vec<String>, Vec<PathBuf>> = HashMap::new();
1474 for (rel, p) in parsed {
1475 let is_type =
1476 p.fm.as_ref()
1477 .and_then(|m| m.get("type"))
1478 .and_then(scalar_string)
1479 .map(|t| t == type_)
1480 .unwrap_or(false);
1481 if !is_type {
1482 continue;
1483 }
1484 if let Some(key) = dedup_key(p, key_fields) {
1485 groups.entry(key).or_default().push(rel.clone());
1486 }
1487 }
1488 let mut collisions: Vec<(PathBuf, Vec<PathBuf>)> = groups
1491 .values()
1492 .filter(|files| files.len() > 1)
1493 .map(|files| canonical_and_related(files))
1494 .collect();
1495 collisions.sort_by(|a, b| a.0.cmp(&b.0));
1496
1497 let fields_disp = key_fields.join(", ");
1498 for (reported, related) in collisions {
1499 let (line, key) = if key_fields.len() == 1 {
1502 (
1503 fm_yaml_of
1504 .get(&reported)
1505 .and_then(|y| fm_key_line(y, &key_fields[0])),
1506 Some(key_fields[0].clone()),
1507 )
1508 } else {
1509 (Some(1), None)
1510 };
1511 let n = related.len();
1512 push(
1513 issues,
1514 Severity::Warning,
1515 codes::DUP_UNIQUE_KEY,
1516 &reported,
1517 line,
1518 key,
1519 format!("`{type_}` unique key ({fields_disp}) collides with {n} other record(s)"),
1520 Some("merge with `dbmd rename`, or cross-link with `dbmd link`".into()),
1521 related,
1522 );
1523 }
1524}
1525
1526fn dedup_key(p: &Parsed, key_fields: &[String]) -> Option<Vec<String>> {
1530 let mut out = Vec::with_capacity(key_fields.len());
1531 for f in key_fields {
1532 out.push(dedup_token(p, f)?);
1533 }
1534 Some(out)
1535}
1536
1537fn dedup_token(p: &Parsed, field: &str) -> Option<String> {
1542 let links = frontmatter_links_for_key(&p.fm_yaml, field, 2);
1545 if !links.is_empty() {
1546 let set: BTreeSet<String> = links
1547 .into_iter()
1548 .map(|l| l.target.trim_end_matches(".md").to_lowercase())
1549 .filter(|t| !t.is_empty())
1550 .collect();
1551 return if set.is_empty() {
1552 None
1553 } else {
1554 Some(set.into_iter().collect::<Vec<_>>().join(","))
1555 };
1556 }
1557 match p.fm.as_ref()?.get(field) {
1558 Some(Value::Sequence(items)) => {
1559 let set: BTreeSet<String> = items
1560 .iter()
1561 .filter_map(scalar_string)
1562 .map(|s| s.trim().to_lowercase())
1563 .filter(|t| !t.is_empty())
1564 .collect();
1565 if set.is_empty() {
1566 None
1567 } else {
1568 Some(set.into_iter().collect::<Vec<_>>().join(","))
1569 }
1570 }
1571 Some(v) => {
1572 let s = scalar_string(v)?.trim().to_lowercase();
1573 if s.is_empty() {
1574 None
1575 } else {
1576 Some(s)
1577 }
1578 }
1579 None => None,
1580 }
1581}
1582
1583fn canonical_and_related(files: &[PathBuf]) -> (PathBuf, Vec<PathBuf>) {
1588 let mut sorted = files.to_vec();
1589 sorted.sort();
1590 let reported = sorted[0].clone();
1591 let related = sorted[1..].to_vec();
1592 (reported, related)
1593}
1594
1595fn check_indexes(store: &Store, files: &[PathBuf], issues: &mut Vec<Issue>) {
1601 let mut type_folders: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1605 for rel in files {
1606 if let Some(tf) = type_folder_of(rel) {
1607 type_folders.entry(tf).or_default().push(rel.clone());
1608 }
1609 }
1610
1611 let mut layers_with_type_folders: BTreeSet<&'static str> = BTreeSet::new();
1623 for tf in type_folders.keys() {
1624 match tf.iter().next().and_then(|s| s.to_str()) {
1625 Some("sources") => {
1626 layers_with_type_folders.insert("sources");
1627 }
1628 Some("records") => {
1629 layers_with_type_folders.insert("records");
1630 }
1631 _ => {}
1632 }
1633 }
1634
1635 if !type_folders.is_empty() {
1637 if !store
1638 .regular_file_exists(Path::new("index.md"))
1639 .unwrap_or(false)
1640 {
1641 push(
1642 issues,
1643 Severity::Error,
1644 codes::INDEX_MISSING,
1645 Path::new("index.md"),
1646 None,
1647 None,
1648 "store has files but no root `index.md`".into(),
1649 Some("run `dbmd index rebuild`".into()),
1650 vec![],
1651 );
1652 } else {
1653 check_index_scope(store, Path::new("index.md"), "root", None, issues);
1654 }
1655 }
1656
1657 for layer in &layers_with_type_folders {
1659 let layer_index_rel = PathBuf::from(layer).join("index.md");
1660 if !store.regular_file_exists(&layer_index_rel).unwrap_or(false) {
1661 push(
1662 issues,
1663 Severity::Error,
1664 codes::INDEX_MISSING,
1665 &layer_index_rel,
1666 None,
1667 None,
1668 format!("layer `{layer}/` has files but no `index.md`"),
1669 Some("run `dbmd index rebuild`".into()),
1670 vec![],
1671 );
1672 } else {
1673 check_index_scope(store, &layer_index_rel, "layer", Some(layer), issues);
1674 }
1675 }
1676
1677 for (tf, members) in &type_folders {
1679 let index_md_rel = tf.join("index.md");
1680 let index_md_present = store.regular_file_exists(&index_md_rel).unwrap_or(false);
1681 if !index_md_present {
1682 push(
1688 issues,
1689 Severity::Error,
1690 codes::INDEX_MISSING,
1691 tf,
1692 None,
1693 None,
1694 format!("non-empty folder `{}` has no index.md", tf.display()),
1695 Some(format!(
1696 "run `dbmd index rebuild --folder {}`",
1697 tf.display()
1698 )),
1699 vec![],
1700 );
1701 continue;
1702 }
1703
1704 check_index_scope(store, &index_md_rel, "type-folder", tf.to_str(), issues);
1705 check_type_folder_index_md(store, tf, &index_md_rel, members, issues);
1706
1707 let jsonl_rel = tf.join("index.jsonl");
1711 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1712 push(
1713 issues,
1714 Severity::Error,
1715 codes::INDEX_JSONL_MISSING,
1716 &jsonl_rel,
1717 None,
1718 None,
1719 format!("type-folder `{}/` has no `index.jsonl` twin", tf.display()),
1720 Some("run `dbmd index rebuild`".into()),
1721 vec![],
1722 );
1723 } else {
1724 check_type_folder_index_jsonl(store, tf, &jsonl_rel, members, issues);
1725 }
1726 }
1727
1728 let mut loose_by_layer: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1736 for rel in files {
1737 if !is_content_file(rel) || type_folder_of(rel).is_some() {
1738 continue;
1739 }
1740 if let Some(layer_dir) = loose_layer_dir(rel) {
1741 loose_by_layer
1742 .entry(layer_dir)
1743 .or_default()
1744 .push(rel.clone());
1745 }
1746 }
1747 for (layer_dir, members) in &loose_by_layer {
1748 let jsonl_rel = layer_dir.join("index.jsonl");
1749 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1750 push(
1751 issues,
1752 Severity::Error,
1753 codes::INDEX_JSONL_MISSING,
1754 &jsonl_rel,
1755 None,
1756 None,
1757 format!(
1758 "loose files at `{}/` are not catalogued — the layer has no `index.jsonl`",
1759 layer_dir.display()
1760 ),
1761 Some("run `dbmd index rebuild`".into()),
1762 members.clone(),
1763 );
1764 } else {
1765 check_type_folder_index_jsonl(store, layer_dir, &jsonl_rel, members, issues);
1769 }
1770 }
1771
1772 for rel in walk_index_files(store) {
1774 let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
1775 let parent_str = parent.to_string_lossy().to_string();
1776 let is_canonical = parent_str.is_empty() || matches!(parent_str.as_str(), "sources" | "records")
1778 || type_folders.contains_key(&parent);
1779 if !is_canonical {
1780 push(
1781 issues,
1782 Severity::Warning,
1783 codes::INDEX_ORPHAN,
1784 &rel,
1785 None,
1786 None,
1787 format!(
1788 "`{}` sits in an empty or non-canonical folder",
1789 rel.display()
1790 ),
1791 Some("remove it, or run `dbmd index rebuild`".into()),
1792 vec![],
1793 );
1794 }
1795 }
1796}
1797
1798fn check_type_folder_index_md(
1802 store: &Store,
1803 tf: &Path,
1804 index_rel: &Path,
1805 members: &[PathBuf],
1806 issues: &mut Vec<Issue>,
1807) {
1808 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1809 return;
1810 };
1811 let entries = parse_index_entries(&text);
1812
1813 let listed: BTreeSet<PathBuf> = entries
1814 .iter()
1815 .map(|e| PathBuf::from(e.target.trim_end_matches(".md")))
1816 .collect();
1817
1818 for entry in &entries {
1820 let bare = entry.target.trim_end_matches(".md");
1821 let target_abs = match resolved_target_abs(store, bare) {
1824 Some(abs) => abs,
1825 None => {
1826 if matches!(resolve_wiki_target(store, bare), TargetResolution::Unsafe) {
1827 push(
1828 issues,
1829 Severity::Error,
1830 codes::INDEX_STALE_ENTRY,
1831 index_rel,
1832 Some(entry.line),
1833 None,
1834 format!("index entry `[[{bare}]]` is not a safe store-relative path"),
1835 Some("run `dbmd index rebuild`".into()),
1836 vec![],
1837 );
1838 } else {
1839 push(
1840 issues,
1841 Severity::Error,
1842 codes::INDEX_STALE_ENTRY,
1843 index_rel,
1844 Some(entry.line),
1845 None,
1846 format!("index entry `[[{bare}]]` points at a missing file"),
1847 Some("run `dbmd index rebuild`".into()),
1848 vec![PathBuf::from(format!("{bare}.md"))],
1852 );
1853 }
1854 continue;
1855 }
1856 };
1857 if let Some(expected) = read_summary(store, &target_abs) {
1864 match &entry.summary_text {
1865 Some(text_part)
1876 if crate::summary::collapse_whitespace(text_part)
1877 != crate::summary::collapse_whitespace(&expected) =>
1878 {
1879 push(
1880 issues,
1881 Severity::Error,
1882 codes::INDEX_SUMMARY_MISMATCH,
1883 index_rel,
1884 Some(entry.line),
1885 None,
1886 format!("index entry for `{bare}` text doesn't match the file's `summary`"),
1887 Some("run `dbmd index rebuild`".into()),
1888 vec![PathBuf::from(format!("{bare}.md"))],
1889 );
1890 }
1891 None if !expected.trim().is_empty() => {
1892 push(
1893 issues,
1894 Severity::Error,
1895 codes::INDEX_SUMMARY_MISMATCH,
1896 index_rel,
1897 Some(entry.line),
1898 None,
1899 format!("index entry for `{bare}` is missing its summary text (the file has a `summary`)"),
1900 Some("run `dbmd index rebuild`".into()),
1901 vec![PathBuf::from(format!("{bare}.md"))],
1902 );
1903 }
1904 _ => {}
1905 }
1906 }
1907 }
1908
1909 let content_members: Vec<&PathBuf> = members.iter().filter(|m| is_content_file(m)).collect();
1913 if content_members.len() <= 500 {
1914 for m in content_members {
1915 let bare = PathBuf::from(m.to_string_lossy().trim_end_matches(".md").to_string());
1916 if !listed.contains(&bare) {
1917 push(
1918 issues,
1919 Severity::Error,
1920 codes::INDEX_MISSING_ENTRY,
1921 index_rel,
1922 None,
1923 None,
1924 format!(
1925 "file `{}` is not listed in its folder's `index.md`",
1926 m.display()
1927 ),
1928 Some("run `dbmd index rebuild`".into()),
1929 vec![(*m).clone()],
1930 );
1931 }
1932 }
1933 }
1934 let _ = tf;
1935}
1936
1937fn check_type_folder_index_jsonl(
1941 store: &Store,
1942 tf: &Path,
1943 jsonl_rel: &Path,
1944 members: &[PathBuf],
1945 issues: &mut Vec<Issue>,
1946) {
1947 let Ok(text) = store.read_text_bounded(jsonl_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1948 return;
1949 };
1950
1951 let mut records: BTreeMap<PathBuf, serde_json::Value> = BTreeMap::new();
1953 for (i, line) in text.lines().enumerate() {
1954 let line = line.trim();
1955 if line.is_empty() {
1956 continue;
1957 }
1958 let rec: serde_json::Value = match serde_json::from_str(line) {
1959 Ok(v) => v,
1960 Err(e) => {
1961 push(
1962 issues,
1963 Severity::Error,
1964 codes::INDEX_JSONL_DESYNC,
1965 jsonl_rel,
1966 Some((i + 1) as u32),
1967 None,
1968 format!("`index.jsonl` line {} is not valid JSON: {e}", i + 1),
1969 Some("run `dbmd index rebuild`".into()),
1970 vec![],
1971 );
1972 continue;
1973 }
1974 };
1975 if let Some(path) = rec.get("path").and_then(|v| v.as_str()) {
1976 if !is_safe_store_relative_path(Path::new(path)) {
1977 push(
1978 issues,
1979 Severity::Error,
1980 codes::INDEX_JSONL_DESYNC,
1981 jsonl_rel,
1982 Some((i + 1) as u32),
1983 None,
1984 format!("`index.jsonl` record path `{path}` is not a safe store-relative path"),
1985 Some("run `dbmd index rebuild`".into()),
1986 vec![],
1987 );
1988 continue;
1989 }
1990 records.insert(PathBuf::from(path), rec);
1991 }
1992 }
1993
1994 let member_set: BTreeSet<PathBuf> = members
1995 .iter()
1996 .filter(|m| is_content_file(m))
1997 .cloned()
1998 .collect();
1999
2000 for path in records.keys() {
2002 if !store.regular_file_exists(path).unwrap_or(false) {
2003 push(
2004 issues,
2005 Severity::Error,
2006 codes::INDEX_JSONL_DESYNC,
2007 jsonl_rel,
2008 None,
2009 None,
2010 format!(
2011 "`index.jsonl` record points at missing file `{}`",
2012 path.display()
2013 ),
2014 Some("run `dbmd index rebuild`".into()),
2015 vec![],
2016 );
2017 }
2018 }
2019
2020 for m in &member_set {
2022 if !records.contains_key(m) {
2023 push(
2024 issues,
2025 Severity::Error,
2026 codes::INDEX_JSONL_DESYNC,
2027 jsonl_rel,
2028 None,
2029 None,
2030 format!(
2031 "file `{}` is missing from the complete `index.jsonl`",
2032 m.display()
2033 ),
2034 Some("run `dbmd index rebuild`".into()),
2035 vec![m.clone()],
2036 );
2037 }
2038 }
2039
2040 for (path, rec) in &records {
2054 if !store.regular_file_exists(path).unwrap_or(false) {
2055 continue;
2056 }
2057 let Ok(expected) =
2058 crate::index::IndexRecord::expected_from_store(store, path, path.clone())
2059 else {
2060 continue; };
2062 let Ok(expected_json) = serde_json::to_value(&expected) else {
2063 continue;
2064 };
2065 let (Some(have), Some(want)) = (rec.as_object(), expected_json.as_object()) else {
2066 continue;
2067 };
2068
2069 let mut mismatched_keys: BTreeSet<&str> = BTreeSet::new();
2072 for key in have.keys().chain(want.keys()) {
2073 if key == "path" {
2074 continue;
2075 }
2076 if have.get(key) != want.get(key) {
2077 mismatched_keys.insert(key);
2078 }
2079 }
2080
2081 if !mismatched_keys.is_empty() {
2082 let keys: Vec<&str> = mismatched_keys.into_iter().collect();
2083 push(
2084 issues,
2085 Severity::Error,
2086 codes::INDEX_JSONL_STALE,
2087 jsonl_rel,
2088 None,
2089 Some(keys.join(",")),
2090 format!(
2091 "`index.jsonl` record for `{}` is stale ({})",
2092 path.display(),
2093 keys.join(", ")
2094 ),
2095 Some("run `dbmd index rebuild`".into()),
2096 vec![path.clone()],
2097 );
2098 }
2099 }
2100 let _ = tf;
2101}
2102
2103fn check_index_scope(
2105 store: &Store,
2106 index_rel: &Path,
2107 expected_scope: &str,
2108 expected_folder: Option<&str>,
2109 issues: &mut Vec<Issue>,
2110) {
2111 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2112 return;
2113 };
2114 let Some((yaml, _, _)) = split_frontmatter(&text) else {
2115 return;
2116 };
2117 let Ok(Value::Mapping(map)) = serde_norway::from_str::<Value>(&yaml) else {
2118 return;
2119 };
2120 let fm = yaml_map_to_btree(&map);
2121
2122 if let Some(scope) = fm.get("scope").and_then(scalar_string) {
2123 let scope_ok =
2125 scope == expected_scope || (expected_scope == "type-folder" && scope == "folder");
2126 if !scope_ok {
2127 push(
2128 issues,
2129 Severity::Warning,
2130 codes::INDEX_WRONG_SCOPE,
2131 index_rel,
2132 fm_key_line(&yaml, "scope"),
2133 Some("scope".into()),
2134 format!(
2135 "index `scope: {scope}` doesn't match location (expected `{expected_scope}`)"
2136 ),
2137 Some(format!("set `scope: {expected_scope}`")),
2138 vec![],
2139 );
2140 }
2141 }
2142 if let Some(expected) = expected_folder {
2144 if let Some(folder) = fm.get("folder").and_then(scalar_string) {
2145 if folder.trim_end_matches('/') != expected.trim_end_matches('/') {
2146 push(
2147 issues,
2148 Severity::Warning,
2149 codes::INDEX_WRONG_SCOPE,
2150 index_rel,
2151 fm_key_line(&yaml, "folder"),
2152 Some("folder".into()),
2153 format!("index `folder: {folder}` doesn't match location `{expected}`"),
2154 Some(format!("set `folder: {expected}`")),
2155 vec![],
2156 );
2157 }
2158 }
2159 }
2160}
2161
2162fn check_log(store: &Store, issues: &mut Vec<Issue>) {
2181 let mut prev: Option<DateTime<FixedOffset>> = None;
2182 for rel in log_files_chronological(store) {
2183 check_log_file(store, &rel, &mut prev, issues);
2184 }
2185}
2186
2187fn log_files_chronological(store: &Store) -> Vec<PathBuf> {
2191 let mut files: Vec<PathBuf> = Vec::new();
2192 let archive_dir = Path::new("log");
2193 if let Ok(entries) = store.regular_file_names(archive_dir) {
2194 let mut archives: Vec<PathBuf> = entries
2195 .into_iter()
2196 .filter(|name| {
2197 name.to_str()
2198 .and_then(|n| n.strip_suffix(".md"))
2199 .is_some_and(is_year_month_archive)
2200 })
2201 .map(|name| archive_dir.join(name))
2202 .collect();
2203 archives.sort();
2205 files.extend(archives);
2206 }
2207 if store
2209 .regular_file_exists(Path::new("log.md"))
2210 .unwrap_or(false)
2211 {
2212 files.push(PathBuf::from("log.md"));
2213 }
2214 files
2215}
2216
2217fn check_log_file(
2221 store: &Store,
2222 log_rel: &Path,
2223 prev: &mut Option<DateTime<FixedOffset>>,
2224 issues: &mut Vec<Issue>,
2225) {
2226 let Ok(text) = store.read_text_bounded(log_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2227 return;
2228 };
2229
2230 for (i, line) in text.lines().enumerate() {
2231 if !line.starts_with("## [") {
2232 continue;
2233 }
2234 let line_no = (i + 1) as u32;
2235 match parse_log_header(line) {
2236 None => push(
2237 issues,
2238 Severity::Error,
2239 codes::LOG_BAD_TIMESTAMP,
2240 log_rel,
2241 Some(line_no),
2242 None,
2243 format!("log entry header has an unparseable timestamp: {line:?}"),
2244 Some("use `## [YYYY-MM-DD HH:MM] <kind> | <object>`".into()),
2245 vec![],
2246 ),
2247 Some((ts, kind, _object)) => {
2248 if !RECOGNIZED_LOG_KINDS.contains(&kind.as_str()) {
2249 push(
2250 issues,
2251 Severity::Warning,
2252 codes::LOG_UNKNOWN_KIND,
2253 log_rel,
2254 Some(line_no),
2255 None,
2256 format!("log entry kind `{kind}` is not recognized"),
2257 Some(format!("use one of: {}", RECOGNIZED_LOG_KINDS.join(", "))),
2258 vec![],
2259 );
2260 }
2261 if let Some(p) = *prev {
2262 if ts < p {
2263 push(
2264 issues,
2265 Severity::Warning,
2266 codes::LOG_OUT_OF_ORDER,
2267 log_rel,
2268 Some(line_no),
2269 None,
2270 "log entry is older than the entry above it (possible rewrite)".into(),
2271 Some("append corrective entries; never reorder past ones".into()),
2272 vec![],
2273 );
2274 }
2275 }
2276 *prev = Some(ts);
2277 }
2278 }
2279 }
2280}
2281
2282#[derive(Debug)]
2288struct Link {
2289 target: String,
2290 line: u32,
2291}
2292
2293fn store_marker_present(store: &Store) -> bool {
2297 store
2298 .regular_file_exists(Path::new("DB.md"))
2299 .unwrap_or(false)
2300}
2301
2302fn check_db_md(store: &Store, issues: &mut Vec<Issue>) {
2313 let rel = Path::new("DB.md");
2314 let Ok(text) = store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2315 return; };
2317
2318 let Some((fm_yaml, body, fm_end_line)) = split_frontmatter(&text) else {
2319 push(
2323 issues,
2324 Severity::Error,
2325 codes::DB_MD_BAD_TYPE,
2326 rel,
2327 Some(1),
2328 Some("type".into()),
2329 "DB.md has no frontmatter; it must declare `type: db-md`".into(),
2330 Some("add a `---` frontmatter block with `type: db-md`".into()),
2331 vec![],
2332 );
2333 for field in ["scope", "owner"] {
2334 push(
2335 issues,
2336 Severity::Error,
2337 codes::DB_MD_MISSING_FIELD,
2338 rel,
2339 Some(1),
2340 Some(field.into()),
2341 format!("DB.md frontmatter is missing required field `{field}`"),
2342 Some(format!("add `{field}:` to the DB.md frontmatter")),
2343 vec![],
2344 );
2345 }
2346 return;
2347 };
2348
2349 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
2352 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
2353 Ok(Value::Null) => Some(BTreeMap::new()),
2354 _ => None,
2355 };
2356
2357 match &fm {
2358 Some(map) => {
2359 let type_ = map.get("type").and_then(scalar_string);
2361 if type_.as_deref() != Some("db-md") {
2362 let (line, msg) = match &type_ {
2363 Some(t) => (
2364 fm_key_line(&fm_yaml, "type"),
2365 format!("DB.md has `type: {t}`; a store's DB.md must be `type: db-md`"),
2366 ),
2367 None => (
2368 Some(1),
2369 "DB.md frontmatter has no `type:`; it must be `type: db-md`".to_string(),
2370 ),
2371 };
2372 push(
2373 issues,
2374 Severity::Error,
2375 codes::DB_MD_BAD_TYPE,
2376 rel,
2377 line,
2378 Some("type".into()),
2379 msg,
2380 Some("set `type: db-md` in the DB.md frontmatter".into()),
2381 vec![],
2382 );
2383 }
2384
2385 for field in ["scope", "owner"] {
2387 let present = map
2388 .get(field)
2389 .and_then(scalar_string)
2390 .map(|s| !s.trim().is_empty())
2391 .unwrap_or(false);
2392 if !present {
2393 push(
2394 issues,
2395 Severity::Error,
2396 codes::DB_MD_MISSING_FIELD,
2397 rel,
2398 fm_key_line_or_top(&fm_yaml, field),
2401 Some(field.into()),
2402 format!("DB.md frontmatter is missing required field `{field}`"),
2403 Some(format!("add `{field}:` to the DB.md frontmatter")),
2404 vec![],
2405 );
2406 }
2407 }
2408 }
2409 None => {
2410 push(
2413 issues,
2414 Severity::Error,
2415 codes::DB_MD_BAD_TYPE,
2416 rel,
2417 Some(1),
2418 Some("type".into()),
2419 "DB.md frontmatter isn't valid YAML; it must declare `type: db-md`".into(),
2420 Some("fix the DB.md frontmatter and set `type: db-md`".into()),
2421 vec![],
2422 );
2423 for field in ["scope", "owner"] {
2424 push(
2425 issues,
2426 Severity::Error,
2427 codes::DB_MD_MISSING_FIELD,
2428 rel,
2429 Some(1),
2430 Some(field.into()),
2431 format!("DB.md frontmatter is missing required field `{field}`"),
2432 Some(format!("add `{field}:` to the DB.md frontmatter")),
2433 vec![],
2434 );
2435 }
2436 }
2437 }
2438
2439 for section in crate::parser::extract_sections(&body) {
2453 if section.level != 2 {
2454 continue;
2455 }
2456 let name = section.heading.trim().to_ascii_lowercase();
2457 if matches!(
2458 name.as_str(),
2459 "agent instructions" | "policies" | "schemas" | "folders"
2460 ) {
2461 continue;
2462 }
2463 let file_line = fm_end_line + section.line;
2466 push(
2467 issues,
2468 Severity::Warning,
2469 codes::DB_MD_UNKNOWN_SECTION,
2470 rel,
2471 Some(file_line),
2472 None,
2473 format!(
2474 "DB.md has an unrecognized `## {}` section",
2475 section.heading.trim()
2476 ),
2477 Some(
2478 "DB.md sections are `## Agent instructions`, `## Policies`, `## Schemas`, \
2479 `## Folders` — remove or rename this heading"
2480 .into(),
2481 ),
2482 vec![],
2483 );
2484 }
2485
2486 check_db_md_schemas(store, rel, &body, fm_end_line, issues);
2491}
2492
2493fn check_db_md_schemas(
2500 store: &Store,
2501 rel: &Path,
2502 body: &str,
2503 fm_end_line: u32,
2504 issues: &mut Vec<Issue>,
2505) {
2506 if store.config.schemas.is_empty() {
2507 return;
2508 }
2509
2510 let mut type_line: BTreeMap<String, u32> = BTreeMap::new();
2515 let mut current_h2: Option<String> = None;
2516 for section in crate::parser::extract_sections(body) {
2517 match section.level {
2518 2 => current_h2 = Some(section.heading.trim().to_ascii_lowercase()),
2519 3 if current_h2.as_deref() == Some("schemas") => {
2520 type_line
2523 .entry(section.heading.trim().to_string())
2524 .or_insert(fm_end_line + section.line);
2525 }
2526 _ => {}
2527 }
2528 }
2529
2530 for (type_name, schema) in &store.config.schemas {
2531 let line = type_line.get(type_name).copied();
2532 let mut seen: BTreeSet<String> = BTreeSet::new();
2533 for field in &schema.fields {
2534 let name = field.name.trim();
2535
2536 if name.is_empty() {
2540 push(
2541 issues,
2542 Severity::Warning,
2543 codes::DB_MD_SCHEMA_FIELD,
2544 rel,
2545 line,
2546 None,
2547 format!("`### {type_name}` has a schema field bullet with no field name"),
2548 Some(
2549 "write each field as `- <name> (<modifiers>)`, e.g. `- email (required, email)`"
2550 .into(),
2551 ),
2552 vec![],
2553 );
2554 continue;
2555 }
2556
2557 if !seen.insert(name.to_string()) {
2561 push(
2562 issues,
2563 Severity::Warning,
2564 codes::DB_MD_SCHEMA_FIELD,
2565 rel,
2566 line,
2567 Some(name.to_string()),
2568 format!("`### {type_name}` declares field `{name}` more than once"),
2569 Some(
2570 "remove the duplicate field bullet, or merge the modifiers onto one".into(),
2571 ),
2572 vec![],
2573 );
2574 }
2575
2576 for modifier in &field.unknown_modifiers {
2581 let modifier = modifier.trim();
2582 if modifier.is_empty() {
2583 continue;
2584 }
2585 push(
2586 issues,
2587 Severity::Info,
2588 codes::DB_MD_SCHEMA_FIELD,
2589 rel,
2590 line,
2591 Some(name.to_string()),
2592 format!(
2593 "`### {type_name}` field `{name}` has an unrecognized modifier `{modifier}`"
2594 ),
2595 Some(
2596 "recognized modifiers are `required`, a shape (`string`/`int`/`bool`/`date`/`email`/`currency`/`url`), `link to <prefix>/`, `default <value>`, `enum: <v1>, <v2>, …`"
2597 .into(),
2598 ),
2599 vec![],
2600 );
2601 }
2602 }
2603
2604 let mut declared: BTreeMap<&str, bool> = BTreeMap::new();
2613 for f in &schema.fields {
2614 let e = declared.entry(f.name.trim()).or_insert(false);
2615 *e = *e || f.required;
2616 }
2617 let mut flagged: BTreeSet<&str> = BTreeSet::new();
2618 for key_fields in &schema.unique_keys {
2619 for field in key_fields {
2620 let name = field.trim();
2621 if name.is_empty()
2622 || declared.get(name).copied() == Some(true)
2623 || !flagged.insert(name)
2624 {
2625 continue;
2626 }
2627 let message = if declared.contains_key(name) {
2628 format!(
2629 "`### {type_name}` `unique:` key field `{name}` is not `required` — a record missing or leaving it empty is silently skipped by the unique check"
2630 )
2631 } else {
2632 format!(
2633 "`### {type_name}` `unique:` key field `{name}` is not declared in the schema, so it can never be `required` — a record missing it is silently skipped by the unique check"
2634 )
2635 };
2636 push(
2637 issues,
2638 Severity::Warning,
2639 codes::DB_MD_SCHEMA_FIELD,
2640 rel,
2641 line,
2642 Some(name.to_string()),
2643 message,
2644 Some(format!(
2645 "mark `{name}` `required` in `### {type_name}`, or build the `unique:` key from required fields only"
2646 )),
2647 vec![],
2648 );
2649 }
2650 }
2651 }
2652}
2653
2654fn not_a_store_issue(store: &Store) -> Issue {
2656 Issue {
2657 severity: Severity::Error,
2658 code: codes::NOT_A_STORE,
2659 file: store.root.clone(),
2660 line: None,
2661 key: None,
2662 message: format!("{} has no DB.md; not a db.md store", store.root.display()),
2663 suggestion: Some("create a `DB.md` at the store root".into()),
2664 related: vec![],
2665 }
2666}
2667
2668fn is_content_file(rel: &Path) -> bool {
2671 if !is_safe_store_relative_path(rel) {
2677 return false;
2678 }
2679 let Some(first) = rel.iter().next().and_then(|s| s.to_str()) else {
2680 return false;
2681 };
2682 if !matches!(first, "sources" | "records") {
2683 return false;
2684 }
2685 let name = rel.file_name().and_then(|s| s.to_str()).unwrap_or("");
2686 if matches!(name, "index.md" | "index.jsonl") {
2690 return false;
2691 }
2692 name.ends_with(".md")
2693}
2694
2695fn is_root_meta_file(rel: &Path) -> bool {
2702 let mut comps = rel.components();
2703 let Some(Component::Normal(only)) = comps.next() else {
2704 return false;
2705 };
2706 if comps.next().is_some() {
2707 return false; }
2709 matches!(only.to_str(), Some("DB.md") | Some("log.md"))
2710}
2711
2712fn is_index_catalog_file(rel: &Path) -> bool {
2720 matches!(
2721 rel.file_name().and_then(|n| n.to_str()),
2722 Some("index.md") | Some("index.jsonl")
2723 )
2724}
2725
2726fn split_frontmatter(text: &str) -> Option<(String, String, u32)> {
2730 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
2735 let mut lines = text.lines();
2736 let first = lines.next()?;
2737 if first.trim_end() != "---" {
2738 return None;
2739 }
2740 let mut yaml = String::new();
2741 let mut close_line: Option<u32> = None;
2742 let mut current = 1u32;
2744 for line in lines {
2745 current += 1;
2746 if line.trim_end() == "---" {
2747 close_line = Some(current);
2748 break;
2749 }
2750 yaml.push_str(line);
2751 yaml.push('\n');
2752 }
2753 let close_line = close_line?;
2754 let body: String = text
2756 .lines()
2757 .skip(close_line as usize)
2758 .collect::<Vec<_>>()
2759 .join("\n");
2760 Some((yaml, body, close_line))
2761}
2762
2763fn body_opens_with_frontmatter(body: &str) -> bool {
2771 let start: String = body
2772 .lines()
2773 .skip_while(|l| l.trim().is_empty())
2774 .collect::<Vec<_>>()
2775 .join("\n");
2776 match split_frontmatter(&start) {
2777 Some((yaml, _, _)) => matches!(
2778 serde_norway::from_str::<Value>(&yaml),
2779 Ok(Value::Mapping(m)) if !m.is_empty()
2780 ),
2781 None => false,
2782 }
2783}
2784
2785fn read_summary(store: &Store, abs: &Path) -> Option<String> {
2787 let text = store
2788 .read_text_bounded(abs, crate::parser::MAX_DBMD_FILE_BYTES)
2789 .ok()?;
2790 let (yaml, _, _) = split_frontmatter(&text)?;
2791 let value: Value = serde_norway::from_str(&yaml).ok()?;
2792 if let Value::Mapping(m) = value {
2793 m.get(Value::String("summary".into()))
2794 .and_then(scalar_string)
2795 } else {
2796 None
2797 }
2798}
2799
2800fn yaml_map_to_btree(map: &serde_norway::Mapping) -> BTreeMap<String, Value> {
2803 let mut out = BTreeMap::new();
2804 for (k, v) in map {
2805 if let Value::String(s) = k {
2806 out.insert(s.clone(), v.clone());
2807 }
2808 }
2809 out
2810}
2811
2812fn scalar_string(v: &Value) -> Option<String> {
2815 match v {
2816 Value::String(s) => Some(s.clone()),
2817 Value::Number(n) => Some(n.to_string()),
2818 Value::Bool(b) => Some(b.to_string()),
2819 _ => None,
2820 }
2821}
2822
2823fn is_empty_value(v: &Value) -> bool {
2830 match v {
2831 Value::Null => true,
2832 Value::Sequence(items) => items.is_empty(),
2833 Value::Mapping(map) => map.is_empty(),
2834 other => scalar_string(other)
2835 .map(|s| s.trim().is_empty())
2836 .unwrap_or(true),
2837 }
2838}
2839
2840fn is_flat_scalar_list(v: &Value) -> bool {
2843 match v {
2844 Value::Sequence(items) => items.iter().all(|it| scalar_string(it).is_some()),
2845 _ => false,
2846 }
2847}
2848
2849fn frontmatter_link_fields_text(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, Link)> {
2859 let mut out = Vec::new();
2860 for (key, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2861 for link in links {
2862 out.push((key.clone(), link));
2863 }
2864 }
2865 out
2866}
2867
2868fn frontmatter_links_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Vec<Link> {
2872 for (k, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2873 if k == key {
2874 return links;
2875 }
2876 }
2877 Vec::new()
2878}
2879
2880fn frontmatter_raw_value_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Option<String> {
2884 for (k, value_text, _links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2885 if k == key {
2886 return Some(value_text);
2887 }
2888 }
2889 None
2890}
2891
2892fn frontmatter_key_blocks(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, String, Vec<Link>)> {
2899 let mut blocks: Vec<(String, String, Vec<Link>)> = Vec::new();
2900 let mut current: Option<(String, String, Vec<Link>)> = None;
2901
2902 for (idx, raw_line) in fm_yaml.lines().enumerate() {
2903 let file_line = fm_start_line + idx as u32;
2904 let indented = raw_line.starts_with(' ') || raw_line.starts_with('\t');
2905 let trimmed = raw_line.trim();
2906
2907 let new_key = if !indented && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
2910 top_level_key(raw_line)
2911 } else {
2912 None
2913 };
2914
2915 if let Some((key, after)) = new_key {
2916 if let Some(done) = current.take() {
2917 blocks.push(done);
2918 }
2919 let mut links = Vec::new();
2920 collect_line_links(after, file_line, &mut links);
2921 current = Some((key, after.trim().to_string(), links));
2922 } else if let Some((_k, value_text, links)) = current.as_mut() {
2923 if !value_text.is_empty() {
2925 value_text.push('\n');
2926 }
2927 value_text.push_str(trimmed);
2928 collect_line_links(raw_line, file_line, links);
2929 }
2930 }
2931 if let Some(done) = current.take() {
2932 blocks.push(done);
2933 }
2934 blocks
2935}
2936
2937fn top_level_key(line: &str) -> Option<(String, &str)> {
2940 let (key, rest) = line.split_once(':')?;
2941 let key = key.trim();
2942 if key.is_empty()
2943 || !key
2944 .chars()
2945 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2946 {
2947 return None;
2948 }
2949 Some((key.to_string(), rest))
2950}
2951
2952fn collect_line_links(s: &str, file_line: u32, links: &mut Vec<Link>) {
2955 let bytes = s.as_bytes();
2956 let mut i = 0;
2957 while i + 1 < bytes.len() {
2958 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2959 if let Some(close) = s[i + 2..].find("]]") {
2960 let inner = &s[i + 2..i + 2 + close];
2961 let target = inner
2964 .trim_start_matches('[')
2965 .split('|')
2966 .next()
2967 .unwrap_or(inner)
2968 .trim()
2969 .to_string();
2970 if !target.is_empty() {
2971 links.push(Link {
2972 target,
2973 line: file_line,
2974 });
2975 }
2976 i = i + 2 + close + 2;
2977 continue;
2978 }
2979 }
2980 i += 1;
2981 }
2982}
2983
2984fn extract_wiki_links(body: &str) -> Vec<Link> {
2996 let mut out = Vec::new();
2997 let mut fence: Option<(u8, usize)> = None;
2998 for (idx, line) in body.lines().enumerate() {
2999 let content = line.trim_end_matches('\r');
3000 if let Some(f) = fence {
3001 if fence_closes(content, f) {
3005 fence = None;
3006 }
3007 continue;
3008 }
3009 if let Some(opened) = fence_opens(content) {
3010 fence = Some(opened);
3011 continue;
3012 }
3013 let line_no = (idx + 1) as u32;
3014 let bytes = line.as_bytes();
3015 let mut i = 0;
3016 while i + 1 < bytes.len() {
3017 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
3018 if let Some(close) = line[i + 2..].find("]]") {
3019 let inner = &line[i + 2..i + 2 + close];
3020 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3021 if !target.is_empty() && !target.starts_with('[') {
3029 out.push(Link {
3030 target,
3031 line: line_no,
3032 });
3033 }
3034 i = i + 2 + close + 2;
3035 continue;
3036 }
3037 }
3038 i += 1;
3039 }
3040 }
3041 out
3042}
3043
3044fn fence_opens(line: &str) -> Option<(u8, usize)> {
3050 let indent = line.len() - line.trim_start_matches(' ').len();
3051 if indent > 3 {
3052 return None;
3053 }
3054 let rest = &line[indent..];
3055 let byte = rest.bytes().next()?;
3056 if byte != b'`' && byte != b'~' {
3057 return None;
3058 }
3059 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3060 if run < 3 {
3061 return None;
3062 }
3063 if byte == b'`' && rest[run..].contains('`') {
3065 return None;
3066 }
3067 Some((byte, run))
3068}
3069
3070fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
3075 let (byte, open_len) = fence;
3076 let indent = line.len() - line.trim_start_matches(' ').len();
3077 if indent > 3 {
3078 return false;
3079 }
3080 let rest = &line[indent..];
3081 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3082 if run < open_len {
3083 return false;
3084 }
3085 rest[run..].trim().is_empty()
3086}
3087
3088fn detect_flow_form_link_lists(fm_yaml: &str) -> Vec<String> {
3105 let mut out = Vec::new();
3106 for line in fm_yaml.lines() {
3107 if line.starts_with(' ') || line.starts_with('\t') {
3109 continue;
3110 }
3111 let Some((key, rest)) = line.split_once(':') else {
3112 continue;
3113 };
3114 let key = key.trim();
3115 if key.is_empty()
3116 || key.starts_with('#')
3117 || key.starts_with('-')
3118 || !key
3119 .chars()
3120 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
3121 {
3122 continue;
3123 }
3124 let rest = rest.trim();
3125 if !rest.starts_with('[') {
3128 continue;
3129 }
3130 if let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(rest) {
3135 let nested = items.iter().any(|item| match item {
3136 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
3137 _ => false,
3138 });
3139 if nested {
3140 out.push(key.to_string());
3141 }
3142 }
3143 }
3144 out
3145}
3146
3147fn is_full_store_path(bare: &str) -> bool {
3150 let mut parts = bare.splitn(2, '/');
3151 let first = parts.next().unwrap_or("");
3152 let has_rest = parts.next().map(|r| !r.is_empty()).unwrap_or(false);
3153 matches!(first, "sources" | "records") && has_rest
3154}
3155
3156fn is_safe_store_relative_path(path: &Path) -> bool {
3160 let mut saw_component = false;
3161 for component in path.components() {
3162 match component {
3163 Component::Normal(_) => saw_component = true,
3164 Component::CurDir => {}
3165 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
3166 }
3167 }
3168 saw_component
3169}
3170
3171fn safe_md_target_rel(bare: &str) -> Option<PathBuf> {
3172 let path = Path::new(bare);
3173 if !is_safe_store_relative_path(path) {
3174 return None;
3175 }
3176 Some(PathBuf::from(format!("{bare}.md")))
3177}
3178
3179enum TargetResolution {
3181 Exists,
3183 Missing,
3185 Unsafe,
3187}
3188
3189fn resolve_wiki_target(store: &Store, bare: &str) -> TargetResolution {
3198 if !is_safe_store_relative_path(Path::new(bare)) {
3202 return TargetResolution::Unsafe;
3203 }
3204 match resolved_target_abs(store, bare) {
3205 Some(_) => TargetResolution::Exists,
3206 None => TargetResolution::Missing,
3207 }
3208}
3209
3210fn resolved_target_abs(store: &Store, bare: &str) -> Option<PathBuf> {
3236 if !is_safe_store_relative_path(Path::new(bare)) {
3237 return None;
3238 }
3239 let literal = PathBuf::from(bare);
3242 if store.regular_file_exists(&literal).ok()? && disk_case_matches(store, &literal, bare) {
3243 return Some(literal);
3244 }
3245 let with_md_rel = format!("{bare}.md");
3247 let with_md = PathBuf::from(&with_md_rel);
3248 if store.regular_file_exists(&with_md).ok()? && disk_case_matches(store, &with_md, &with_md_rel)
3249 {
3250 return Some(with_md);
3251 }
3252 None
3253}
3254
3255fn disk_case_matches(store: &Store, abs: &Path, requested: &str) -> bool {
3270 abs == Path::new(requested) && store.path_case_matches(abs).unwrap_or(true)
3271}
3272
3273fn path_under_prefix(bare: &str, prefix: &str) -> bool {
3275 let prefix = prefix.trim_end_matches('/');
3276 bare == prefix || bare.starts_with(&format!("{prefix}/"))
3277}
3278
3279fn type_folder_of(rel: &Path) -> Option<PathBuf> {
3283 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3284 if comps.len() < 3 {
3285 return None; }
3287 if !matches!(comps[0], "sources" | "records") {
3288 return None;
3289 }
3290 Some(PathBuf::from(comps[0]).join(comps[1]))
3291}
3292
3293fn loose_layer_dir(rel: &Path) -> Option<PathBuf> {
3298 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3299 if comps.len() != 2 || !matches!(comps[0], "sources" | "records") {
3300 return None;
3301 }
3302 Some(PathBuf::from(comps[0]))
3303}
3304
3305fn walk_index_files(store: &Store) -> Vec<PathBuf> {
3310 let mut out = Vec::new();
3311 if store
3312 .regular_file_exists(Path::new("index.md"))
3313 .unwrap_or(false)
3314 {
3315 out.push(PathBuf::from("index.md"));
3316 }
3317 for layer in ["sources", "records"] {
3318 if let Ok(files) = store.walk_regular_files(Path::new(layer)) {
3319 for rel in files {
3320 if rel.file_name().and_then(|name| name.to_str()) == Some("index.md") {
3321 out.push(rel);
3322 }
3323 }
3324 }
3325 }
3326 out.sort();
3327 out
3328}
3329
3330struct IndexEntry {
3333 target: String,
3334 summary_text: Option<String>,
3335 line: u32,
3336}
3337
3338fn parse_index_entries(text: &str) -> Vec<IndexEntry> {
3343 let mut out = Vec::new();
3344 let mut in_more = false;
3345 for (idx, line) in text.lines().enumerate() {
3346 let trimmed = line.trim_start();
3347 if trimmed.starts_with("## More") {
3348 in_more = true;
3349 continue;
3350 }
3351 if in_more {
3352 continue;
3353 }
3354 if !trimmed.starts_with("- ") {
3355 continue;
3356 }
3357 let Some(open) = trimmed.find("[[") else {
3359 continue;
3360 };
3361 let Some(close_rel) = trimmed[open + 2..].find("]]") else {
3362 continue;
3363 };
3364 let inner = &trimmed[open + 2..open + 2 + close_rel];
3365 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3366
3367 let after = &trimmed[open + 2 + close_rel + 2..];
3369 let summary_text = extract_index_entry_summary(after);
3370
3371 out.push(IndexEntry {
3372 target,
3373 summary_text,
3374 line: (idx + 1) as u32,
3375 });
3376 }
3377 out
3378}
3379
3380fn extract_index_entry_summary(after: &str) -> Option<String> {
3386 let mut s = after.trim();
3387 if s.starts_with('(') {
3389 if let Some(close) = s.find(')') {
3390 s = s[close + 1..].trim_start();
3391 }
3392 }
3393 let s = s.strip_prefix('—').or_else(|| s.strip_prefix('-'))?.trim();
3395 if s.is_empty() {
3396 return None;
3397 }
3398 let s = match s.rsplit_once(" · ") {
3413 Some((summary, tags)) if is_tag_suffix(tags) => summary.trim(),
3414 _ => s,
3415 };
3416 Some(s.to_string())
3417}
3418
3419fn is_tag_suffix(s: &str) -> bool {
3424 let mut any = false;
3425 for tok in s.split_whitespace() {
3426 if !tok.starts_with('#') || tok.len() < 2 {
3427 return false;
3428 }
3429 any = true;
3430 }
3431 any
3432}
3433
3434fn parse_log_header(line: &str) -> Option<(DateTime<FixedOffset>, String, Option<String>)> {
3438 let rest = line.strip_prefix("## [")?;
3439 let close = rest.find(']')?;
3440 let ts_str = &rest[..close];
3441 let tail = rest[close + 1..].trim();
3442
3443 let naive = NaiveDateTime::parse_from_str(ts_str.trim(), "%Y-%m-%d %H:%M").ok()?;
3446 let offset = FixedOffset::east_opt(0)?;
3447 let ts = naive.and_local_timezone(offset).single()?;
3448
3449 let (kind, object) = match tail.split_once('|') {
3451 Some((k, o)) => {
3452 let o = o.trim();
3453 (
3454 k.trim().to_string(),
3455 if o.is_empty() {
3456 None
3457 } else {
3458 Some(o.to_string())
3459 },
3460 )
3461 }
3462 None => (tail.to_string(), None),
3463 };
3464 if kind.is_empty() {
3465 return None;
3466 }
3467 Some((ts, kind, object))
3468}
3469
3470fn log_files_for_working_set(store: &Store) -> Vec<PathBuf> {
3480 let mut files = vec![PathBuf::from("log.md")];
3481 let archive_dir = Path::new("log");
3482 if let Ok(entries) = store.regular_file_names(archive_dir) {
3483 let mut archives: Vec<PathBuf> = entries
3484 .into_iter()
3485 .filter(|name| {
3486 name.to_str()
3487 .and_then(|n| n.strip_suffix(".md"))
3488 .is_some_and(is_year_month_archive)
3489 })
3490 .map(|name| archive_dir.join(name))
3491 .collect();
3492 archives.sort();
3496 files.extend(archives);
3497 }
3498 files.retain(|path| store.regular_file_exists(path).unwrap_or(false));
3499 files
3500}
3501
3502fn is_year_month_archive(s: &str) -> bool {
3505 let b = s.as_bytes();
3506 b.len() == 7
3507 && b[..4].iter().all(u8::is_ascii_digit)
3508 && b[4] == b'-'
3509 && b[5..7].iter().all(u8::is_ascii_digit)
3510}
3511
3512fn last_validate_at(store: &Store) -> Option<DateTime<FixedOffset>> {
3518 let mut latest: Option<DateTime<FixedOffset>> = None;
3519 for file in log_files_for_working_set(store) {
3520 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3521 continue;
3522 };
3523 for line in text.lines() {
3524 if !line.starts_with("## [") {
3525 continue;
3526 }
3527 if let Some((ts, kind, _)) = parse_log_header(line) {
3528 if kind == "validate" {
3529 latest = Some(match latest {
3530 Some(p) if p >= ts => p,
3531 _ => ts,
3532 });
3533 }
3534 }
3535 }
3536 }
3537 latest
3538}
3539
3540fn changed_objects_since(
3551 store: &Store,
3552 cutoff: Option<DateTime<FixedOffset>>,
3553) -> BTreeSet<PathBuf> {
3554 let mut out = BTreeSet::new();
3555 for file in log_files_for_working_set(store) {
3556 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3557 continue;
3558 };
3559 for line in text.lines() {
3560 if !line.starts_with("## [") {
3561 continue;
3562 }
3563 let Some((ts, kind, object)) = parse_log_header(line) else {
3564 continue;
3565 };
3566 if let Some(c) = cutoff {
3567 if ts < c {
3568 continue;
3569 }
3570 }
3571 if !matches!(
3572 kind.as_str(),
3573 "create" | "update" | "ingest" | "rename" | "delete" | "link"
3574 ) {
3575 continue;
3576 }
3577 if let Some(obj) = object {
3578 let bare = obj
3580 .trim()
3581 .trim_start_matches("[[")
3582 .trim_end_matches("]]")
3583 .split('|')
3584 .next()
3585 .unwrap_or("")
3586 .trim()
3587 .trim_end_matches(".md")
3588 .to_string();
3589 if bare.is_empty() {
3590 continue;
3591 }
3592 if let Some(rel) = safe_md_target_rel(&bare) {
3602 out.insert(rel);
3603 }
3604 }
3605 }
3606 }
3607 out
3608}
3609
3610#[derive(Debug, Clone, PartialEq, Eq)]
3615pub struct DerivedFromIgnored {
3616 pub target: String,
3619 pub target_type: String,
3622}
3623
3624pub fn derived_from_ignored_type<I, S>(
3638 store: &Store,
3639 meta_type: &str,
3640 derived_from_targets: I,
3641) -> Option<DerivedFromIgnored>
3642where
3643 I: IntoIterator<Item = S>,
3644 S: AsRef<str>,
3645{
3646 if meta_type != "conclusion" || store.config.ignored_types.is_empty() {
3647 return None;
3648 }
3649 for target in derived_from_targets {
3650 let target = target.as_ref();
3651 if let Some(target_type) = link_target_type(store, target) {
3652 if store.config.ignored_types.contains(&target_type) {
3653 return Some(DerivedFromIgnored {
3654 target: target.to_string(),
3655 target_type,
3656 });
3657 }
3658 }
3659 }
3660 None
3661}
3662
3663fn link_target_type(store: &Store, target: &str) -> Option<String> {
3665 let bare = target.trim_end_matches(".md");
3666 let rel = safe_md_target_rel(bare)?;
3667 let text = store
3668 .read_text_bounded(&rel, crate::parser::MAX_DBMD_FILE_BYTES)
3669 .ok()?;
3670 let (yaml, _, _) = split_frontmatter(&text)?;
3671 let value: Value = serde_norway::from_str(&yaml).ok()?;
3672 if let Value::Mapping(m) = value {
3673 m.get(Value::String("type".into())).and_then(scalar_string)
3674 } else {
3675 None
3676 }
3677}
3678
3679fn is_iso8601(s: &str) -> bool {
3684 DateTime::parse_from_rfc3339(s.trim()).is_ok()
3685}
3686
3687fn is_iso8601_date_or_datetime(s: &str) -> bool {
3691 let s = s.trim();
3692 if DateTime::parse_from_rfc3339(s).is_ok() {
3693 return true;
3694 }
3695 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
3696}
3697
3698fn is_email(s: &str) -> bool {
3703 let s = s.trim();
3704 let Some((local, domain)) = s.split_once('@') else {
3705 return false;
3706 };
3707 !local.is_empty()
3708 && !domain.contains('@')
3709 && domain.contains('.')
3710 && !domain.starts_with('.')
3711 && !domain.ends_with('.')
3712 && !domain.contains(' ')
3713 && !local.contains(' ')
3714}
3715
3716fn is_currency(s: &str) -> bool {
3723 let mut t = s.trim();
3724 for sym in ["$", "€", "£", "¥"] {
3726 if let Some(rest) = t.strip_prefix(sym) {
3727 t = rest.trim_start();
3728 break;
3729 }
3730 }
3731 if let Some((head, rest)) = t.split_once(char::is_whitespace) {
3735 if head.len() == 3 && head.chars().all(|c| c.is_ascii_alphabetic()) {
3736 t = rest.trim_start();
3737 }
3738 }
3739
3740 let cleaned: String = t.chars().filter(|c| *c != ',').collect();
3741 is_plain_amount(cleaned.trim())
3742}
3743
3744fn is_plain_amount(s: &str) -> bool {
3747 let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
3748 let (int_part, frac_part) = match digits.split_once('.') {
3749 Some((i, f)) => (i, Some(f)),
3750 None => (digits, None),
3751 };
3752 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3753 return false;
3754 }
3755 match frac_part {
3756 None => true,
3757 Some(f) => (1..=2).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()),
3758 }
3759}
3760
3761fn is_url(s: &str) -> bool {
3767 let s = s.trim();
3768 for scheme in ["http://", "https://"] {
3769 if let Some(rest) = s.strip_prefix(scheme) {
3770 return !rest.is_empty();
3771 }
3772 }
3773 false
3774}
3775
3776fn shape_suggestion(shape: Shape) -> String {
3778 match shape {
3779 Shape::String => "use a scalar string".into(),
3780 Shape::Int => "use an integer".into(),
3781 Shape::Bool => "use `true` or `false`".into(),
3782 Shape::Date => "use an ISO-8601 date, e.g. 2026-05-27".into(),
3783 Shape::Email => "use a `<local>@<domain>` address".into(),
3784 Shape::Currency => "use a numeric amount, e.g. 1234.56".into(),
3785 Shape::Url => "use an http(s) URL".into(),
3786 }
3787}
3788
3789fn short_form_suggestion(bare: &str) -> Option<String> {
3792 Some(format!(
3793 "use a full store-relative path, e.g. [[records/contacts/{}]]",
3794 slugish(bare)
3795 ))
3796}
3797
3798fn slugish(s: &str) -> String {
3800 s.trim()
3801 .to_lowercase()
3802 .chars()
3803 .map(|c| if c.is_whitespace() { '-' } else { c })
3804 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == '_')
3805 .collect()
3806}
3807
3808fn check_assets(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
3814 use crate::assets;
3815
3816 let manifest_rel = Path::new(assets::MANIFEST_FILE);
3817 let mut manifest: BTreeMap<String, assets::AssetRecord> = BTreeMap::new();
3819 if store.regular_file_exists(manifest_rel).unwrap_or(false) {
3820 if let Ok(text) = store.read_text_bounded(manifest_rel, crate::parser::MAX_DBMD_FILE_BYTES)
3821 {
3822 for (i, line) in text.lines().enumerate() {
3823 if line.trim().is_empty() {
3824 continue;
3825 }
3826 match serde_json::from_str::<assets::AssetRecord>(line) {
3827 Ok(rec) => {
3828 manifest.insert(rec.path.clone(), rec);
3829 }
3830 Err(e) => push(
3831 issues,
3832 Severity::Error,
3833 codes::ASSET_MANIFEST_MALFORMED,
3834 manifest_rel,
3835 Some((i as u32) + 1),
3836 None,
3837 format!("invalid {} record: {e}", assets::MANIFEST_FILE),
3838 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3839 vec![],
3840 ),
3841 }
3842 }
3843 }
3844 }
3845
3846 let mut declared: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3850 let mut supersessions: BTreeMap<String, (String, PathBuf)> = BTreeMap::new();
3851 for (rel, p) in parsed {
3852 let Some(map) = &p.fm else {
3853 continue;
3854 };
3855 for decl in assets::declarations_from_yaml_map(map) {
3856 let norm = match assets::normalize_asset_path(&decl.path) {
3857 Ok(n) => n,
3858 Err(_) => continue, };
3860 declared.insert(norm.clone());
3861 if !manifest.contains_key(&norm) {
3862 push(
3863 issues,
3864 Severity::Error,
3865 codes::ASSET_UNDECLARED,
3866 rel,
3867 None,
3868 Some("asset".to_string()),
3869 format!(
3870 "references asset `{norm}` with no record in {}",
3871 assets::MANIFEST_FILE
3872 ),
3873 Some("run `dbmd assets scan` to catalog it".to_string()),
3874 vec![PathBuf::from(&norm)],
3875 );
3876 }
3877 }
3878 match assets::asset_supersession_from_yaml_map(map) {
3879 Ok(Some(supersession)) => {
3880 declared.insert(supersession.original.clone());
3881 let wrapper = rel.to_string_lossy().replace('\\', "/");
3882 if let Some((prior_replacement, prior_wrapper)) =
3883 supersessions.get(&supersession.original)
3884 {
3885 if prior_replacement != &supersession.replacement {
3886 push(
3887 issues,
3888 Severity::Error,
3889 codes::ASSET_SUPERSESSION_INVALID,
3890 rel,
3891 None,
3892 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3893 format!(
3894 "asset `{}` is superseded by both `{}` and `{}` ({})",
3895 supersession.original,
3896 prior_replacement,
3897 supersession.replacement,
3898 prior_wrapper.display()
3899 ),
3900 Some(
3901 "keep exactly one replacement for an asset coordinate".to_string(),
3902 ),
3903 vec![prior_wrapper.clone()],
3904 );
3905 }
3906 } else {
3907 supersessions.insert(
3908 supersession.original.clone(),
3909 (supersession.replacement.clone(), rel.clone()),
3910 );
3911 }
3912 match manifest.get(&supersession.original) {
3913 Some(record)
3914 if !record.required && record.wrappers.contains(&wrapper) => {}
3915 Some(_) => push(
3916 issues,
3917 Severity::Error,
3918 codes::ASSET_SUPERSESSION_INVALID,
3919 rel,
3920 None,
3921 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3922 format!(
3923 "superseded asset `{}` must remain cataloged as optional evidence under this wrapper",
3924 supersession.original
3925 ),
3926 Some(format!(
3927 "run `dbmd assets refresh {}` --wrapper {wrapper}",
3928 supersession.replacement
3929 )),
3930 vec![PathBuf::from(&supersession.original)],
3931 ),
3932 None => push(
3933 issues,
3934 Severity::Error,
3935 codes::ASSET_SUPERSESSION_INVALID,
3936 rel,
3937 None,
3938 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3939 format!(
3940 "superseded asset `{}` has no record in {}",
3941 supersession.original,
3942 assets::MANIFEST_FILE
3943 ),
3944 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3945 vec![PathBuf::from(&supersession.original)],
3946 ),
3947 }
3948 }
3949 Ok(None) => {}
3950 Err(error) => push(
3951 issues,
3952 Severity::Error,
3953 codes::ASSET_SUPERSESSION_INVALID,
3954 rel,
3955 None,
3956 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3957 error,
3958 Some(format!(
3959 "remove `{}` or declare exactly one required replacement asset",
3960 assets::SUPERSEDES_ASSET_KEY
3961 )),
3962 vec![],
3963 ),
3964 }
3965 }
3966
3967 let mut reported_cycle_members = BTreeSet::new();
3968 for origin in supersessions.keys() {
3969 let mut order: Vec<String> = Vec::new();
3970 let mut positions = BTreeMap::new();
3971 let mut current = origin.as_str();
3972 while let Some((next, _)) = supersessions.get(current) {
3973 if let Some(start) = positions.get(current).copied() {
3974 for member in &order[start..] {
3975 if reported_cycle_members.insert(member.clone()) {
3976 let (_, wrapper) = &supersessions[member];
3977 push(
3978 issues,
3979 Severity::Error,
3980 codes::ASSET_SUPERSESSION_INVALID,
3981 wrapper,
3982 None,
3983 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3984 format!("asset replacement cycle includes `{member}`"),
3985 Some("replace the cycle with a one-way provenance chain".to_string()),
3986 vec![],
3987 );
3988 }
3989 }
3990 break;
3991 }
3992 positions.insert(current.to_string(), order.len());
3993 order.push(current.to_string());
3994 current = next;
3995 }
3996 }
3997
3998 for (path, rec) in &manifest {
4000 for w in &rec.wrappers {
4001 if !store.regular_file_exists(Path::new(w)).unwrap_or(false) {
4002 push(
4003 issues,
4004 Severity::Error,
4005 codes::ASSET_WRAPPER_BROKEN,
4006 Path::new(path),
4007 None,
4008 None,
4009 format!("manifest record for `{path}` names a missing wrapper `{w}`"),
4010 Some("run `dbmd assets scan` to reconcile the manifest".to_string()),
4011 vec![PathBuf::from(w)],
4012 );
4013 }
4014 }
4015 if !declared.contains(path) {
4016 push(
4017 issues,
4018 Severity::Warning,
4019 codes::ASSET_MANIFEST_ORPHAN,
4020 Path::new(path),
4021 None,
4022 None,
4023 format!(
4024 "`{path}` is in {} but no wrapper references it",
4025 assets::MANIFEST_FILE
4026 ),
4027 Some("run `dbmd assets scan` to drop the orphan, or add a wrapper".to_string()),
4028 vec![],
4029 );
4030 }
4031 }
4032}
4033
4034#[allow(clippy::too_many_arguments)]
4036fn push(
4037 issues: &mut Vec<Issue>,
4038 severity: Severity,
4039 code: &'static str,
4040 file: &Path,
4041 line: Option<u32>,
4042 key: Option<String>,
4043 message: String,
4044 suggestion: Option<String>,
4045 related: Vec<PathBuf>,
4046) {
4047 issues.push(Issue {
4048 severity,
4049 code,
4050 file: file.to_path_buf(),
4051 line,
4052 key,
4053 message,
4054 suggestion,
4055 related,
4056 });
4057}
4058
4059fn fm_key_line(fm_yaml: &str, key: &str) -> Option<u32> {
4062 for (i, line) in fm_yaml.lines().enumerate() {
4063 let trimmed = line.trim_start();
4064 if let Some(rest) = trimmed.strip_prefix(key) {
4066 if rest.starts_with(':') && line.starts_with(key) {
4067 return Some((i as u32) + 2);
4069 }
4070 }
4071 }
4072 None
4073}
4074
4075fn fm_key_line_or_top(fm_yaml: &str, key: &str) -> Option<u32> {
4081 fm_key_line(fm_yaml, key).or(Some(1))
4082}
4083
4084fn issue_order(a: &Issue, b: &Issue) -> std::cmp::Ordering {
4087 a.file
4088 .cmp(&b.file)
4089 .then(a.line.cmp(&b.line))
4090 .then(a.code.cmp(b.code))
4091 .then(a.key.cmp(&b.key))
4092}
4093
4094#[cfg(test)]
4099mod tests {
4100 use super::*;
4101 use crate::parser::{Config, FieldSpec};
4102 use std::fs;
4103 use tempfile::TempDir;
4104
4105 #[test]
4106 fn split_frontmatter_tolerates_leading_bom() {
4107 let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody\n";
4112 let parsed = split_frontmatter(text);
4113 assert!(
4114 parsed.is_some(),
4115 "a leading BOM must not hide frontmatter from validate"
4116 );
4117 let (yaml, body, close_line) = parsed.unwrap();
4118 assert_eq!(yaml, "type: contact\nsummary: hi\n");
4119 assert_eq!(body, "body");
4120 assert_eq!(close_line, 4, "BOM is inline on line 1, not a new line");
4121 }
4122
4123 struct Fixture {
4126 dir: TempDir,
4127 config: Config,
4128 }
4129
4130 impl Fixture {
4131 fn new() -> Self {
4136 let dir = TempDir::new().unwrap();
4137 fs::write(
4138 dir.path().join("DB.md"),
4139 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
4140 )
4141 .unwrap();
4142 for layer in ["sources", "records"] {
4143 fs::create_dir_all(dir.path().join(layer)).unwrap();
4144 }
4145 Fixture {
4146 dir,
4147 config: Config::default(),
4148 }
4149 }
4150
4151 fn bare() -> Self {
4153 let dir = TempDir::new().unwrap();
4154 Fixture {
4155 dir,
4156 config: Config::default(),
4157 }
4158 }
4159
4160 fn write(&self, rel: &str, contents: &str) {
4162 let abs = self.dir.path().join(rel);
4163 fs::create_dir_all(abs.parent().unwrap()).unwrap();
4164 fs::write(abs, contents).unwrap();
4165 }
4166
4167 fn store(&self) -> Store {
4168 Store::from_root_and_config(self.dir.path(), self.config.clone()).unwrap()
4169 }
4170
4171 fn store_all(&self) -> Vec<Issue> {
4172 validate_all(&self.store()).unwrap()
4173 }
4174
4175 fn rebuild_indexes(&self) {
4182 crate::index::Index::rebuild_all(&self.store()).unwrap();
4183 }
4184 }
4185
4186 fn has(issues: &[Issue], code: &str) -> bool {
4188 issues.iter().any(|i| i.code == code)
4189 }
4190
4191 fn count(issues: &[Issue], code: &str) -> usize {
4193 issues.iter().filter(|i| i.code == code).count()
4194 }
4195
4196 fn find<'a>(issues: &'a [Issue], code: &str) -> &'a Issue {
4198 issues
4199 .iter()
4200 .find(|i| i.code == code)
4201 .unwrap_or_else(|| panic!("expected an issue with code {code}; got {issues:#?}"))
4202 }
4203
4204 fn valid_contact(summary: &str) -> String {
4206 format!(
4207 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"{summary}\"\nname: A\n---\n\n# A\n"
4208 )
4209 }
4210
4211 #[test]
4214 fn not_a_store_when_db_md_absent() {
4215 let fx = Fixture::bare();
4216 let issues = fx.store_all();
4217 assert_eq!(issues.len(), 1, "only NOT_A_STORE expected: {issues:#?}");
4218 assert_eq!(issues[0].code, codes::NOT_A_STORE);
4219 assert!(issues[0].is_error());
4220 }
4221
4222 #[test]
4223 fn working_set_also_reports_not_a_store() {
4224 let fx = Fixture::bare();
4225 let issues = validate_working_set(&fx.store(), None).unwrap();
4226 assert!(has(&issues, codes::NOT_A_STORE));
4227 }
4228
4229 #[test]
4230 fn both_scopes_report_nested_store_without_validating_its_content() {
4231 let fx = Fixture::new();
4232 fx.write(
4233 "records/nested/DB.md",
4234 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
4235 );
4236 fx.write("records/nested/records/notes/bad.md", "not frontmatter");
4239
4240 for issues in [
4241 validate_working_set(&fx.store(), None).unwrap(),
4242 validate_all(&fx.store()).unwrap(),
4243 ] {
4244 assert_eq!(count(&issues, codes::NESTED_STORE), 1, "{issues:#?}");
4245 assert_eq!(
4246 find(&issues, codes::NESTED_STORE).file,
4247 PathBuf::from("records/nested/DB.md")
4248 );
4249 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4250 }
4251 }
4252
4253 #[test]
4254 fn clean_store_has_no_issues() {
4255 let fx = Fixture::new();
4256 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4257 fx.rebuild_indexes();
4261 let issues = fx.store_all();
4262 assert!(
4263 issues.is_empty(),
4264 "expected a clean store, got: {issues:#?}"
4265 );
4266 }
4267
4268 #[test]
4276 fn meta_type_enum_is_closed_for_scalars_and_non_scalars() {
4277 let fx = Fixture::new();
4278 let body = |mt: &str| {
4279 format!(
4280 "---\ntype: profile\nmeta-type: {mt}\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n"
4281 )
4282 };
4283
4284 for ok in ["fact", "operational", "conclusion"] {
4286 fx.write("records/profiles/ok.md", &body(ok));
4287 let issues = validate_working_set(&fx.store(), None).unwrap();
4288 assert!(
4289 !has(&issues, codes::FM_BAD_META_TYPE),
4290 "`meta-type: {ok}` must be accepted; got {issues:#?}"
4291 );
4292 }
4293 fx.write(
4294 "records/profiles/absent.md",
4295 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n",
4296 );
4297 assert!(
4298 !has(
4299 &validate_working_set(&fx.store(), None).unwrap(),
4300 codes::FM_BAD_META_TYPE
4301 ),
4302 "an absent meta-type is the default `fact` and must be accepted"
4303 );
4304
4305 for bad in ["xyz", "Fact", "[fact, conclusion]", "{kind: conclusion}"] {
4307 let fx2 = Fixture::new();
4308 fx2.write("records/profiles/bad.md", &body(bad));
4309 let issues = validate_working_set(&fx2.store(), None).unwrap();
4310 assert!(
4311 has(&issues, codes::FM_BAD_META_TYPE),
4312 "`meta-type: {bad}` must be rejected with FM_BAD_META_TYPE; got {issues:#?}"
4313 );
4314 }
4315 }
4316
4317 #[test]
4326 fn id_absent_slug_ulid_and_numeric_are_all_silent() {
4327 let body = |id_line: &str| {
4328 format!(
4329 "---\ntype: contact\n{id_line}created: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n"
4330 )
4331 };
4332 for (case, id_line) in [
4333 ("absent", ""),
4334 ("slug", "id: sarah-chen\n"),
4335 ("ulid", "id: 01j5qc3v9k4ym8rwbn2tqe6f7d\n"),
4336 ("numeric-scalar", "id: 100\n"),
4337 ] {
4338 let fx = Fixture::new();
4339 fx.write("records/contacts/a.md", &body(id_line));
4340 let issues = validate_working_set(&fx.store(), None).unwrap();
4341 assert!(
4342 !has(&issues, codes::FM_BAD_ID),
4343 "id case `{case}` must be silent; got {issues:#?}"
4344 );
4345 }
4346 }
4347
4348 #[test]
4353 fn id_unusable_as_identifier_warns_fm_bad_id() {
4354 let body = |id_line: &str| {
4355 format!(
4356 "---\ntype: contact\n{id_line}\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n"
4357 )
4358 };
4359 for bad in [
4360 "id: \"\"",
4361 "id: \" \"",
4362 "id: two words",
4363 "id: [a, b]",
4364 "id: {k: v}",
4365 ] {
4366 let fx = Fixture::new();
4367 fx.write("records/contacts/a.md", &body(bad));
4368 let issues = validate_working_set(&fx.store(), None).unwrap();
4369 let issue = issues
4370 .iter()
4371 .find(|i| i.code == codes::FM_BAD_ID)
4372 .unwrap_or_else(|| panic!("`{bad}` must fire FM_BAD_ID; got {issues:#?}"));
4373 assert!(
4374 matches!(issue.severity, Severity::Warning),
4375 "FM_BAD_ID is a warning (additive v0.4 — it must never block a store): {issue:#?}"
4376 );
4377 assert_eq!(issue.key.as_deref(), Some("id"));
4378 assert!(
4379 !issue.is_error(),
4380 "FM_BAD_ID must not fail validation: {issue:#?}"
4381 );
4382 }
4383 }
4384
4385 #[test]
4389 fn dup_id_fires_on_shared_ulid_ids() {
4390 let fx = Fixture::new();
4391 let rec = |name: &str| {
4392 format!(
4393 "---\ntype: contact\nid: 01j5qc3v9k4ym8rwbn2tqe6f7d\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: {name}\nname: {name}\n---\n\n# {name}\n"
4394 )
4395 };
4396 fx.write("records/contacts/a.md", &rec("A"));
4397 fx.write("records/contacts/b.md", &rec("B"));
4398 let issues = fx.store_all();
4399 assert_eq!(count(&issues, codes::DUP_ID), 1, "{issues:#?}");
4400 let issue = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
4401 assert!(issue.is_error());
4402 assert!(!has(&issues, codes::FM_BAD_ID), "{issues:#?}");
4404 }
4405
4406 #[test]
4412 fn valid_db_md_emits_no_structure_issue() {
4413 let fx = Fixture::new();
4414 let issues = fx.store_all();
4415 assert!(
4416 !has(&issues, codes::DB_MD_BAD_TYPE)
4417 && !has(&issues, codes::DB_MD_MISSING_FIELD)
4418 && !has(&issues, codes::DB_MD_UNKNOWN_SECTION),
4419 "a valid DB.md (type: db-md + scope + owner, recognized sections) is silent: {issues:#?}"
4420 );
4421 }
4422
4423 #[test]
4427 fn db_md_wrong_type_is_error() {
4428 let fx = Fixture::new();
4429 fx.write("DB.md", "---\ntype: notes\nscope: company\nowner: T\n---\n");
4430 let issues = fx.store_all();
4431 let i = find(&issues, codes::DB_MD_BAD_TYPE);
4432 assert!(i.is_error());
4433 assert_eq!(i.file, PathBuf::from("DB.md"));
4434 assert_eq!(i.key.as_deref(), Some("type"));
4435 assert_eq!(i.line, Some(2), "anchors to the `type:` line");
4436 }
4437
4438 #[test]
4441 fn db_md_missing_scope_and_owner_each_report() {
4442 let fx = Fixture::new();
4443 fx.write("DB.md", "---\ntype: db-md\n---\n");
4444 let issues = fx.store_all();
4445 assert_eq!(
4446 count(&issues, codes::DB_MD_MISSING_FIELD),
4447 2,
4448 "both scope and owner absent → two issues: {issues:#?}"
4449 );
4450 let keys: BTreeSet<Option<String>> = issues
4451 .iter()
4452 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4453 .map(|i| i.key.clone())
4454 .collect();
4455 assert_eq!(
4456 keys,
4457 BTreeSet::from([Some("scope".to_string()), Some("owner".to_string())]),
4458 "one issue keyed on each missing field"
4459 );
4460 for i in issues
4461 .iter()
4462 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4463 {
4464 assert!(i.is_error());
4465 assert_eq!(i.line, Some(1), "absent field anchors to the block top");
4466 }
4467 }
4468
4469 #[test]
4473 fn db_md_blank_required_field_is_missing() {
4474 let fx = Fixture::new();
4475 fx.write(
4476 "DB.md",
4477 "---\ntype: db-md\nscope: company\nowner: \"\"\n---\n",
4478 );
4479 let issues = fx.store_all();
4480 let i = find(&issues, codes::DB_MD_MISSING_FIELD);
4481 assert_eq!(i.key.as_deref(), Some("owner"));
4482 assert_eq!(
4483 i.line,
4484 Some(4),
4485 "a present-but-empty field anchors to its line"
4486 );
4487 assert!(
4488 count(&issues, codes::DB_MD_MISSING_FIELD) == 1,
4489 "scope is present and non-empty → only owner reported"
4490 );
4491 }
4492
4493 #[test]
4496 fn db_md_unknown_section_is_warning() {
4497 let fx = Fixture::new();
4498 fx.write(
4499 "DB.md",
4500 "---\ntype: db-md\nscope: company\nowner: T\n---\n\n## Agent instructions\n\nbe good\n\n## Glossary\n\nterms\n",
4504 );
4505 let issues = fx.store_all();
4506 let i = find(&issues, codes::DB_MD_UNKNOWN_SECTION);
4507 assert!(!i.is_error(), "unknown section is a warning, not an error");
4508 assert_eq!(i.severity, Severity::Warning);
4509 assert_eq!(
4510 i.line,
4511 Some(11),
4512 "anchors to the `## Glossary` heading line"
4513 );
4514 assert!(
4515 i.message.contains("Glossary"),
4516 "the message names the offending section: {}",
4517 i.message
4518 );
4519 assert_eq!(
4521 count(&issues, codes::DB_MD_UNKNOWN_SECTION),
4522 1,
4523 "only the unrecognized section is flagged: {issues:#?}"
4524 );
4525 }
4526
4527 #[test]
4530 fn db_md_no_frontmatter_reports_type_and_both_fields() {
4531 let fx = Fixture::new();
4532 fx.write("DB.md", "# just a heading, no frontmatter\n");
4533 let issues = fx.store_all();
4534 assert!(has(&issues, codes::DB_MD_BAD_TYPE));
4535 assert_eq!(count(&issues, codes::DB_MD_MISSING_FIELD), 2);
4536 }
4537
4538 #[test]
4541 fn missing_type_is_error() {
4542 let fx = Fixture::new();
4543 fx.write(
4544 "records/contacts/a.md",
4545 "---\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\n# A\n",
4546 );
4547 let issues = fx.store_all();
4548 assert!(has(&issues, codes::FM_MISSING_TYPE));
4549 assert!(find(&issues, codes::FM_MISSING_TYPE).is_error());
4550 }
4551
4552 #[test]
4553 fn missing_universal_timestamps_are_errors_on_content_files() {
4554 let fx = Fixture::new();
4555 fx.write(
4556 "records/contacts/a.md",
4557 "---\ntype: contact\nsummary: x\nname: A\n---\n\n# A\n",
4558 );
4559 let issues = fx.store_all();
4560
4561 let missing_created = find(&issues, codes::FM_MISSING_CREATED);
4562 assert_eq!(missing_created.key.as_deref(), Some("created"));
4563 assert!(missing_created.is_error());
4564
4565 let missing_updated = find(&issues, codes::FM_MISSING_UPDATED);
4566 assert_eq!(missing_updated.key.as_deref(), Some("updated"));
4567 assert!(missing_updated.is_error());
4568 }
4569
4570 #[test]
4571 fn meta_files_do_not_require_universal_timestamps() {
4572 let fx = Fixture::new();
4573 let issues = fx.store_all();
4574
4575 assert!(
4576 !has(&issues, codes::FM_MISSING_CREATED),
4577 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4578 );
4579 assert!(
4580 !has(&issues, codes::FM_MISSING_UPDATED),
4581 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4582 );
4583 }
4584
4585 #[test]
4586 fn content_file_with_no_frontmatter_block_reports_type_and_summary() {
4587 let fx = Fixture::new();
4588 fx.write(
4589 "records/profiles/a.md",
4590 "# Just a heading\n\nNo frontmatter here.\n",
4591 );
4592 let issues = fx.store_all();
4593 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4594 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4595 }
4596
4597 #[test]
4598 fn content_file_with_empty_frontmatter_reports_type_and_summary() {
4599 let fx = Fixture::new();
4600 fx.write("records/profiles/a.md", "---\n---\n\nbody\n");
4601 let issues = fx.store_all();
4602 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4603 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4604 }
4605
4606 #[test]
4607 fn malformed_yaml_is_error_and_suppresses_field_checks() {
4608 let fx = Fixture::new();
4609 fx.write(
4611 "records/contacts/a.md",
4612 "---\ntype: contact\n bad: : : :\n: : nope\n---\n\nbody\n",
4613 );
4614 let issues = fx.store_all();
4615 let issue = find(&issues, codes::FM_MALFORMED_YAML);
4616 assert!(issue.is_error());
4617 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4618 assert!(
4621 !has(&issues, codes::SUMMARY_MISSING),
4622 "malformed YAML should suppress SUMMARY_MISSING: {issues:#?}"
4623 );
4624 }
4625
4626 #[test]
4627 fn bad_created_timestamp_is_error() {
4628 let fx = Fixture::new();
4629 fx.write(
4630 "records/contacts/a.md",
4631 "---\ntype: contact\ncreated: not-a-date\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
4632 );
4633 let issues = fx.store_all();
4634 let issue = find(&issues, codes::FM_BAD_TIMESTAMP);
4635 assert_eq!(issue.key.as_deref(), Some("created"));
4636 assert!(issue.is_error());
4637 }
4638
4639 #[test]
4640 fn date_only_created_is_rejected_but_type_date_field_accepted() {
4641 let fx = Fixture::new();
4642 fx.write(
4645 "records/contacts/a.md",
4646 "---\ntype: contact\ncreated: 2026-05-22\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\nlast_touch: 2026-05-22\n---\n\n# A\n",
4647 );
4648 let issues = fx.store_all();
4649 let created_issues: Vec<_> = issues
4650 .iter()
4651 .filter(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created"))
4652 .collect();
4653 assert_eq!(
4654 created_issues.len(),
4655 1,
4656 "date-only `created` must fail: {issues:#?}"
4657 );
4658 assert!(
4659 !issues.iter().any(
4660 |i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("last_touch")
4661 ),
4662 "date-only `last_touch` is valid: {issues:#?}"
4663 );
4664 }
4665
4666 #[test]
4669 fn summary_missing_empty_multiline_toolong() {
4670 let fx = Fixture::new();
4671 fx.write(
4672 "records/profiles/missing.md",
4673 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\n---\n\nbody\n",
4674 );
4675 fx.write(
4676 "records/profiles/empty.md",
4677 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \" \"\n---\n\nbody\n",
4678 );
4679 let long = "x".repeat(201);
4680 fx.write(
4681 "records/profiles/long.md",
4682 &format!("---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"{long}\"\n---\n\nbody\n"),
4683 );
4684 let issues = fx.store_all();
4685 assert!(has(&issues, codes::SUMMARY_MISSING));
4686 assert_eq!(
4687 find(&issues, codes::SUMMARY_MISSING).file,
4688 PathBuf::from("records/profiles/missing.md")
4689 );
4690 assert!(has(&issues, codes::SUMMARY_EMPTY));
4691 assert!(has(&issues, codes::SUMMARY_TOO_LONG));
4692 assert_eq!(
4693 find(&issues, codes::SUMMARY_TOO_LONG).severity,
4694 Severity::Warning
4695 );
4696 }
4697
4698 #[test]
4699 fn summary_multiline_via_yaml_block_scalar() {
4700 let fx = Fixture::new();
4701 fx.write(
4703 "records/profiles/a.md",
4704 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: |\n line one\n line two\n---\n\nbody\n",
4705 );
4706 let issues = fx.store_all();
4707 assert!(has(&issues, codes::SUMMARY_MULTILINE), "{issues:#?}");
4708 }
4709
4710 #[test]
4711 fn summary_exactly_200_chars_is_ok() {
4712 let fx = Fixture::new();
4713 let s = "y".repeat(200);
4714 fx.write(
4715 "records/profiles/a.md",
4716 &format!("---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"{s}\"\n---\n\nbody\n"),
4717 );
4718 let issues = fx.store_all();
4719 assert!(
4720 !has(&issues, codes::SUMMARY_TOO_LONG),
4721 "200 is the bound, inclusive: {issues:#?}"
4722 );
4723 }
4724
4725 #[test]
4726 fn meta_files_need_no_summary() {
4727 let fx = Fixture::new();
4728 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4731 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n# I\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
4732 fx.write(
4733 "records/index.md",
4734 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
4735 );
4736 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — A contact\n");
4737 fx.write(
4738 "records/contacts/index.jsonl",
4739 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"A contact\"}\n",
4740 );
4741 fx.write("log.md", "---\ntype: log\n---\n\n# Log\n");
4742 let issues = fx.store_all();
4743 assert!(!has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4744 }
4745
4746 #[test]
4749 fn nested_tags_warns_flat_tags_ok() {
4750 let fx = Fixture::new();
4751 fx.write(
4752 "records/contacts/nested.md",
4753 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ntags:\n - good\n - [nested, list]\n---\n\n# A\n",
4754 );
4755 fx.write(
4756 "records/contacts/flat.md",
4757 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ntags: [customer, vip]\n---\n\n# A\n",
4758 );
4759 let issues = fx.store_all();
4760 let tag_issues: Vec<_> = issues
4761 .iter()
4762 .filter(|i| i.code == codes::TAGS_MALFORMED)
4763 .collect();
4764 assert_eq!(
4765 tag_issues.len(),
4766 1,
4767 "only the nested-tags file should warn: {issues:#?}"
4768 );
4769 assert_eq!(
4770 tag_issues[0].file,
4771 PathBuf::from("records/contacts/nested.md")
4772 );
4773 assert_eq!(tag_issues[0].severity, Severity::Warning);
4774 }
4775
4776 #[test]
4779 fn short_form_wiki_link_is_error() {
4780 let fx = Fixture::new();
4781 let mut body = valid_contact("links to a short form");
4782 body.push_str("\nSee [[sarah-chen]] for details.\n");
4783 fx.write("records/contacts/a.md", &body);
4784 let issues = fx.store_all();
4785 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4786 assert!(issue.is_error());
4787 assert!(issue.message.contains("sarah-chen"));
4788 assert!(
4790 !issues
4791 .iter()
4792 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("sarah-chen")),
4793 "short-form should suppress broken: {issues:#?}"
4794 );
4795 }
4796
4797 #[test]
4798 fn broken_full_path_wiki_link_is_error() {
4799 let fx = Fixture::new();
4800 let mut body = valid_contact("links to a missing file");
4801 body.push_str("\nSee [[records/contacts/ghost]].\n");
4802 fx.write("records/contacts/a.md", &body);
4803 let issues = fx.store_all();
4804 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4805 assert!(issue.is_error());
4806 assert!(issue.message.contains("records/contacts/ghost"));
4807 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4808 }
4809
4810 #[test]
4811 fn traversal_full_path_wiki_link_is_rejected_before_probe() {
4812 let fx = Fixture::new();
4813 let mut body = valid_contact("links with traversal");
4814 body.push_str("\nSee [[records/contacts/../../ghost]].\n");
4815 fx.write("records/contacts/a.md", &body);
4816 let issues = fx.store_all();
4817 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4818 assert!(issue.message.contains("not a safe store-relative path"));
4819 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4820 }
4821
4822 #[test]
4823 fn valid_full_path_wiki_link_passes() {
4824 let fx = Fixture::new();
4825 fx.write("records/contacts/target.md", &valid_contact("target"));
4826 let mut body = valid_contact("links to target");
4827 body.push_str("\nSee [[records/contacts/target]].\n");
4828 fx.write("records/contacts/a.md", &body);
4829 let issues = fx.store_all();
4830 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4831 assert!(!has(&issues, codes::WIKI_LINK_SHORT_FORM), "{issues:#?}");
4832 }
4833
4834 #[test]
4835 fn md_extension_wiki_link_warns_and_resolves() {
4836 let fx = Fixture::new();
4837 fx.write("records/contacts/target.md", &valid_contact("target"));
4838 let mut body = valid_contact("links with extension");
4839 body.push_str("\nSee [[records/contacts/target.md]].\n");
4840 fx.write("records/contacts/a.md", &body);
4841 let issues = fx.store_all();
4842 let issue = find(&issues, codes::WIKI_LINK_HAS_EXTENSION);
4843 assert_eq!(issue.severity, Severity::Warning);
4844 assert_eq!(
4845 issue.suggestion.as_deref(),
4846 Some("drop the extension: [[records/contacts/target]]")
4847 );
4848 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4850 }
4851
4852 #[test]
4853 fn wiki_links_in_code_fences_are_ignored() {
4854 let fx = Fixture::new();
4855 let mut body = valid_contact("has a fenced example");
4856 body.push_str("\n```\n[[sarah-chen]]\n```\n");
4857 fx.write("records/contacts/a.md", &body);
4858 let issues = fx.store_all();
4859 assert!(
4860 !has(&issues, codes::WIKI_LINK_SHORT_FORM),
4861 "fenced wiki-links must be ignored: {issues:#?}"
4862 );
4863 }
4864
4865 #[test]
4866 fn flow_form_link_list_in_frontmatter_is_error() {
4867 let fx = Fixture::new();
4868 fx.write(
4869 "records/meetings/m.md",
4870 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a meeting\ndate: 2026-05-22\nattendees: [[[records/contacts/a]], [[records/contacts/b]]]\n---\n\n# M\n",
4871 );
4872 let issues = fx.store_all();
4873 let issue = find(&issues, codes::WIKI_LINK_FLOW_FORM_LIST);
4874 assert!(issue.is_error());
4875 assert_eq!(issue.key.as_deref(), Some("attendees"));
4876 }
4877
4878 #[test]
4879 fn block_form_link_list_in_frontmatter_is_not_flow_form() {
4880 let fx = Fixture::new();
4881 fx.write("records/contacts/a.md", &valid_contact("a"));
4882 fx.write("records/contacts/b.md", &valid_contact("b"));
4883 fx.write(
4884 "records/meetings/m.md",
4885 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a meeting\ndate: 2026-05-22\nattendees:\n - [[records/contacts/a]]\n - [[records/contacts/b]]\n---\n\n# M\n",
4886 );
4887 let issues = fx.store_all();
4888 assert!(
4889 !has(&issues, codes::WIKI_LINK_FLOW_FORM_LIST),
4890 "{issues:#?}"
4891 );
4892 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4894 }
4895
4896 #[test]
4897 fn frontmatter_short_form_link_field_is_error() {
4898 let fx = Fixture::new();
4899 fx.write(
4902 "records/synthesis/a.md",
4903 "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nrelated: \"[[sarah-chen]]\"\n---\n\n# A\n",
4904 );
4905 let issues = fx.store_all();
4906 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4907 assert!(issue.is_error());
4908 assert_eq!(issue.key.as_deref(), Some("related"));
4909 }
4910
4911 #[test]
4912 fn unquoted_frontmatter_link_is_recognized() {
4913 let fx = Fixture::new();
4918 fx.write(
4919 "records/synthesis/short.md",
4920 "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nrelated: [[sarah-chen]]\n---\n\n# A\n",
4921 );
4922 fx.write(
4923 "records/synthesis/broken.md",
4924 "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nrelated: [[records/contacts/ghost]]\n---\n\n# A\n",
4925 );
4926 let issues = fx.store_all();
4927 assert!(
4928 issues.iter().any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4929 && i.file == Path::new("records/synthesis/short.md")
4930 && i.key.as_deref() == Some("related")),
4931 "unquoted short-form frontmatter link must be caught: {issues:#?}"
4932 );
4933 assert!(
4934 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
4935 && i.file == Path::new("records/synthesis/broken.md")),
4936 "unquoted full-path frontmatter link to a missing file must be caught: {issues:#?}"
4937 );
4938 }
4939
4940 #[test]
4941 fn short_form_in_declared_link_field_is_prefix_mismatch_not_double_reported() {
4942 let mut fx = Fixture::new();
4947 fx.config.schemas.insert(
4948 "contact".into(),
4949 Schema {
4950 fields: vec![FieldSpec {
4951 name: "company".into(),
4952 link_prefix: Some(PathBuf::from("records/companies")),
4953 ..Default::default()
4954 }],
4955 ..Default::default()
4956 },
4957 );
4958 fx.write(
4959 "records/contacts/a.md",
4960 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ncompany: \"[[northstar]]\"\n---\n\n# A\n",
4961 );
4962 let issues = fx.store_all();
4963 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
4964 assert_eq!(issue.key.as_deref(), Some("company"));
4965 assert!(
4967 !issues
4968 .iter()
4969 .any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4970 && i.key.as_deref() == Some("company")),
4971 "schema link fields are checked once, by the schema path: {issues:#?}"
4972 );
4973 }
4974
4975 #[test]
4976 fn schema_link_field_with_md_extension_still_warns() {
4977 let mut fx = Fixture::new();
4978 fx.config.schemas.insert(
4979 "contact".into(),
4980 Schema {
4981 fields: vec![FieldSpec {
4982 name: "company".into(),
4983 link_prefix: Some(PathBuf::from("records/companies")),
4984 ..Default::default()
4985 }],
4986 ..Default::default()
4987 },
4988 );
4989 fx.write(
4990 "records/companies/acme.md",
4991 "---\ntype: company\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: Acme\nname: Acme\n---\n\n# Acme\n",
4992 );
4993 fx.write(
4994 "records/contacts/a.md",
4995 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ncompany: \"[[records/companies/acme.md]]\"\n---\n\n# A\n",
4996 );
4997 let issues = fx.store_all();
4998 let issue = issues
4999 .iter()
5000 .find(|i| {
5001 i.code == codes::WIKI_LINK_HAS_EXTENSION && i.key.as_deref() == Some("company")
5002 })
5003 .unwrap_or_else(|| panic!("schema link extension warning missing: {issues:#?}"));
5004 assert_eq!(issue.severity, Severity::Warning);
5005 assert!(
5006 !issues
5007 .iter()
5008 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.key.as_deref() == Some("company")),
5009 "extensionless existence check should still find acme.md: {issues:#?}"
5010 );
5011 }
5012
5013 #[test]
5016 fn explicit_schema_required_shape_enum() {
5017 let fx = {
5018 let mut fx = Fixture::new();
5019 let schema = Schema {
5022 fields: vec![
5023 FieldSpec {
5024 name: "name".into(),
5025 required: true,
5026 ..Default::default()
5027 },
5028 FieldSpec {
5029 name: "email".into(),
5030 required: true,
5031 shape: Some(Shape::Email),
5032 ..Default::default()
5033 },
5034 FieldSpec {
5035 name: "status".into(),
5036 enum_values: Some(vec!["active".into(), "inactive".into()]),
5037 ..Default::default()
5038 },
5039 ],
5040 ..Default::default()
5041 };
5042 fx.config.schemas.insert("contact".into(), schema);
5043 fx
5044 };
5045 fx.write(
5046 "records/contacts/a.md",
5047 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nemail: not-an-email\nstatus: archived\n---\n\n# A\n",
5048 );
5049 let issues = fx.store_all();
5050 assert!(
5052 issues
5053 .iter()
5054 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
5055 && i.key.as_deref() == Some("name")),
5056 "{issues:#?}"
5057 );
5058 assert!(
5060 issues.iter().any(
5061 |i| i.code == codes::SCHEMA_SHAPE_MISMATCH && i.key.as_deref() == Some("email")
5062 ),
5063 "{issues:#?}"
5064 );
5065 assert!(
5067 issues
5068 .iter()
5069 .any(|i| i.code == codes::SCHEMA_ENUM_VIOLATION
5070 && i.key.as_deref() == Some("status")),
5071 "{issues:#?}"
5072 );
5073 }
5074
5075 #[test]
5076 fn schema_without_link_field_allows_plain_value() {
5077 let mut fx = Fixture::new();
5081 fx.config.schemas.insert(
5082 "contact".into(),
5083 Schema {
5084 fields: vec![FieldSpec {
5085 name: "name".into(),
5086 required: true,
5087 ..Default::default()
5088 }],
5089 ..Default::default()
5090 },
5091 );
5092 fx.write(
5093 "records/contacts/a.md",
5094 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: Sarah\ncompany: \"Acme Co\"\n---\n\n# Sarah\n",
5095 );
5096 let issues = fx.store_all();
5097 assert!(
5098 !has(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH),
5099 "no declared link field for `company` → a plain value is fine: {issues:#?}"
5100 );
5101 }
5102
5103 #[test]
5104 fn schema_link_field_plain_value_is_prefix_mismatch() {
5105 let mut fx = Fixture::new();
5108 fx.config.schemas.insert(
5109 "contact".into(),
5110 Schema {
5111 fields: vec![FieldSpec {
5112 name: "company".into(),
5113 link_prefix: Some(PathBuf::from("records/companies")),
5114 ..Default::default()
5115 }],
5116 ..Default::default()
5117 },
5118 );
5119 fx.write(
5120 "records/contacts/a.md",
5121 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: Sarah\ncompany: \"Acme Co\"\n---\n\n# Sarah\n",
5122 );
5123 let issues = fx.store_all();
5124 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
5125 assert_eq!(issue.key.as_deref(), Some("company"));
5126 assert!(issue
5127 .suggestion
5128 .as_deref()
5129 .unwrap()
5130 .contains("records/companies/"));
5131 }
5132
5133 #[test]
5134 fn schema_shape_int_and_url_and_currency() {
5135 let mut fx = Fixture::new();
5136 fx.config.schemas.insert(
5137 "widget".into(),
5138 Schema {
5139 fields: vec![
5140 FieldSpec {
5141 name: "qty".into(),
5142 shape: Some(Shape::Int),
5143 ..Default::default()
5144 },
5145 FieldSpec {
5146 name: "site".into(),
5147 shape: Some(Shape::Url),
5148 ..Default::default()
5149 },
5150 FieldSpec {
5151 name: "price".into(),
5152 shape: Some(Shape::Currency),
5153 ..Default::default()
5154 },
5155 ],
5156 ..Default::default()
5157 },
5158 );
5159 fx.write(
5162 "records/widgets/ok.md",
5163 "---\ntype: widget\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: ok\nqty: 5\nsite: https://example.com\nprice: \"USD 1,234.50\"\n---\n\n# ok\n",
5164 );
5165 fx.write(
5169 "records/widgets/bad.md",
5170 "---\ntype: widget\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: bad\nqty: five\nsite: ftp://nope\nprice: inf\n---\n\n# bad\n",
5171 );
5172 let issues = fx.store_all();
5173 let bad_shape: Vec<_> = issues
5174 .iter()
5175 .filter(|i| {
5176 i.code == codes::SCHEMA_SHAPE_MISMATCH
5177 && i.file == Path::new("records/widgets/bad.md")
5178 })
5179 .map(|i| i.key.clone().unwrap_or_default())
5180 .collect();
5181 assert!(bad_shape.contains(&"qty".to_string()), "{issues:#?}");
5182 assert!(bad_shape.contains(&"site".to_string()), "{issues:#?}");
5183 assert!(
5184 bad_shape.contains(&"price".to_string()),
5185 "inf must be rejected as currency: {issues:#?}"
5186 );
5187 assert!(
5188 !issues.iter().any(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH
5189 && i.file == Path::new("records/widgets/ok.md")),
5190 "valid shapes (incl. `USD 1,234.50`) must not fire: {issues:#?}"
5191 );
5192 }
5193
5194 #[test]
5195 fn schema_shape_or_enum_field_with_non_scalar_value_is_shape_mismatch() {
5196 let mut fx = Fixture::new();
5197 fx.config.schemas.insert(
5198 "contact".into(),
5199 Schema {
5200 fields: vec![
5201 FieldSpec {
5202 name: "email".into(),
5203 required: true,
5204 shape: Some(Shape::Email),
5205 ..Default::default()
5206 },
5207 FieldSpec {
5208 name: "status".into(),
5209 enum_values: Some(vec!["active".into(), "inactive".into()]),
5210 ..Default::default()
5211 },
5212 ],
5213 ..Default::default()
5214 },
5215 );
5216 fx.write(
5220 "records/contacts/bad.md",
5221 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: bad\nemail:\n - a@b.com\n - c@d.com\nstatus:\n - active\n---\n\n# bad\n",
5222 );
5223 let issues = fx.store_all();
5224 let mismatched: Vec<_> = issues
5225 .iter()
5226 .filter(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH)
5227 .map(|i| i.key.clone().unwrap_or_default())
5228 .collect();
5229 assert!(
5230 mismatched.contains(&"email".to_string()),
5231 "list-valued required email must flag: {issues:#?}"
5232 );
5233 assert!(
5234 mismatched.contains(&"status".to_string()),
5235 "list-valued enum must flag: {issues:#?}"
5236 );
5237 }
5238
5239 #[test]
5240 fn is_currency_accepts_codes_and_rejects_non_numeric() {
5241 for ok in [
5243 "100",
5244 "1234.56",
5245 "$1,234.50",
5246 "USD 100", "usd 100", "EUR 9.50",
5249 "£12",
5250 "¥1000",
5251 "-5.00", "+5",
5253 "1,000,000",
5254 ] {
5255 assert!(is_currency(ok), "expected currency: {ok:?}");
5256 }
5257 for bad in [
5260 "inf", "-inf", "infinity", "NaN", "nan", "12.999", "1.2345", "USD", "$", "free", "", " ", "1e3", "1.", ".5", "1 000", "USDD 100", ] {
5271 assert!(!is_currency(bad), "expected NOT currency: {bad:?}");
5272 }
5273 }
5274
5275 #[test]
5278 fn ignored_type_present_is_info() {
5279 let mut fx = Fixture::new();
5280 fx.config.ignored_types.push("temp".into());
5281 fx.write(
5282 "records/temps/x.md",
5283 "---\ntype: temp\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a temp\n---\n\n# x\n",
5284 );
5285 let issues = fx.store_all();
5286 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_PRESENT);
5287 assert_eq!(issue.severity, Severity::Info);
5288 assert!(!issue.is_error());
5289 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5290 }
5291
5292 #[test]
5293 fn conclusion_record_derived_from_ignored_type_warns() {
5294 let mut fx = Fixture::new();
5295 fx.config.ignored_types.push("temp".into());
5296 fx.write(
5297 "records/temps/x.md",
5298 "---\ntype: temp\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a temp\n---\n\n# x\n",
5299 );
5300 fx.write(
5304 "records/synthesis/t.md",
5305 "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: derived\nderived_from: \"[[records/temps/x]]\"\n---\n\n# t\n",
5306 );
5307 let issues = fx.store_all();
5308 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_DERIVED);
5309 assert_eq!(issue.severity, Severity::Warning);
5310 assert_eq!(issue.key.as_deref(), Some("derived_from"));
5311 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5312 }
5313
5314 #[test]
5322 fn derived_from_ignored_type_is_the_shared_policy_decision() {
5323 let mut fx = Fixture::new();
5324 fx.config.ignored_types.push("secret".into());
5325 fx.write(
5327 "records/secrets/s.md",
5328 "---\ntype: secret\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: hush\n---\n\n# s\n",
5329 );
5330 fx.write(
5332 "records/contacts/c.md",
5333 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: ok\nname: C\n---\n\n# c\n",
5334 );
5335 let store = fx.store();
5336
5337 let hit =
5341 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s"))
5342 .expect("conclusion → ignored-type record must match");
5343 assert_eq!(hit.target, "records/secrets/s");
5344 assert_eq!(hit.target_type, "secret");
5345
5346 assert_eq!(
5349 derived_from_ignored_type(&store, "fact", std::iter::once("records/secrets/s")),
5350 None,
5351 "only conclusion derivation is policed"
5352 );
5353
5354 assert_eq!(
5356 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/contacts/c")),
5357 None,
5358 "deriving from a non-ignored type is allowed"
5359 );
5360
5361 let hit = derived_from_ignored_type(
5363 &store,
5364 "conclusion",
5365 ["records/contacts/c", "records/secrets/s"],
5366 )
5367 .expect("a later ignored-type target must still be found");
5368 assert_eq!(hit.target, "records/secrets/s");
5369
5370 fx.config.ignored_types.clear();
5372 let store = fx.store();
5373 assert_eq!(
5374 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s")),
5375 None,
5376 "an empty ignored-types policy short-circuits"
5377 );
5378 }
5379
5380 #[test]
5383 fn dup_id_is_hard_error_with_related() {
5384 let fx = Fixture::new();
5385 fx.write(
5386 "records/contacts/a.md",
5387 "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a\nname: A\n---\n\n# A\n",
5388 );
5389 fx.write(
5390 "records/contacts/b.md",
5391 "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: b\nname: B\n---\n\n# B\n",
5392 );
5393 let issues = fx.store_all();
5394 assert_eq!(
5397 count(&issues, codes::DUP_ID),
5398 1,
5399 "one issue per group: {issues:#?}"
5400 );
5401 let a = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
5402 assert_eq!(a.file, PathBuf::from("records/contacts/a.md"));
5403 assert!(a.is_error());
5404 assert_eq!(a.key.as_deref(), Some("id"));
5405 assert_eq!(
5406 a.line,
5407 Some(3),
5408 "anchors to the `id` line on the reported file"
5409 );
5410 assert_eq!(a.related, vec![PathBuf::from("records/contacts/b.md")]);
5411 }
5412
5413 #[test]
5414 fn dup_id_not_fired_in_working_set() {
5415 let fx = Fixture::new();
5417 fx.write(
5418 "records/contacts/a.md",
5419 "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a\nname: A\n---\n\n# A\n",
5420 );
5421 fx.write(
5422 "records/contacts/b.md",
5423 "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: b\nname: B\n---\n\n# B\n",
5424 );
5425 fx.write(
5427 "log.md",
5428 "---\ntype: log\n---\n\n## [2026-05-22 10:00] create | records/contacts/a\nx\n\n## [2026-05-22 10:01] create | records/contacts/b\nx\n",
5429 );
5430 let issues = validate_working_set(&fx.store(), None).unwrap();
5431 assert!(
5432 !has(&issues, codes::DUP_ID),
5433 "DUP_ID is --all only: {issues:#?}"
5434 );
5435 }
5436
5437 #[test]
5438 fn dup_unique_key_single_field_is_warning() {
5439 let mut fx = Fixture::new();
5440 fx.config.schemas.insert(
5442 "contact".into(),
5443 Schema {
5444 unique_keys: vec![vec!["email".into()]],
5445 ..Default::default()
5446 },
5447 );
5448 for (f, name) in [("a", "A"), ("b", "B")] {
5449 fx.write(
5450 &format!("records/contacts/{f}.md"),
5451 &format!("---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: s\nname: {name}\nemail: dup@x.com\n---\n\n# {name}\n"),
5452 );
5453 }
5454 let issues = fx.store_all();
5455 assert_eq!(count(&issues, codes::DUP_UNIQUE_KEY), 1);
5458 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5459 assert_eq!(dup.severity, Severity::Warning);
5460 assert_eq!(dup.file, PathBuf::from("records/contacts/a.md"));
5461 assert_eq!(dup.key.as_deref(), Some("email"));
5462 assert_eq!(dup.related, vec![PathBuf::from("records/contacts/b.md")]);
5463 }
5464
5465 #[test]
5466 fn dup_unique_key_compound_and_clean_when_one_field_differs() {
5467 let mut fx = Fixture::new();
5468 fx.config.schemas.insert(
5470 "expense".into(),
5471 Schema {
5472 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
5473 ..Default::default()
5474 },
5475 );
5476 fx.write("records/companies/acme.md", "---\ntype: company\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: c\nname: Acme\n---\n# A\n");
5477 let exp = |f: &str, amount: &str| {
5478 format!(
5479 "---\ntype: expense\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: e\ndate: 2026-05-01\namount: {amount}\nvendor: \"[[records/companies/acme]]\"\n---\n\n# {f}\n"
5480 )
5481 };
5482 fx.write("records/expenses/e1.md", &exp("e1", "100"));
5483 fx.write("records/expenses/e2.md", &exp("e2", "100"));
5484 fx.write("records/expenses/e3.md", &exp("e3", "200")); let issues = fx.store_all();
5486 assert_eq!(
5489 count(&issues, codes::DUP_UNIQUE_KEY),
5490 1,
5491 "only e1+e2 collide, one issue: {issues:#?}"
5492 );
5493 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5494 assert_eq!(dup.file, PathBuf::from("records/expenses/e1.md"));
5495 assert_eq!(
5496 dup.line,
5497 Some(1),
5498 "compound-key collision anchors to line 1"
5499 );
5500 assert_eq!(dup.related, vec![PathBuf::from("records/expenses/e2.md")]);
5501 assert!(
5502 !issues.iter().any(|i| i.code == codes::DUP_UNIQUE_KEY
5503 && i.related.contains(&PathBuf::from("records/expenses/e3.md"))),
5504 "e3 differs on amount and must not collide: {issues:#?}"
5505 );
5506 }
5507
5508 #[test]
5509 fn dup_unique_key_list_field_is_order_independent() {
5510 let mut fx = Fixture::new();
5511 fx.config.schemas.insert(
5513 "meeting".into(),
5514 Schema {
5515 unique_keys: vec![vec!["date".into(), "attendees".into()]],
5516 ..Default::default()
5517 },
5518 );
5519 fx.write("records/contacts/a.md", &valid_contact("a"));
5520 fx.write("records/contacts/b.md", &valid_contact("b"));
5521 let m = |f: &str, order: &str| {
5522 let attendees = if order == "ab" {
5523 " - [[records/contacts/a]]\n - [[records/contacts/b]]"
5524 } else {
5525 " - [[records/contacts/b]]\n - [[records/contacts/a]]"
5526 };
5527 format!(
5528 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: m\ndate: 2026-05-01\nattendees:\n{attendees}\n---\n\n# {f}\n"
5529 )
5530 };
5531 fx.write("records/meetings/m1.md", &m("m1", "ab"));
5532 fx.write("records/meetings/m2.md", &m("m2", "ba"));
5533 let issues = fx.store_all();
5534 assert_eq!(
5537 count(&issues, codes::DUP_UNIQUE_KEY),
5538 1,
5539 "same date + same attendee set (any order) collide as one issue: {issues:#?}"
5540 );
5541 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5542 assert_eq!(dup.file, PathBuf::from("records/meetings/m1.md"));
5543 assert_eq!(dup.related, vec![PathBuf::from("records/meetings/m2.md")]);
5544 }
5545
5546 #[test]
5549 fn missing_indexes_at_all_three_levels() {
5550 let fx = Fixture::new();
5551 fx.write("records/contacts/a.md", &valid_contact("a"));
5552 let issues = fx.store_all();
5553 let missing_files: BTreeSet<PathBuf> = issues
5557 .iter()
5558 .filter(|i| i.code == codes::INDEX_MISSING)
5559 .map(|i| i.file.clone())
5560 .collect();
5561 assert!(
5562 missing_files.contains(&PathBuf::from("index.md")),
5563 "{issues:#?}"
5564 );
5565 assert!(
5566 missing_files.contains(&PathBuf::from("records/index.md")),
5567 "{issues:#?}"
5568 );
5569 assert!(
5570 missing_files.contains(&PathBuf::from("records/contacts")),
5571 "{issues:#?}"
5572 );
5573 assert!(!has(&issues, codes::INDEX_JSONL_MISSING), "{issues:#?}");
5576 }
5577
5578 #[test]
5579 fn index_stale_entry_and_missing_entry() {
5580 let fx = Fixture::new();
5581 fx.write(
5582 "records/contacts/present.md",
5583 &valid_contact("present contact"),
5584 );
5585 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5587 fx.write(
5588 "records/index.md",
5589 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5590 );
5591 fx.write(
5593 "records/contacts/index.md",
5594 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/ghost]] — gone\n",
5595 );
5596 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/present.md\",\"type\":\"contact\",\"summary\":\"present contact\"}\n");
5597 let issues = fx.store_all();
5598 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5599 assert!(stale.message.contains("ghost"));
5600 assert!(stale.is_error());
5601 let missing = find(&issues, codes::INDEX_MISSING_ENTRY);
5602 assert!(
5603 missing.message.contains("present.md"),
5604 "{}",
5605 missing.message
5606 );
5607 }
5608
5609 #[test]
5610 fn index_md_entry_with_traversal_path_is_stale_not_probe() {
5611 let fx = Fixture::new();
5612 fx.write("records/contacts/a.md", &valid_contact("a"));
5613 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5614 fx.write(
5615 "records/index.md",
5616 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5617 );
5618 fx.write(
5619 "records/contacts/index.md",
5620 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/../../ghost]] — unsafe\n",
5621 );
5622 fx.write(
5623 "records/contacts/index.jsonl",
5624 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5625 );
5626 let issues = fx.store_all();
5627 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5628 assert!(stale.message.contains("not a safe store-relative path"));
5629 }
5630
5631 #[test]
5632 fn index_summary_mismatch() {
5633 let fx = Fixture::new();
5634 fx.write("records/contacts/a.md", &valid_contact("the real summary"));
5635 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5636 fx.write(
5637 "records/index.md",
5638 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5639 );
5640 fx.write(
5641 "records/contacts/index.md",
5642 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a STALE summary\n",
5643 );
5644 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"the real summary\"}\n");
5645 let issues = fx.store_all();
5646 let issue = find(&issues, codes::INDEX_SUMMARY_MISMATCH);
5647 assert!(issue.is_error());
5648 assert_eq!(issue.related, vec![PathBuf::from("records/contacts/a.md")]);
5649 }
5650
5651 #[test]
5652 fn index_summary_match_passes() {
5653 let fx = Fixture::new();
5654 fx.write("records/contacts/a.md", &valid_contact("matching summary"));
5655 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5656 fx.write(
5657 "records/index.md",
5658 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5659 );
5660 fx.write(
5661 "records/contacts/index.md",
5662 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — matching summary\n",
5663 );
5664 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"matching summary\"}\n");
5665 let issues = fx.store_all();
5666 assert!(!has(&issues, codes::INDEX_SUMMARY_MISMATCH), "{issues:#?}");
5667 }
5668
5669 #[test]
5670 fn index_entry_with_tag_suffix_matches_summary() {
5671 let fx = Fixture::new();
5672 fx.write("records/contacts/a.md", &valid_contact("clean summary"));
5673 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5674 fx.write(
5675 "records/index.md",
5676 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5677 );
5678 fx.write(
5682 "records/contacts/index.md",
5683 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — clean summary · #customer\n",
5684 );
5685 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"clean summary\"}\n");
5686 let issues = fx.store_all();
5687 assert!(
5688 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5689 "tag suffix should be stripped: {issues:#?}"
5690 );
5691 }
5692
5693 #[test]
5694 fn index_entry_single_spaced_middot_tail_is_part_of_summary() {
5695 let fx = Fixture::new();
5702 fx.write(
5703 "records/contacts/a.md",
5704 &valid_contact("Standup notes · #standup"),
5705 );
5706 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5707 fx.write(
5708 "records/index.md",
5709 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5710 );
5711 fx.write(
5712 "records/contacts/index.md",
5713 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — Standup notes · #standup\n",
5714 );
5715 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"Standup notes · #standup\"}\n");
5716 let issues = fx.store_all();
5717 assert!(
5718 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5719 "a single-spaced middot tail is part of the summary, not a tag block: {issues:#?}"
5720 );
5721 }
5722
5723 #[test]
5724 fn index_jsonl_desync_missing_file_in_jsonl() {
5725 let fx = Fixture::new();
5726 fx.write("records/contacts/a.md", &valid_contact("a"));
5727 fx.write("records/contacts/b.md", &valid_contact("b"));
5728 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (2 files)\n");
5729 fx.write(
5730 "records/index.md",
5731 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5732 );
5733 fx.write(
5734 "records/contacts/index.md",
5735 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n- [[records/contacts/b]] — b\n",
5736 );
5737 fx.write(
5739 "records/contacts/index.jsonl",
5740 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5741 );
5742 let issues = fx.store_all();
5743 let desync = find(&issues, codes::INDEX_JSONL_DESYNC);
5744 assert!(desync.message.contains("b.md"), "{}", desync.message);
5745 }
5746
5747 #[test]
5748 fn index_jsonl_desync_record_points_at_missing_file() {
5749 let fx = Fixture::new();
5750 fx.write("records/contacts/a.md", &valid_contact("a"));
5751 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5752 fx.write(
5753 "records/index.md",
5754 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5755 );
5756 fx.write(
5757 "records/contacts/index.md",
5758 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5759 );
5760 fx.write(
5761 "records/contacts/index.jsonl",
5762 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5763 );
5764 let issues = fx.store_all();
5765 assert!(
5766 issues
5767 .iter()
5768 .any(|i| i.code == codes::INDEX_JSONL_DESYNC && i.message.contains("ghost.md")),
5769 "{issues:#?}"
5770 );
5771 }
5772
5773 #[test]
5774 fn index_jsonl_record_with_traversal_path_is_desync_not_probe() {
5775 let fx = Fixture::new();
5776 fx.write("records/contacts/a.md", &valid_contact("a"));
5777 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5778 fx.write(
5779 "records/index.md",
5780 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5781 );
5782 fx.write(
5783 "records/contacts/index.md",
5784 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5785 );
5786 fx.write(
5787 "records/contacts/index.jsonl",
5788 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/../../ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5789 );
5790 let issues = fx.store_all();
5791 assert!(
5792 issues.iter().any(|i| i.code == codes::INDEX_JSONL_DESYNC
5793 && i.message.contains("not a safe store-relative path")),
5794 "{issues:#?}"
5795 );
5796 }
5797
5798 #[test]
5799 fn index_jsonl_stale_summary() {
5800 let fx = Fixture::new();
5801 fx.write("records/contacts/a.md", &valid_contact("real summary"));
5802 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5803 fx.write(
5804 "records/index.md",
5805 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5806 );
5807 fx.write(
5808 "records/contacts/index.md",
5809 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — real summary\n",
5810 );
5811 fx.write(
5813 "records/contacts/index.jsonl",
5814 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"OUTDATED\"}\n",
5815 );
5816 let issues = fx.store_all();
5817 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5818 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5819 assert!(stale.key.as_deref().unwrap().contains("summary"));
5820 }
5821
5822 #[test]
5830 fn index_jsonl_stale_queryable_field_email() {
5831 let fx = Fixture::new();
5832 let contact = "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"a contact\"\nname: A\nemail: real@correct.com\n---\n\n# A\n";
5833 fx.write("records/contacts/a.md", contact);
5834 fx.rebuild_indexes();
5836 let jsonl_path = fx.dir.path().join("records/contacts/index.jsonl");
5837 let good = fs::read_to_string(&jsonl_path).unwrap();
5838 assert!(
5840 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5841 "freshly-rebuilt sidecar must not be stale"
5842 );
5843 assert!(
5845 good.contains("real@correct.com"),
5846 "sidecar projects email: {good}"
5847 );
5848 fx.write(
5849 "records/contacts/index.jsonl",
5850 &good.replace("real@correct.com", "STALE-WRONG@evil.com"),
5851 );
5852
5853 let issues = fx.store_all();
5854 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5855 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5856 let key = stale.key.as_deref().unwrap();
5859 assert!(
5860 key.contains("email"),
5861 "expected `email` in stale key, got {key:?}"
5862 );
5863 assert!(!key.contains("summary"), "summary still matches: {key:?}");
5864 assert!(!key.contains("type"), "type still matches: {key:?}");
5865 }
5866
5867 #[test]
5871 fn index_jsonl_stale_typed_and_list_fields() {
5872 let fx = Fixture::new();
5873 let expense = "---\ntype: expense\ncreated: 2026-05-20T08:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"office chairs\"\ntags: [furniture, q2]\namount: 1299\nvendor: Acme\ndate: 2026-05-20\n---\n\n# Expense\n";
5874 fx.write("records/expenses/e.md", expense);
5875 fx.rebuild_indexes();
5876 let jsonl_path = fx.dir.path().join("records/expenses/index.jsonl");
5877 let good = fs::read_to_string(&jsonl_path).unwrap();
5878 assert!(
5879 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5880 "freshly-rebuilt sidecar must not be stale"
5881 );
5882 let stale_line = good
5884 .replace("\"q2\"", "\"WRONG-TAG\"")
5885 .replace("2026-05-22T10:00:00-07:00", "2099-01-01T00:00:00-07:00")
5886 .replace("1299", "9999");
5887 fx.write("records/expenses/index.jsonl", &stale_line);
5888
5889 let issues = fx.store_all();
5890 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5891 let key = stale.key.as_deref().unwrap();
5892 for expected in ["amount", "tags", "updated"] {
5893 assert!(
5894 key.contains(expected),
5895 "expected `{expected}` in stale key, got {key:?}"
5896 );
5897 }
5898 }
5899
5900 #[test]
5901 fn index_orphan_in_noncanonical_folder() {
5902 let fx = Fixture::new();
5903 fx.write("records/contacts/a.md", &valid_contact("a"));
5904 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5906 fx.write(
5907 "records/index.md",
5908 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5909 );
5910 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5911 fx.write(
5912 "records/contacts/index.jsonl",
5913 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5914 );
5915 fx.write(
5917 "records/contacts/subfolder/index.md",
5918 "---\ntype: index\nscope: type-folder\n---\n\n# stray\n",
5919 );
5920 let issues = fx.store_all();
5921 let orphan = find(&issues, codes::INDEX_ORPHAN);
5922 assert_eq!(orphan.severity, Severity::Warning);
5923 assert_eq!(
5924 orphan.file,
5925 PathBuf::from("records/contacts/subfolder/index.md")
5926 );
5927 }
5928
5929 #[test]
5930 fn index_wrong_scope() {
5931 let fx = Fixture::new();
5932 fx.write("records/contacts/a.md", &valid_contact("a"));
5933 fx.write("index.md", "---\ntype: index\nscope: layer\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5935 fx.write(
5936 "records/index.md",
5937 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5938 );
5939 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5940 fx.write(
5941 "records/contacts/index.jsonl",
5942 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5943 );
5944 let issues = fx.store_all();
5945 let issue = find(&issues, codes::INDEX_WRONG_SCOPE);
5946 assert_eq!(issue.severity, Severity::Warning);
5947 assert_eq!(issue.file, PathBuf::from("index.md"));
5948 }
5949
5950 #[test]
5951 fn capped_type_folder_index_does_not_flag_missing_entries() {
5952 let fx = Fixture::new();
5954 for i in 0..501 {
5955 fx.write(
5956 &format!("records/contacts/c{i:04}.md"),
5957 &valid_contact(&format!("contact {i}")),
5958 );
5959 }
5960 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (501 files)\n");
5961 fx.write(
5962 "records/index.md",
5963 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5964 );
5965 fx.write(
5967 "records/contacts/index.md",
5968 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/c0000]] — contact 0\n\n## More\n\nThis folder has 501 files.\n",
5969 );
5970 let mut jsonl = String::new();
5972 for i in 0..501 {
5973 jsonl.push_str(&format!(
5974 "{{\"path\":\"records/contacts/c{i:04}.md\",\"type\":\"contact\",\"summary\":\"contact {i}\"}}\n"
5975 ));
5976 }
5977 fx.write("records/contacts/index.jsonl", &jsonl);
5978 let issues = fx.store_all();
5979 assert!(
5980 !has(&issues, codes::INDEX_MISSING_ENTRY),
5981 "over the cap, missing browse entries are expected: {issues:#?}"
5982 );
5983 assert!(
5985 !has(&issues, codes::INDEX_JSONL_DESYNC),
5986 "{:#?}",
5987 issues
5988 .iter()
5989 .filter(|i| i.code == codes::INDEX_JSONL_DESYNC)
5990 .collect::<Vec<_>>()
5991 );
5992 }
5993
5994 #[test]
5997 fn log_bad_timestamp_unknown_kind_out_of_order() {
5998 let fx = Fixture::new();
5999 fx.write(
6000 "log.md",
6001 concat!(
6002 "---\ntype: log\n---\n\n# Log\n\n",
6003 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6004 "## [2026-05-27 09:00] update | records/contacts/b\nx\n\n", "## [2026-05-27 11:00] frobnicate | records/contacts/c\nx\n\n", "## [not-a-date] create | records/contacts/d\nx\n", ),
6008 );
6009 let issues = fx.store_all();
6010 assert!(has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
6011 assert_eq!(
6012 find(&issues, codes::LOG_OUT_OF_ORDER).severity,
6013 Severity::Warning
6014 );
6015 let unknown = find(&issues, codes::LOG_UNKNOWN_KIND);
6016 assert_eq!(unknown.severity, Severity::Warning);
6017 assert!(unknown.message.contains("frobnicate"));
6018 assert!(unknown
6019 .suggestion
6020 .as_deref()
6021 .is_some_and(|s| s.contains("create")));
6022 let bad = find(&issues, codes::LOG_BAD_TIMESTAMP);
6023 assert!(bad.is_error());
6024 }
6025
6026 #[test]
6027 fn log_validate_entry_without_object_is_well_formed() {
6028 let fx = Fixture::new();
6029 fx.write(
6030 "log.md",
6031 "---\ntype: log\n---\n\n## [2026-05-27 10:00] validate\nPASS\n",
6032 );
6033 let issues = fx.store_all();
6034 assert!(!has(&issues, codes::LOG_BAD_TIMESTAMP), "{issues:#?}");
6035 assert!(!has(&issues, codes::LOG_UNKNOWN_KIND), "{issues:#?}");
6036 }
6037
6038 #[test]
6039 fn log_in_order_is_clean() {
6040 let fx = Fixture::new();
6041 fx.write(
6042 "log.md",
6043 concat!(
6044 "---\ntype: log\n---\n\n",
6045 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6046 "## [2026-05-27 10:05] update | records/contacts/a\nx\n",
6047 ),
6048 );
6049 let issues = fx.store_all();
6050 assert!(!has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
6051 }
6052
6053 #[test]
6054 fn log_not_checked_in_working_set() {
6055 let fx = Fixture::new();
6057 fx.write(
6058 "log.md",
6059 concat!(
6060 "---\ntype: log\n---\n\n",
6061 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6062 "## [2026-05-27 09:00] update | records/contacts/a\nx\n",
6063 ),
6064 );
6065 let issues = validate_working_set(&fx.store(), None).unwrap();
6066 assert!(
6067 !has(&issues, codes::LOG_OUT_OF_ORDER),
6068 "log ordering is --all only: {issues:#?}"
6069 );
6070 }
6071
6072 #[test]
6075 fn working_set_validates_only_changed_files() {
6076 let fx = Fixture::new();
6077 fx.write(
6080 "records/contacts/dirty.md",
6081 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6082 );
6083 fx.write(
6084 "records/contacts/unlogged.md",
6085 "---\ntype: contact\ncreated: ALSO-BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6086 );
6087 fx.write(
6088 "log.md",
6089 "---\ntype: log\n---\n\n## [2026-05-22 10:00] update | records/contacts/dirty\nedited\n",
6090 );
6091 let issues = validate_working_set(&fx.store(), None).unwrap();
6092 assert!(
6093 issues.iter().any(|i| i.code == codes::FM_BAD_TIMESTAMP
6094 && i.file == Path::new("records/contacts/dirty.md")),
6095 "{issues:#?}"
6096 );
6097 assert!(
6098 !issues
6099 .iter()
6100 .any(|i| i.file == Path::new("records/contacts/unlogged.md")),
6101 "unlogged file must not be in the working set: {issues:#?}"
6102 );
6103 }
6104
6105 #[test]
6106 fn working_set_includes_incoming_linkers_to_changed_path() {
6107 let fx = Fixture::new();
6108 fx.write(
6111 "records/profiles/linker.md",
6112 "---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: links to a removed page\n---\n\nSee [[records/contacts/changed]].\n",
6113 );
6114 fx.write(
6116 "log.md",
6117 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/changed\nremoved\n",
6118 );
6119 let issues = validate_working_set(&fx.store(), None).unwrap();
6120 assert!(
6121 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
6122 && i.file == Path::new("records/profiles/linker.md")),
6123 "incoming linker to a removed path must be validated: {issues:#?}"
6124 );
6125 }
6126
6127 #[test]
6128 fn working_set_respects_explicit_since_cutoff() {
6129 let fx = Fixture::new();
6130 fx.write(
6131 "records/contacts/old.md",
6132 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6133 );
6134 fx.write(
6135 "records/contacts/new.md",
6136 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6137 );
6138 fx.write(
6139 "log.md",
6140 concat!(
6141 "---\ntype: log\n---\n\n",
6142 "## [2026-05-20 10:00] update | records/contacts/old\nx\n\n",
6143 "## [2026-05-25 10:00] update | records/contacts/new\nx\n",
6144 ),
6145 );
6146 let since = DateTime::parse_from_rfc3339("2026-05-22T00:00:00+00:00").unwrap();
6148 let issues = validate_working_set(&fx.store(), Some(since)).unwrap();
6149 assert!(
6150 issues
6151 .iter()
6152 .any(|i| i.file == Path::new("records/contacts/new.md")),
6153 "{issues:#?}"
6154 );
6155 assert!(
6156 !issues
6157 .iter()
6158 .any(|i| i.file == Path::new("records/contacts/old.md")),
6159 "old change is before the cutoff: {issues:#?}"
6160 );
6161 }
6162
6163 #[test]
6164 fn working_set_default_since_is_last_validate_entry() {
6165 let fx = Fixture::new();
6166 fx.write(
6168 "records/contacts/before.md",
6169 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6170 );
6171 fx.write(
6172 "records/contacts/after.md",
6173 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6174 );
6175 fx.write(
6176 "log.md",
6177 concat!(
6178 "---\ntype: log\n---\n\n",
6179 "## [2026-05-20 10:00] update | records/contacts/before\nx\n\n",
6180 "## [2026-05-21 10:00] validate\nPASS\n\n",
6181 "## [2026-05-22 10:00] update | records/contacts/after\nx\n",
6182 ),
6183 );
6184 let issues = validate_working_set(&fx.store(), None).unwrap();
6185 assert!(
6186 issues
6187 .iter()
6188 .any(|i| i.file == Path::new("records/contacts/after.md")),
6189 "{issues:#?}"
6190 );
6191 assert!(
6192 !issues
6193 .iter()
6194 .any(|i| i.file == Path::new("records/contacts/before.md")),
6195 "change before the last validate entry is outside the default window: {issues:#?}"
6196 );
6197 }
6198
6199 #[test]
6202 fn issues_are_sorted_by_file_then_line() {
6203 let fx = Fixture::new();
6204 fx.write("records/profiles/z.md", "---\ntype: profile\nmeta-type: conclusion\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n");
6205 fx.write("records/profiles/a.md", "---\ntype: profile\nmeta-type: conclusion\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n");
6206 let issues = fx.store_all();
6207 let files: Vec<&PathBuf> = issues.iter().map(|i| &i.file).collect();
6208 let mut sorted = files.clone();
6209 sorted.sort();
6210 assert_eq!(
6211 files, sorted,
6212 "issues must be emitted in a stable file order"
6213 );
6214 }
6215
6216 #[test]
6219 fn frozen_page_is_not_a_validate_error() {
6220 let mut fx = Fixture::new();
6223 fx.config
6224 .frozen_pages
6225 .push(PathBuf::from("records/decisions/d.md"));
6226 fx.write(
6227 "records/decisions/d.md",
6228 "---\ntype: decision\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a finalized decision\n---\n\n# D\n",
6229 );
6230 let issues = fx.store_all();
6231 assert!(
6232 !has(&issues, codes::POLICY_FROZEN_PAGE),
6233 "frozen pages are enforced at write-time, not by validate: {issues:#?}"
6234 );
6235 }
6236
6237 #[test]
6238 fn wiki_link_ambiguous_is_never_emitted_under_full_path_doctrine() {
6239 let fx = Fixture::new();
6242 fx.write("records/contacts/sarah-chen.md", &valid_contact("sarah"));
6243 let mut body = valid_contact("links to sarah");
6244 body.push_str("\nSee [[records/contacts/sarah-chen]].\n");
6245 fx.write("records/contacts/p.md", &body);
6246 let issues = fx.store_all();
6247 assert!(!has(&issues, codes::WIKI_LINK_AMBIGUOUS), "{issues:#?}");
6248 }
6249
6250 #[test]
6253 fn unknown_type_passes_through() {
6254 let fx = Fixture::new();
6258 fx.write(
6259 "records/proposals/x.md",
6260 "---\ntype: proposal\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a proposal\ncustom_field: anything\nbudget: 5000\n---\n\n# Proposal\n",
6261 );
6262 let issues = fx.store_all();
6263 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
6264 assert!(!has(&issues, codes::SCHEMA_MISSING_REQUIRED), "{issues:#?}");
6265 assert!(!has(&issues, codes::SCHEMA_SHAPE_MISMATCH), "{issues:#?}");
6266 assert!(
6268 !issues
6269 .iter()
6270 .any(|i| i.key.as_deref() == Some("custom_field")
6271 || i.key.as_deref() == Some("budget")),
6272 "unknown fields are ambient context: {issues:#?}"
6273 );
6274 }
6275
6276 #[test]
6279 fn incoming_linker_scan_does_not_prefix_match() {
6280 let fx = Fixture::new();
6283 fx.write(
6284 "records/profiles/only-sarah-chen.md",
6285 "---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nSee [[records/contacts/sarah-chen]].\n",
6286 );
6287 fx.write(
6289 "log.md",
6290 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah\nremoved\n",
6291 );
6292 let issues = validate_working_set(&fx.store(), None).unwrap();
6293 assert!(
6294 !issues
6295 .iter()
6296 .any(|i| i.file == Path::new("records/profiles/only-sarah-chen.md")),
6297 "a prefix-sharing link must not pull a file into the working set: {issues:#?}"
6298 );
6299 }
6300
6301 #[test]
6302 fn working_set_does_not_flag_stale_catalog_index_as_wiki_link_broken() {
6303 let fx = Fixture::new();
6317 fx.write(
6320 "records/contacts/index.md",
6321 "---\ntype: index\n---\n\n- [[records/contacts/sarah-chen]] — Sarah Chen\n",
6322 );
6323 fx.write(
6325 "log.md",
6326 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n",
6327 );
6328 let issues = validate_working_set(&fx.store(), None).unwrap();
6329 assert!(
6330 !issues
6331 .iter()
6332 .any(|i| i.file == Path::new("records/contacts/index.md")
6333 && i.code == codes::WIKI_LINK_BROKEN),
6334 "a stale catalog `index.md` entry must NOT be WIKI_LINK_BROKEN in the \
6335 working set (it is an INDEX_STALE_ENTRY under `--all`): {issues:#?}"
6336 );
6337 }
6338
6339 #[test]
6340 fn incoming_linker_scan_covers_the_whole_changed_set_in_one_pass() {
6341 let fx = Fixture::new();
6350 fx.write(
6352 "records/profiles/refers-sarah.md",
6353 "---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nSee [[records/contacts/sarah-chen]].\n",
6354 );
6355 fx.write(
6359 "records/meetings/2026/05/kickoff.md",
6360 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: m\ndate: 2026-05-01\ncompany: \"[[records/companies/acme]]\"\n---\n\n# Kickoff\n",
6361 );
6362 fx.write(
6364 "log.md",
6365 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n\n## [2026-05-22 10:05] delete | records/companies/acme\nremoved\n",
6366 );
6367
6368 let issues = validate_working_set(&fx.store(), None).unwrap();
6369 assert!(
6370 issues
6371 .iter()
6372 .any(|i| i.file == Path::new("records/profiles/refers-sarah.md")
6373 && i.code == codes::WIKI_LINK_BROKEN),
6374 "linker to the FIRST deleted target must be pulled in and flagged: {issues:#?}"
6375 );
6376 assert!(
6377 issues.iter().any(
6378 |i| i.file == Path::new("records/meetings/2026/05/kickoff.md")
6379 && i.code == codes::WIKI_LINK_BROKEN
6380 ),
6381 "linker to the SECOND deleted target (typed-field edge) must also be \
6382 pulled in and flagged — proves the scan covers the whole changed set, \
6383 not just one object: {issues:#?}"
6384 );
6385 }
6386
6387 #[test]
6388 fn frontmatter_block_sequence_links_each_get_their_own_line() {
6389 let fx = Fixture::new();
6391 fx.write(
6393 "records/meetings/m.md",
6394 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: m\ndate: 2026-05-01\nparticipants:\n - [[records/contacts/ghost1]]\n - [[records/contacts/ghost2]]\n---\n\n# M\n",
6395 );
6396 let issues = fx.store_all();
6397 let broken_lines: BTreeSet<Option<u32>> = issues
6398 .iter()
6399 .filter(|i| i.code == codes::WIKI_LINK_BROKEN)
6400 .map(|i| i.line)
6401 .collect();
6402 assert_eq!(
6403 broken_lines.len(),
6404 2,
6405 "two distinct broken-link lines: {issues:#?}"
6406 );
6407 }
6408
6409 #[test]
6412 fn null_created_is_missing_not_silently_passed() {
6413 let fx = Fixture::new();
6417 fx.write(
6418 "records/contacts/a.md",
6419 "---\ntype: contact\ncreated:\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6420 );
6421 let issues = fx.store_all();
6422 assert!(
6423 has(&issues, codes::FM_MISSING_CREATED),
6424 "null `created:` must read as missing: {issues:#?}"
6425 );
6426 }
6427
6428 #[test]
6429 fn sequence_created_is_bad_timestamp() {
6430 let fx = Fixture::new();
6432 fx.write(
6433 "records/contacts/a.md",
6434 "---\ntype: contact\ncreated: [2026]\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6435 );
6436 let issues = fx.store_all();
6437 assert!(
6438 issues
6439 .iter()
6440 .any(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created")),
6441 "a sequence `created:` must be FM_BAD_TIMESTAMP: {issues:#?}"
6442 );
6443 }
6444
6445 #[test]
6448 fn required_field_null_or_empty_collection_is_missing() {
6449 for value in ["", " []", " {}"] {
6454 let mut fx = Fixture::new();
6455 fx.config.schemas.insert(
6456 "contact".into(),
6457 Schema {
6458 fields: vec![FieldSpec {
6459 name: "name".into(),
6460 required: true,
6461 ..Default::default()
6462 }],
6463 ..Default::default()
6464 },
6465 );
6466 fx.write(
6467 "records/contacts/a.md",
6468 &format!(
6469 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname:{value}\n---\n\n# A\n"
6470 ),
6471 );
6472 let issues = fx.store_all();
6473 assert!(
6474 issues
6475 .iter()
6476 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
6477 && i.key.as_deref() == Some("name")),
6478 "required `name:{value}` must be SCHEMA_MISSING_REQUIRED: {issues:#?}"
6479 );
6480 }
6481 }
6482
6483 #[test]
6486 fn wiki_link_to_raw_source_file_resolves() {
6487 let fx = Fixture::new();
6491 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6492 fx.write(
6493 "records/contacts/a.md",
6494 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\nSee [[sources/emails/2026-05-22-elena.eml]] for context.\n",
6495 );
6496 let issues = fx.store_all();
6497 assert!(
6498 !issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
6499 "a link to an existing raw source file must not be broken: {issues:#?}"
6500 );
6501 }
6502
6503 #[test]
6506 fn wrong_case_wiki_link_is_broken_exact_case() {
6507 let fx = Fixture::new();
6513 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6514 let mut body = valid_contact("links with the wrong case");
6515 body.push_str("\nKnows [[records/contacts/BOB]].\n");
6516 fx.write("records/contacts/alice.md", &body);
6517 let issues = fx.store_all();
6518 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6519 assert!(issue.is_error());
6520 assert!(
6521 issue.message.contains("records/contacts/BOB"),
6522 "the wrong-case target must be named in the issue: {issues:#?}"
6523 );
6524 }
6525
6526 #[test]
6527 fn correct_case_wiki_link_still_resolves() {
6528 let fx = Fixture::new();
6532 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6533 let mut body = valid_contact("links with the right case");
6534 body.push_str("\nKnows [[records/contacts/bob]].\n");
6535 fx.write("records/contacts/alice.md", &body);
6536 let issues = fx.store_all();
6537 assert!(
6538 !issues
6539 .iter()
6540 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("contacts/bob")),
6541 "a correct-case link must resolve clean: {issues:#?}"
6542 );
6543 }
6544
6545 #[test]
6546 fn wrong_case_raw_source_wiki_link_is_broken() {
6547 let fx = Fixture::new();
6552 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6553 fx.write(
6554 "records/contacts/a.md",
6555 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\nSee [[sources/emails/2026-05-22-ELENA.eml]] for context.\n",
6556 );
6557 let issues = fx.store_all();
6558 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6559 assert!(issue.is_error());
6560 assert!(
6561 issue.message.contains("2026-05-22-ELENA.eml"),
6562 "the wrong-case raw-source target must be flagged: {issues:#?}"
6563 );
6564 }
6565
6566 #[test]
6569 fn non_utf8_content_file_is_reported() {
6570 let fx = Fixture::new();
6574 let abs = fx.dir.path().join("records/notes/corrupt.md");
6575 fs::create_dir_all(abs.parent().unwrap()).unwrap();
6576 fs::write(&abs, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
6577 let issues = validate_working_set(&fx.store(), None).unwrap();
6578 assert!(
6579 has(&issues, codes::FM_UNREADABLE),
6580 "an unreadable content file must be reported, not silently skipped: {issues:#?}"
6581 );
6582 }
6583
6584 #[test]
6587 fn tilde_fence_containing_backtick_fence_does_not_invert() {
6588 let body = "~~~markdown\n```\n[[fake-link]]\n```\n~~~\n";
6593 let links = extract_wiki_links(body);
6594 assert!(
6595 links.is_empty(),
6596 "wiki-link inside a nested code fence must be skipped: {links:?}"
6597 );
6598 }
6599
6600 #[test]
6603 fn all_sweep_visits_in_layer_log_folder() {
6604 let fx = Fixture::new();
6609 fx.write("records/log/2026-06-01-pricing.md", "no frontmatter here\n");
6610 let issues = fx.store_all();
6611 assert!(
6612 has(&issues, codes::FM_MISSING_TYPE),
6613 "--all must validate files under an in-layer `log/` folder: {issues:#?}"
6614 );
6615 }
6616
6617 #[test]
6620 fn flow_form_link_list_with_spaces_is_flagged() {
6621 let keys = detect_flow_form_link_lists("attendees: [ [[records/contacts/elena]] ]\n");
6625 assert!(
6626 keys.iter().any(|k| k == "attendees"),
6627 "spaced flow-form list must be detected: {keys:?}"
6628 );
6629 }
6630
6631 #[test]
6634 fn middot_hashtag_summary_tail_round_trips() {
6635 assert_eq!(
6641 extract_index_entry_summary("— Standup notes · #standup").as_deref(),
6642 Some("Standup notes · #standup"),
6643 "a single-spaced middot tail is part of the summary, not a tag block"
6644 );
6645 assert_eq!(
6647 extract_index_entry_summary("— Renewal champion · #renewal #acme").as_deref(),
6648 Some("Renewal champion"),
6649 "the renderer's double-spaced ` · #tag` suffix is stripped"
6650 );
6651 }
6652
6653 #[test]
6656 fn url_shape_accepts_short_http_and_rejects_bare_scheme() {
6657 assert!(is_url("http://x"), "an 8-char http URL is valid");
6658 assert!(is_url("https://x"), "a 9-char https URL is valid");
6659 assert!(!is_url("http://"), "a bare scheme with no host is rejected");
6660 assert!(!is_url("https://"), "a bare https scheme is rejected");
6661 }
6662
6663 #[test]
6664 fn email_shape_rejects_double_at() {
6665 assert!(!is_email("sarah@@acme.com"), "double-@ domain is rejected");
6666 assert!(!is_email("a@b@c.com"), "two @ signs are rejected");
6667 assert!(is_email("sarah@acme.com"), "a normal address still passes");
6668 }
6669
6670 #[test]
6673 fn working_set_does_not_flag_log_md_body_links() {
6674 let fx = Fixture::new();
6680 fx.write("records/contacts/a.md", &valid_contact("A"));
6681 fx.write(
6682 "log.md",
6683 "---\ntype: log\n---\n\n## [2026-06-01 10:00] delete | records/contacts/ghost\n\nRemoved [[records/contacts/ghost]] per cleanup.\n",
6684 );
6685 let issues = validate_working_set(&fx.store(), None).unwrap();
6686 assert!(
6687 !issues
6688 .iter()
6689 .any(|i| i.code == codes::WIKI_LINK_BROKEN
6690 && i.file == std::path::Path::new("log.md")),
6691 "a broken wiki-link inside append-only log.md must not be flagged: {issues:#?}"
6692 );
6693 }
6694
6695 #[test]
6698 fn schema_duplicate_field_name_is_flagged() {
6699 let mut fx = Fixture::new();
6700 fx.config.schemas.insert(
6701 "contact".into(),
6702 Schema {
6703 fields: vec![
6704 FieldSpec {
6705 name: "name".into(),
6706 required: true,
6707 ..Default::default()
6708 },
6709 FieldSpec {
6710 name: "name".into(),
6711 ..Default::default()
6712 },
6713 ],
6714 ..Default::default()
6715 },
6716 );
6717 let issues = fx.store_all();
6718 assert!(
6719 issues
6720 .iter()
6721 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.key.as_deref() == Some("name")),
6722 "a duplicate schema field name must be flagged: {issues:#?}"
6723 );
6724 }
6725
6726 #[test]
6727 fn schema_unknown_modifier_is_info() {
6728 let mut fx = Fixture::new();
6729 fx.config.schemas.insert(
6730 "contact".into(),
6731 Schema {
6732 fields: vec![FieldSpec {
6733 name: "name".into(),
6734 unknown_modifiers: vec!["requierd".into()],
6735 ..Default::default()
6736 }],
6737 ..Default::default()
6738 },
6739 );
6740 let issues = fx.store_all();
6741 assert!(
6742 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6743 && i.severity == Severity::Info
6744 && i.key.as_deref() == Some("name")),
6745 "an unrecognized schema modifier must surface as Info: {issues:#?}"
6746 );
6747 }
6748
6749 #[test]
6755 fn schema_unique_key_optional_field_is_warning() {
6756 let mut fx = Fixture::new();
6757 fx.config.schemas.insert(
6758 "expense".into(),
6759 Schema {
6760 fields: vec![
6761 FieldSpec {
6762 name: "date".into(),
6763 required: true,
6764 ..Default::default()
6765 },
6766 FieldSpec {
6767 name: "amount".into(),
6768 required: true,
6769 ..Default::default()
6770 },
6771 FieldSpec {
6772 name: "vendor".into(),
6773 ..Default::default()
6774 },
6775 ],
6776 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
6777 ..Default::default()
6778 },
6779 );
6780 let issues = fx.store_all();
6781 assert!(
6782 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6783 && i.severity == Severity::Warning
6784 && i.key.as_deref() == Some("vendor")
6785 && i.message.contains("unique")),
6786 "a `unique:` key field not marked required must warn: {issues:#?}"
6787 );
6788 assert!(
6790 !issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6791 && matches!(i.key.as_deref(), Some("date") | Some("amount"))),
6792 "required key fields must not warn: {issues:#?}"
6793 );
6794 }
6795
6796 #[test]
6801 fn body_leading_frontmatter_block_is_warning() {
6802 let fx = Fixture::new();
6803 fx.write(
6804 "records/notes/imported.md",
6805 "---\ntype: note\nsummary: an imported daily note\ncreated: 2026-06-02T09:00:00-07:00\nupdated: 2026-06-02T09:00:00-07:00\n---\n---\ntags: [daily]\n---\n# 2026-06-02\n\nSigned the SOW.\n",
6806 );
6807 let issues = fx.store_all();
6808 assert!(
6809 issues
6810 .iter()
6811 .any(|i| i.code == codes::FM_IN_BODY && i.severity == Severity::Warning),
6812 "a body opening with a second frontmatter block must warn: {issues:#?}"
6813 );
6814 }
6815
6816 #[test]
6819 fn body_thematic_break_rules_do_not_warn() {
6820 let fx = Fixture::new();
6821 fx.write(
6822 "records/notes/rules.md",
6823 "---\ntype: note\nsummary: a note using horizontal rules\ncreated: 2026-06-02T09:00:00-07:00\nupdated: 2026-06-02T09:00:00-07:00\n---\n---\nJust some prose between two rules.\n---\nMore text.\n",
6824 );
6825 let issues = fx.store_all();
6826 assert!(
6827 !has(&issues, codes::FM_IN_BODY),
6828 "a `---` thematic rule around prose (not a YAML mapping) must NOT warn: {issues:#?}"
6829 );
6830 }
6831
6832 #[test]
6836 fn body_fenced_frontmatter_example_does_not_warn() {
6837 let fx = Fixture::new();
6838 fx.write(
6839 "records/notes/doc.md",
6840 "---\ntype: note\nsummary: a note showing an example record\ncreated: 2026-06-02T09:00:00-07:00\nupdated: 2026-06-02T09:00:00-07:00\n---\n```markdown\n---\ntype: contact\nname: Sam\n---\n```\n",
6841 );
6842 let issues = fx.store_all();
6843 assert!(
6844 !has(&issues, codes::FM_IN_BODY),
6845 "a fenced example block (body opens with a code fence, not `---`) must NOT warn: {issues:#?}"
6846 );
6847 }
6848
6849 #[test]
6852 fn schema_unique_key_undeclared_field_is_warning() {
6853 let mut fx = Fixture::new();
6854 fx.config.schemas.insert(
6855 "expense".into(),
6856 Schema {
6857 fields: vec![FieldSpec {
6858 name: "date".into(),
6859 required: true,
6860 ..Default::default()
6861 }],
6862 unique_keys: vec![vec!["date".into(), "vendor".into()]],
6863 ..Default::default()
6864 },
6865 );
6866 let issues = fx.store_all();
6867 assert!(
6868 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6869 && i.severity == Severity::Warning
6870 && i.key.as_deref() == Some("vendor")
6871 && i.message.contains("not declared")),
6872 "a `unique:` key field absent from the schema must warn: {issues:#?}"
6873 );
6874 }
6875
6876 #[test]
6878 fn schema_unique_key_all_required_is_clean() {
6879 let mut fx = Fixture::new();
6880 fx.config.schemas.insert(
6881 "expense".into(),
6882 Schema {
6883 fields: vec![
6884 FieldSpec {
6885 name: "date".into(),
6886 required: true,
6887 ..Default::default()
6888 },
6889 FieldSpec {
6890 name: "amount".into(),
6891 required: true,
6892 ..Default::default()
6893 },
6894 ],
6895 unique_keys: vec![vec!["date".into(), "amount".into()]],
6896 ..Default::default()
6897 },
6898 );
6899 let issues = fx.store_all();
6900 assert!(
6901 !issues
6902 .iter()
6903 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.message.contains("unique")),
6904 "an all-required unique key must not warn: {issues:#?}"
6905 );
6906 }
6907
6908 #[test]
6914 fn every_code_constant_is_documented_in_spec() {
6915 let this_src = include_str!("validate.rs");
6919 let mut codes_in_module: Vec<String> = Vec::new();
6920 let mut in_codes_mod = false;
6921 for line in this_src.lines() {
6922 let t = line.trim();
6923 if t.starts_with("pub mod codes") {
6924 in_codes_mod = true;
6925 continue;
6926 }
6927 if in_codes_mod && line == "}" {
6929 break;
6930 }
6931 if in_codes_mod {
6932 if let Some(rest) = t.strip_prefix("pub const ") {
6933 let value = rest
6935 .split_once('=')
6936 .map(|(_, v)| v.trim())
6937 .and_then(|v| v.strip_prefix('"'))
6938 .and_then(|v| v.strip_suffix("\";"))
6939 .unwrap_or_else(|| panic!("unparseable code constant line: {line:?}"));
6940 codes_in_module.push(value.to_string());
6941 }
6942 }
6943 }
6944 assert!(
6945 codes_in_module.len() >= 36,
6946 "parsed only {} code constants from `mod codes`; the parser likely \
6947 broke against a source-format change",
6948 codes_in_module.len()
6949 );
6950
6951 let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SPEC.md");
6953 let spec = fs::read_to_string(&spec_path)
6954 .unwrap_or_else(|e| panic!("cannot read {}: {e}", spec_path.display()));
6955
6956 let missing: Vec<&String> = codes_in_module
6958 .iter()
6959 .filter(|code| !spec.contains(&format!("| `{code}` |")))
6960 .collect();
6961 assert!(
6962 missing.is_empty(),
6963 "validation codes emitted by the engine but absent from SPEC.md \
6964 § Validation (the declared complete vocabulary): {missing:?}"
6965 );
6966 }
6967
6968 const LOOSE_ALICE: &str = "---\ntype: contact\nid: alice\ncreated: 2026-06-01T08:00:00-07:00\nupdated: 2026-06-01T08:00:00-07:00\nsummary: Alice\n---\nbody\n";
6971 const LOOSE_BOB: &str = "---\ntype: contact\nid: bob\ncreated: 2026-06-01T08:00:00-07:00\nupdated: 2026-06-01T08:00:00-07:00\nsummary: Bob loose\n---\nbody\n";
6972
6973 #[test]
6974 fn loose_file_catalogued_in_layer_jsonl_validates_clean() {
6975 let fx = Fixture::new();
6976 fx.write("records/contacts/alice.md", LOOSE_ALICE);
6977 fx.write("records/bob.md", LOOSE_BOB); fx.rebuild_indexes();
6979 let issues = fx.store_all();
6980 assert!(
6981 issues.is_empty(),
6982 "a rebuilt store with a catalogued loose file must validate clean, got: {issues:?}"
6983 );
6984 }
6985
6986 #[test]
6987 fn loose_file_with_missing_layer_jsonl_is_index_jsonl_missing() {
6988 let fx = Fixture::new();
6989 fx.write("records/contacts/alice.md", LOOSE_ALICE);
6990 fx.write("records/bob.md", LOOSE_BOB);
6991 fx.rebuild_indexes();
6992 fs::remove_file(fx.dir.path().join("records/index.jsonl")).unwrap();
6994 let issues = fx.store_all();
6995 assert!(
6996 has(&issues, codes::INDEX_JSONL_MISSING),
6997 "a loose file with no layer index.jsonl must raise INDEX_JSONL_MISSING, got: {issues:?}"
6998 );
6999 }
7000
7001 #[cfg(unix)]
7002 #[test]
7003 fn validation_reads_opened_root_after_path_replacement() {
7004 use std::os::unix::fs::symlink;
7005
7006 let sandbox = tempfile::tempdir().unwrap();
7007 let root = sandbox.path().join("store");
7008 fs::create_dir_all(root.join("records/notes")).unwrap();
7009 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
7010 fs::write(
7011 root.join("records/notes/owned.md"),
7012 "---\ntype: note\n---\nowned body\n",
7013 )
7014 .unwrap();
7015 let store = Store::open_strict(&root).unwrap();
7016 let detached = sandbox.path().join("detached");
7017 fs::rename(&root, &detached).unwrap();
7018
7019 let replacement = sandbox.path().join("replacement");
7020 fs::create_dir_all(replacement.join("records/notes")).unwrap();
7021 fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
7022 fs::write(
7023 replacement.join("records/notes/replacement-secret.md"),
7024 "not frontmatter\n",
7025 )
7026 .unwrap();
7027 symlink(&replacement, &root).unwrap();
7028
7029 let issues = validate_content_sweep(&store).unwrap();
7030 assert!(
7031 issues
7032 .iter()
7033 .any(|issue| issue.file == Path::new("records/notes/owned.md")),
7034 "the held original file must be validated: {issues:?}"
7035 );
7036 assert!(
7037 issues
7038 .iter()
7039 .all(|issue| !issue.file.to_string_lossy().contains("replacement-secret")),
7040 "replacement-root files must be invisible: {issues:?}"
7041 );
7042 }
7043}