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_PATH_IS_CONTENT: &str = "ASSET_PATH_IS_CONTENT";
207}
208
209const MAX_SUMMARY_LEN: usize = 200;
211
212const RECOGNIZED_LOG_KINDS: &[&str] = &[
215 "ingest",
216 "create",
217 "update",
218 "delete",
219 "rename",
220 "link",
221 "validate",
222 "index-rebuild",
223 "contradiction",
224];
225
226pub fn validate_working_set(
252 store: &Store,
253 since: Option<DateTime<FixedOffset>>,
254) -> crate::Result<Vec<Issue>> {
255 if !store_marker_present(store) {
256 return Ok(vec![not_a_store_issue(store)]);
257 }
258
259 let cutoff = match since {
260 Some(ts) => Some(ts),
261 None => last_validate_at(store),
262 };
263
264 let changed = changed_objects_since(store, cutoff);
266 if changed.is_empty() && since.is_none() {
267 return validate_content_sweep(store);
268 }
269
270 let changed_targets: Vec<PathBuf> = changed.iter().cloned().collect();
281 let mut working: BTreeSet<PathBuf> = changed;
282 for linker in store.find_links_to_any(&changed_targets)? {
283 working.insert(linker);
284 }
285
286 let mut issues = nested_store_issues(store)?;
287 for rel in &working {
288 let abs = store.root.join(rel);
289 if !abs.is_file() || !store.owns_path(&abs) {
292 continue;
293 }
294 check_content_file(store, rel, &abs, 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 let abs = store.root.join(&rel);
308 check_content_file(store, &rel, &abs, None, &mut issues);
309 }
310 issues.sort_by(issue_order);
311 Ok(issues)
312}
313
314fn nested_store_issues(store: &Store) -> crate::Result<Vec<Issue>> {
318 let mut issues = Vec::new();
319 for nested in store.nested_store_roots()? {
320 let marker = nested.join("DB.md");
321 push(
322 &mut issues,
323 Severity::Error,
324 codes::NESTED_STORE,
325 &marker,
326 None,
327 None,
328 format!(
329 "`{}` is a db.md store nested inside this store",
330 nested.display()
331 ),
332 Some(
333 "move the nested store outside this store, or run dbmd from the nested root"
334 .to_string(),
335 ),
336 vec![],
337 );
338 }
339 Ok(issues)
340}
341
342pub fn validate_all(store: &Store) -> crate::Result<Vec<Issue>> {
347 if !store_marker_present(store) {
348 return Ok(vec![not_a_store_issue(store)]);
349 }
350
351 let mut issues = nested_store_issues(store)?;
352
353 check_db_md(store, &mut issues);
357
358 let files = store.walk()?;
359
360 let basenames = build_basename_index(&files);
365
366 let mut parsed: Vec<(PathBuf, Parsed)> = Vec::new();
368 for rel in &files {
369 let abs = store.root.join(rel);
370 if let Some(p) = check_content_file(store, rel, &abs, Some(&basenames), &mut issues) {
371 parsed.push((rel.clone(), p));
372 }
373 }
374
375 check_duplicates(store, &parsed, &mut issues);
377
378 check_indexes(store, &files, &mut issues);
380
381 check_log(store, &mut issues);
383
384 check_assets(store, &parsed, &mut issues);
389
390 issues.sort_by(issue_order);
391 Ok(issues)
392}
393
394struct Parsed {
403 fm: Option<BTreeMap<String, Value>>,
406 fm_yaml: String,
409}
410
411fn check_content_file(
416 store: &Store,
417 rel: &Path,
418 abs: &Path,
419 basenames: Option<&BasenameIndex>,
420 issues: &mut Vec<Issue>,
421) -> Option<Parsed> {
422 let text = match std::fs::read_to_string(abs) {
423 Ok(t) => t,
424 Err(e) => {
425 let detail = if e.kind() == std::io::ErrorKind::InvalidData {
433 "file is not valid UTF-8 text".to_string()
434 } else {
435 format!("file could not be read: {e}")
436 };
437 push(
438 issues,
439 Severity::Error,
440 codes::FM_UNREADABLE,
441 rel,
442 None,
443 None,
444 format!("content file is unreadable: {detail}"),
445 Some(
446 "save the file as UTF-8 text, or remove it if it isn't a db.md content file"
447 .into(),
448 ),
449 vec![],
450 );
451 return None;
452 }
453 };
454
455 let is_content = is_content_file(rel);
456
457 let (fm_yaml, body, fm_end_line) = match split_frontmatter(&text) {
458 Some(split) => split,
459 None => {
460 if is_content {
464 push(
465 issues,
466 Severity::Error,
467 codes::FM_MISSING_TYPE,
468 rel,
469 None,
470 Some("type".into()),
471 "content file has no frontmatter `type:`".into(),
472 Some("add a YAML frontmatter block with `type:`".into()),
473 vec![],
474 );
475 push(
476 issues,
477 Severity::Error,
478 codes::SUMMARY_MISSING,
479 rel,
480 None,
481 Some("summary".into()),
482 "content file has no `summary`".into(),
483 Some("run `dbmd fm init`".into()),
484 vec![],
485 );
486 }
487 return None;
488 }
489 };
490
491 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
493 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
494 Ok(Value::Null) => Some(BTreeMap::new()),
496 Ok(_) => {
497 push(
501 issues,
502 Severity::Error,
503 codes::FM_MALFORMED_YAML,
504 rel,
505 Some(1),
506 None,
507 "frontmatter is not a YAML mapping".into(),
508 Some("repair the frontmatter YAML mapping, then rerun `dbmd validate`".into()),
509 vec![],
510 );
511 None
512 }
513 Err(e) => {
514 push(
517 issues,
518 Severity::Error,
519 codes::FM_MALFORMED_YAML,
520 rel,
521 Some(1),
522 None,
523 format!("frontmatter block isn't valid YAML: {e}"),
524 Some("repair the frontmatter YAML block, then rerun `dbmd validate`".into()),
525 vec![],
526 );
527 None
528 }
529 };
530
531 if let Some(map) = &fm {
532 check_frontmatter(store, rel, map, &fm_yaml, basenames, issues, is_content);
534 }
535
536 if !is_root_meta_file(rel) && !is_index_catalog_file(rel) {
558 check_body_wiki_links(store, rel, &body, fm_end_line, basenames, issues);
559 }
560
561 if is_content && body_opens_with_frontmatter(&body) {
568 push(
569 issues,
570 Severity::Warning,
571 codes::FM_IN_BODY,
572 rel,
573 Some(fm_end_line + 1),
574 None,
575 "the body opens with a second `---` frontmatter block; the record's \
576 frontmatter is the block at the top of the file, so this one is body \
577 text (usually an imported file's own frontmatter left in place)"
578 .into(),
579 Some(
580 "delete the leftover `---…---` block from the body, or move its \
581 fields into the record's frontmatter"
582 .into(),
583 ),
584 vec![],
585 );
586 }
587
588 Some(Parsed { fm, fm_yaml })
589}
590
591fn check_frontmatter(
593 store: &Store,
594 rel: &Path,
595 fm: &BTreeMap<String, Value>,
596 fm_yaml: &str,
597 basenames: Option<&BasenameIndex>,
598 issues: &mut Vec<Issue>,
599 is_content: bool,
600) {
601 let type_ = fm.get("type").and_then(scalar_string);
602
603 if is_content && type_.is_none() {
605 push(
606 issues,
607 Severity::Error,
608 codes::FM_MISSING_TYPE,
609 rel,
610 fm_key_line_or_top(fm_yaml, "type"),
611 Some("type".into()),
612 "content file has no `type:`".into(),
613 Some("add a `type:` field (e.g. `type: contact`)".into()),
614 vec![],
615 );
616 }
617
618 if is_content {
623 if let Some(v) = fm.get("meta-type").filter(|v| !v.is_null()) {
632 match scalar_string(v) {
633 Some(mt) if matches!(mt.as_str(), "fact" | "operational" | "conclusion") => {}
634 Some(mt) => push(
635 issues,
636 Severity::Error,
637 codes::FM_BAD_META_TYPE,
638 rel,
639 fm_key_line_or_top(fm_yaml, "meta-type"),
640 Some("meta-type".into()),
641 format!("`meta-type: {mt}` is not one of fact / operational / conclusion"),
642 Some(
643 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
644 .into(),
645 ),
646 vec![],
647 ),
648 None => push(
649 issues,
650 Severity::Error,
651 codes::FM_BAD_META_TYPE,
652 rel,
653 fm_key_line_or_top(fm_yaml, "meta-type"),
654 Some("meta-type".into()),
655 "`meta-type` is not one of fact / operational / conclusion: expected a scalar \
656 string, found a list or mapping"
657 .to_string(),
658 Some(
659 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
660 .into(),
661 ),
662 vec![],
663 ),
664 }
665 }
666 }
667
668 if is_content {
679 if let Some(v) = fm.get("id").filter(|v| !v.is_null()) {
680 let problem = match scalar_string(v) {
681 Some(id) if id.trim().is_empty() => Some("`id` is empty".to_string()),
682 Some(id) if id.chars().any(char::is_whitespace) => {
683 Some(format!("`id` {id:?} contains whitespace"))
684 }
685 Some(_) => None,
686 None => Some(
687 "`id` is not a scalar (found a list or mapping), so duplicate detection \
688 (DUP_ID) cannot see it"
689 .to_string(),
690 ),
691 };
692 if let Some(message) = problem {
693 push(
694 issues,
695 Severity::Warning,
696 codes::FM_BAD_ID,
697 rel,
698 fm_key_line_or_top(fm_yaml, "id"),
699 Some("id".into()),
700 message,
701 Some(
702 "use one opaque token with no whitespace — the recommended form is a \
703 lowercase ULID (`dbmd write` mints one) — or drop `id` to fall back to \
704 filename identity"
705 .into(),
706 ),
707 vec![],
708 );
709 }
710 }
711 }
712
713 if is_content {
715 check_summary(rel, fm, fm_yaml, issues);
716 }
717
718 if is_content {
722 for (key, missing_code) in [
723 ("created", codes::FM_MISSING_CREATED),
724 ("updated", codes::FM_MISSING_UPDATED),
725 ] {
726 let value = fm.get(key);
731 let missing = value.is_none() || value.is_some_and(Value::is_null);
732 if missing {
733 push(
734 issues,
735 Severity::Error,
736 missing_code,
737 rel,
738 fm_key_line_or_top(fm_yaml, key),
739 Some(key.into()),
740 format!("content file has no `{key}:` timestamp"),
741 Some(format!(
742 "set `{key}` to an RFC3339 timestamp, e.g. 2026-05-27T08:00:00-07:00"
743 )),
744 vec![],
745 );
746 } else if let Some(v) = value {
747 match scalar_string(v) {
753 Some(s) if is_iso8601(&s) => {}
754 Some(s) => push(
755 issues,
756 Severity::Error,
757 codes::FM_BAD_TIMESTAMP,
758 rel,
759 fm_key_line(fm_yaml, key),
760 Some(key.into()),
761 format!("`{key}` is not ISO-8601: {s:?}"),
762 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
763 vec![],
764 ),
765 None => push(
766 issues,
767 Severity::Error,
768 codes::FM_BAD_TIMESTAMP,
769 rel,
770 fm_key_line(fm_yaml, key),
771 Some(key.into()),
772 format!(
773 "`{key}` is not ISO-8601: expected a timestamp string, found a list or mapping"
774 ),
775 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
776 vec![],
777 ),
778 }
779 }
780 }
781 }
782 if let Some(tags) = fm.get("tags") {
784 if !is_flat_scalar_list(tags) {
785 push(
786 issues,
787 Severity::Warning,
788 codes::TAGS_MALFORMED,
789 rel,
790 fm_key_line(fm_yaml, "tags"),
791 Some("tags".into()),
792 "`tags` must be a flat YAML list of short scalar labels".into(),
793 Some("use block form: one `- <tag>` per line".into()),
794 vec![],
795 );
796 }
797 }
798
799 for key in detect_flow_form_link_lists(fm_yaml) {
801 push(
802 issues,
803 Severity::Error,
804 codes::WIKI_LINK_FLOW_FORM_LIST,
805 rel,
806 fm_key_line(fm_yaml, &key),
807 Some(key.clone()),
808 format!("`{key}` uses inline flow form `[[[a]], [[b]]]`"),
809 Some("use YAML block-sequence form: one `- [[...]]` per line".into()),
810 vec![],
811 );
812 }
813
814 let schema_link_keys: BTreeSet<String> =
819 effective_schema(store, type_.as_deref().unwrap_or(""))
820 .map(|s| {
821 s.fields
822 .iter()
823 .filter(|f| f.link_prefix.is_some())
824 .map(|f| f.name.clone())
825 .collect()
826 })
827 .unwrap_or_default();
828 for (key, link) in frontmatter_link_fields_text(fm_yaml, 2) {
829 if schema_link_keys.contains(&key) {
830 continue;
831 }
832 check_wiki_link(
833 store,
834 rel,
835 &link,
836 Some(link.line),
837 Some(&key),
838 basenames,
839 issues,
840 );
841 }
842
843 if let Some(t) = &type_ {
845 if store.config.ignored_types.iter().any(|it| it == t) {
846 push(
847 issues,
848 Severity::Info,
849 codes::POLICY_IGNORED_TYPE_PRESENT,
850 rel,
851 fm_key_line(fm_yaml, "type"),
852 Some("type".into()),
853 format!("file has ignored type `{t}` (per DB.md ## Policies)"),
854 Some(
855 "change the `type`, or remove it from DB.md `### Ignored types` if it should be managed"
856 .into(),
857 ),
858 vec![PathBuf::from("DB.md")],
860 );
861 }
862 let meta_type = fm
868 .get("meta-type")
869 .and_then(scalar_string)
870 .unwrap_or_else(|| "fact".to_string());
871 for link in frontmatter_links_for_key(fm_yaml, "derived_from", 2) {
872 if let Some(hit) =
873 derived_from_ignored_type(store, &meta_type, std::iter::once(link.target.as_str()))
874 {
875 push(
876 issues,
877 Severity::Warning,
878 codes::POLICY_IGNORED_TYPE_DERIVED,
879 rel,
880 Some(link.line),
881 Some("derived_from".into()),
882 format!(
883 "conclusion record derives from ignored-type record `{}` (type `{}`)",
884 hit.target, hit.target_type
885 ),
886 Some(
887 "drop this `derived_from` link, or remove the target type from DB.md `### Ignored types`"
888 .into(),
889 ),
890 vec![
893 PathBuf::from(format!("{}.md", hit.target)),
894 PathBuf::from("DB.md"),
895 ],
896 );
897 }
898 }
899 }
900
901 if let Some(t) = &type_ {
903 if let Some(schema) = effective_schema(store, t) {
904 check_schema(store, rel, fm, fm_yaml, &schema, issues);
905 }
906 }
907}
908
909fn check_summary(rel: &Path, fm: &BTreeMap<String, Value>, fm_yaml: &str, issues: &mut Vec<Issue>) {
911 let line = fm_key_line(fm_yaml, "summary");
912 match fm.get("summary") {
913 None => push(
914 issues,
915 Severity::Error,
916 codes::SUMMARY_MISSING,
917 rel,
918 fm_key_line_or_top(fm_yaml, "summary"),
921 Some("summary".into()),
922 "content file has no `summary`".into(),
923 Some("run `dbmd fm init`".into()),
924 vec![],
925 ),
926 Some(v) => {
927 let s = scalar_string(v).unwrap_or_default();
928 if s.trim().is_empty() {
929 push(
930 issues,
931 Severity::Error,
932 codes::SUMMARY_EMPTY,
933 rel,
934 line,
935 Some("summary".into()),
936 "`summary` is present but empty".into(),
937 Some("write a one-line summary, or run `dbmd fm init`".into()),
938 vec![],
939 );
940 } else if s.contains('\n') {
941 push(
942 issues,
943 Severity::Error,
944 codes::SUMMARY_MULTILINE,
945 rel,
946 line,
947 Some("summary".into()),
948 "`summary` must be one line (contains a newline)".into(),
949 Some("collapse the summary to a single line".into()),
950 vec![],
951 );
952 } else if s.chars().count() > MAX_SUMMARY_LEN {
953 push(
954 issues,
955 Severity::Warning,
956 codes::SUMMARY_TOO_LONG,
957 rel,
958 line,
959 Some("summary".into()),
960 format!(
961 "`summary` is {} chars (> {MAX_SUMMARY_LEN})",
962 s.chars().count()
963 ),
964 Some(format!("trim the summary to ≤ {MAX_SUMMARY_LEN} chars")),
965 vec![],
966 );
967 }
968 }
969 }
970}
971
972fn check_body_wiki_links(
974 store: &Store,
975 rel: &Path,
976 body: &str,
977 fm_end_line: u32,
978 basenames: Option<&BasenameIndex>,
979 issues: &mut Vec<Issue>,
980) {
981 for link in extract_wiki_links(body) {
982 let abs_line = fm_end_line + link.line;
985 check_wiki_link(store, rel, &link, Some(abs_line), None, basenames, issues);
986 }
987}
988
989type BasenameIndex = HashMap<String, Vec<PathBuf>>;
997
998fn build_basename_index(files: &[PathBuf]) -> BasenameIndex {
1001 let mut idx: BasenameIndex = HashMap::new();
1002 for rel in files {
1003 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
1004 idx.entry(stem.to_string()).or_default().push(rel.clone());
1005 }
1006 }
1007 idx
1008}
1009
1010fn check_wiki_link(
1015 store: &Store,
1016 rel: &Path,
1017 link: &Link,
1018 line: Option<u32>,
1019 key: Option<&str>,
1020 basenames: Option<&BasenameIndex>,
1021 issues: &mut Vec<Issue>,
1022) {
1023 let bare = link.target.trim_end_matches(".md");
1024
1025 if !is_full_store_path(bare) {
1028 if !bare.contains('/') {
1033 if let Some(idx) = basenames {
1034 if let Some(matches) = idx.get(bare) {
1035 if matches.len() >= 2 {
1036 let mut related = matches.clone();
1037 related.sort();
1038 push(
1039 issues,
1040 Severity::Error,
1041 codes::WIKI_LINK_AMBIGUOUS,
1042 rel,
1043 line,
1044 key.map(str::to_string),
1045 format!(
1046 "short-form wiki-link `[[{}]]` matches multiple files",
1047 link.target
1048 ),
1049 Some("use the full store-relative path to disambiguate".into()),
1050 related,
1051 );
1052 return;
1053 }
1054 }
1055 }
1056 }
1057 push(
1058 issues,
1059 Severity::Error,
1060 codes::WIKI_LINK_SHORT_FORM,
1061 rel,
1062 line,
1063 key.map(str::to_string),
1064 format!(
1065 "wiki-link `[[{}]]` is not a full store-relative path",
1066 link.target
1067 ),
1068 short_form_suggestion(bare),
1069 vec![],
1070 );
1071 return;
1073 }
1074
1075 if link.target.ends_with(".md") {
1077 push(
1078 issues,
1079 Severity::Warning,
1080 codes::WIKI_LINK_HAS_EXTENSION,
1081 rel,
1082 line,
1083 key.map(str::to_string),
1084 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1085 Some(format!("drop the extension: [[{bare}]]")),
1086 vec![],
1087 );
1088 }
1089
1090 match resolve_wiki_target(store, bare) {
1095 TargetResolution::Exists => {}
1096 TargetResolution::Missing => push(
1097 issues,
1098 Severity::Error,
1099 codes::WIKI_LINK_BROKEN,
1100 rel,
1101 line,
1102 key.map(str::to_string),
1103 format!("wiki-link target `{bare}` doesn't exist"),
1104 Some(format!(
1105 "create `{bare}.md`, or point the link at an existing file"
1106 )),
1107 vec![],
1108 ),
1109 TargetResolution::Unsafe => push(
1110 issues,
1111 Severity::Error,
1112 codes::WIKI_LINK_BROKEN,
1113 rel,
1114 line,
1115 key.map(str::to_string),
1116 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1117 Some("use a full store-relative path under sources/ or records/".into()),
1118 vec![],
1119 ),
1120 }
1121}
1122
1123fn effective_schema(store: &Store, type_: &str) -> Option<Schema> {
1134 store.config.schemas.get(type_).cloned()
1135}
1136
1137fn check_schema(
1139 store: &Store,
1140 rel: &Path,
1141 fm: &BTreeMap<String, Value>,
1142 fm_yaml: &str,
1143 schema: &Schema,
1144 issues: &mut Vec<Issue>,
1145) {
1146 for spec in &schema.fields {
1147 let present = fm.get(&spec.name);
1148 let line = fm_key_line(fm_yaml, &spec.name);
1149
1150 let is_empty = match present {
1158 None => true,
1159 Some(v) => is_empty_value(v),
1160 };
1161 if spec.required && is_empty {
1162 push(
1163 issues,
1164 Severity::Error,
1165 codes::SCHEMA_MISSING_REQUIRED,
1166 rel,
1167 fm_key_line_or_top(fm_yaml, &spec.name),
1170 Some(spec.name.clone()),
1171 format!("required field `{}` is absent or empty", spec.name),
1172 Some(format!("set `{}` to a non-empty value", spec.name)),
1173 vec![],
1174 );
1175 continue;
1176 }
1177 let Some(value) = present else { continue };
1178
1179 let value_empty = value.is_null()
1185 || scalar_string(value)
1186 .map(|s| s.trim().is_empty())
1187 .unwrap_or(false);
1188 if !spec.required && value_empty {
1189 continue;
1190 }
1191
1192 if let Some(prefix) = &spec.link_prefix {
1195 check_schema_link(store, rel, &spec.name, fm_yaml, prefix, line, issues);
1196 continue; }
1198
1199 if (spec.shape.is_some() || spec.enum_values.is_some()) && scalar_string(value).is_none() {
1206 push(
1207 issues,
1208 Severity::Error,
1209 codes::SCHEMA_SHAPE_MISMATCH,
1210 rel,
1211 line,
1212 Some(spec.name.clone()),
1213 format!(
1214 "`{}` must be a scalar value, found a list or mapping",
1215 spec.name
1216 ),
1217 Some(format!("set `{}` to a single scalar value", spec.name)),
1218 vec![],
1219 );
1220 continue;
1221 }
1222
1223 if let Some(allowed) = &spec.enum_values {
1225 if let Some(s) = scalar_string(value) {
1226 if !allowed.iter().any(|a| a == &s) {
1227 push(
1228 issues,
1229 Severity::Error,
1230 codes::SCHEMA_ENUM_VIOLATION,
1231 rel,
1232 line,
1233 Some(spec.name.clone()),
1234 format!("`{}` value {s:?} not in enum {allowed:?}", spec.name),
1235 Some(format!("use one of: {}", allowed.join(", "))),
1236 vec![],
1237 );
1238 }
1239 }
1240 continue;
1241 }
1242
1243 if let Some(shape) = spec.shape {
1245 check_schema_shape(rel, &spec.name, value, shape, line, issues);
1246 }
1247 }
1248}
1249
1250fn check_schema_link(
1255 store: &Store,
1256 rel: &Path,
1257 field: &str,
1258 fm_yaml: &str,
1259 prefix: &Path,
1260 line: Option<u32>,
1261 issues: &mut Vec<Issue>,
1262) {
1263 let prefix_str = prefix.to_string_lossy();
1264 let prefix_str = prefix_str.trim_end_matches('/');
1265 let suggestion = |target_leaf: &str| {
1266 Some(format!(
1267 "expected `link to {prefix_str}/`; replace with [[{prefix_str}/{target_leaf}]]"
1268 ))
1269 };
1270
1271 let links = frontmatter_links_for_key(fm_yaml, field, 2);
1272 if links.is_empty() {
1273 let raw = frontmatter_raw_value_for_key(fm_yaml, field, 2).unwrap_or_default();
1275 let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
1276 let leaf = slugish(raw);
1277 push(
1278 issues,
1279 Severity::Error,
1280 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1281 rel,
1282 line,
1283 Some(field.to_string()),
1284 format!(
1285 "`{field}` is a plain string {raw:?}, expected a wiki-link under `{prefix_str}/`"
1286 ),
1287 suggestion(&leaf),
1288 vec![],
1289 );
1290 return;
1291 }
1292
1293 for link in links {
1294 if link.target.ends_with(".md") {
1295 let bare = link.target.trim_end_matches(".md");
1296 push(
1297 issues,
1298 Severity::Warning,
1299 codes::WIKI_LINK_HAS_EXTENSION,
1300 rel,
1301 Some(link.line),
1302 Some(field.to_string()),
1303 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1304 Some(format!("drop the extension: [[{bare}]]")),
1305 vec![],
1306 );
1307 }
1308 let bare = link.target.trim_end_matches(".md");
1309 if !path_under_prefix(bare, prefix_str) {
1310 let leaf = bare.rsplit('/').next().unwrap_or(bare);
1311 push(
1312 issues,
1313 Severity::Error,
1314 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1315 rel,
1316 line,
1317 Some(field.to_string()),
1318 format!("`{field}` target `{bare}` is not under `{prefix_str}/`"),
1319 suggestion(leaf),
1320 vec![],
1321 );
1322 } else {
1323 match resolve_wiki_target(store, bare) {
1328 TargetResolution::Exists => {}
1329 TargetResolution::Missing => push(
1330 issues,
1331 Severity::Error,
1332 codes::WIKI_LINK_BROKEN,
1333 rel,
1334 line,
1335 Some(field.to_string()),
1336 format!("wiki-link target `{bare}` doesn't exist"),
1337 Some(format!(
1338 "create `{bare}.md`, or point the link at an existing file"
1339 )),
1340 vec![],
1341 ),
1342 TargetResolution::Unsafe => push(
1343 issues,
1344 Severity::Error,
1345 codes::WIKI_LINK_BROKEN,
1346 rel,
1347 line,
1348 Some(field.to_string()),
1349 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1350 Some("use a full store-relative path under sources/ or records/".into()),
1351 vec![],
1352 ),
1353 }
1354 }
1355 }
1356}
1357
1358fn check_schema_shape(
1360 rel: &Path,
1361 field: &str,
1362 value: &Value,
1363 shape: Shape,
1364 line: Option<u32>,
1365 issues: &mut Vec<Issue>,
1366) {
1367 let s = scalar_string(value).unwrap_or_default();
1368 let ok = match shape {
1369 Shape::String => true, Shape::Int => value.is_i64() || value.is_u64() || s.trim().parse::<i64>().is_ok(),
1371 Shape::Bool => value.is_bool() || matches!(s.trim(), "true" | "false"),
1372 Shape::Date => is_iso8601_date_or_datetime(&s),
1373 Shape::Email => is_email(&s),
1374 Shape::Currency => is_currency(&s),
1375 Shape::Url => is_url(&s),
1376 };
1377 if !ok {
1378 push(
1379 issues,
1380 Severity::Error,
1381 codes::SCHEMA_SHAPE_MISMATCH,
1382 rel,
1383 line,
1384 Some(field.to_string()),
1385 format!("`{field}` value {s:?} doesn't match shape {shape:?}"),
1386 Some(shape_suggestion(shape)),
1387 vec![],
1388 );
1389 }
1390}
1391
1392fn check_duplicates(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
1411 let fm_yaml_of: HashMap<&PathBuf, &str> = parsed
1414 .iter()
1415 .map(|(rel, p)| (rel, p.fm_yaml.as_str()))
1416 .collect();
1417
1418 let mut by_id: HashMap<String, Vec<PathBuf>> = HashMap::new();
1420 for (rel, p) in parsed {
1421 if let Some(map) = &p.fm {
1422 if let Some(id) = map.get("id").and_then(scalar_string) {
1423 if !id.trim().is_empty() {
1424 by_id.entry(id).or_default().push(rel.clone());
1425 }
1426 }
1427 }
1428 }
1429 for (id, files) in &by_id {
1430 if files.len() > 1 {
1431 let (reported, related) = canonical_and_related(files);
1432 let line = fm_yaml_of.get(&reported).and_then(|y| fm_key_line(y, "id"));
1433 push(
1434 issues,
1435 Severity::Error,
1436 codes::DUP_ID,
1437 &reported,
1438 line,
1439 Some("id".into()),
1440 format!("id {id:?} is declared by more than one file"),
1441 Some("give each file a unique `id` (or drop it to derive from the path)".into()),
1442 related,
1443 );
1444 }
1445 }
1446
1447 for (type_name, schema) in &store.config.schemas {
1452 for key_fields in &schema.unique_keys {
1453 soft_dup(parsed, issues, type_name, key_fields, &fm_yaml_of);
1454 }
1455 }
1456}
1457
1458fn soft_dup(
1467 parsed: &[(PathBuf, Parsed)],
1468 issues: &mut Vec<Issue>,
1469 type_: &str,
1470 key_fields: &[String],
1471 fm_yaml_of: &HashMap<&PathBuf, &str>,
1472) {
1473 if key_fields.is_empty() {
1474 return;
1475 }
1476 let mut groups: HashMap<Vec<String>, Vec<PathBuf>> = HashMap::new();
1477 for (rel, p) in parsed {
1478 let is_type =
1479 p.fm.as_ref()
1480 .and_then(|m| m.get("type"))
1481 .and_then(scalar_string)
1482 .map(|t| t == type_)
1483 .unwrap_or(false);
1484 if !is_type {
1485 continue;
1486 }
1487 if let Some(key) = dedup_key(p, key_fields) {
1488 groups.entry(key).or_default().push(rel.clone());
1489 }
1490 }
1491 let mut collisions: Vec<(PathBuf, Vec<PathBuf>)> = groups
1494 .values()
1495 .filter(|files| files.len() > 1)
1496 .map(|files| canonical_and_related(files))
1497 .collect();
1498 collisions.sort_by(|a, b| a.0.cmp(&b.0));
1499
1500 let fields_disp = key_fields.join(", ");
1501 for (reported, related) in collisions {
1502 let (line, key) = if key_fields.len() == 1 {
1505 (
1506 fm_yaml_of
1507 .get(&reported)
1508 .and_then(|y| fm_key_line(y, &key_fields[0])),
1509 Some(key_fields[0].clone()),
1510 )
1511 } else {
1512 (Some(1), None)
1513 };
1514 let n = related.len();
1515 push(
1516 issues,
1517 Severity::Warning,
1518 codes::DUP_UNIQUE_KEY,
1519 &reported,
1520 line,
1521 key,
1522 format!("`{type_}` unique key ({fields_disp}) collides with {n} other record(s)"),
1523 Some("merge with `dbmd rename`, or cross-link with `dbmd link`".into()),
1524 related,
1525 );
1526 }
1527}
1528
1529fn dedup_key(p: &Parsed, key_fields: &[String]) -> Option<Vec<String>> {
1533 let mut out = Vec::with_capacity(key_fields.len());
1534 for f in key_fields {
1535 out.push(dedup_token(p, f)?);
1536 }
1537 Some(out)
1538}
1539
1540fn dedup_token(p: &Parsed, field: &str) -> Option<String> {
1545 let links = frontmatter_links_for_key(&p.fm_yaml, field, 2);
1548 if !links.is_empty() {
1549 let set: BTreeSet<String> = links
1550 .into_iter()
1551 .map(|l| l.target.trim_end_matches(".md").to_lowercase())
1552 .filter(|t| !t.is_empty())
1553 .collect();
1554 return if set.is_empty() {
1555 None
1556 } else {
1557 Some(set.into_iter().collect::<Vec<_>>().join(","))
1558 };
1559 }
1560 match p.fm.as_ref()?.get(field) {
1561 Some(Value::Sequence(items)) => {
1562 let set: BTreeSet<String> = items
1563 .iter()
1564 .filter_map(scalar_string)
1565 .map(|s| s.trim().to_lowercase())
1566 .filter(|t| !t.is_empty())
1567 .collect();
1568 if set.is_empty() {
1569 None
1570 } else {
1571 Some(set.into_iter().collect::<Vec<_>>().join(","))
1572 }
1573 }
1574 Some(v) => {
1575 let s = scalar_string(v)?.trim().to_lowercase();
1576 if s.is_empty() {
1577 None
1578 } else {
1579 Some(s)
1580 }
1581 }
1582 None => None,
1583 }
1584}
1585
1586fn canonical_and_related(files: &[PathBuf]) -> (PathBuf, Vec<PathBuf>) {
1591 let mut sorted = files.to_vec();
1592 sorted.sort();
1593 let reported = sorted[0].clone();
1594 let related = sorted[1..].to_vec();
1595 (reported, related)
1596}
1597
1598fn check_indexes(store: &Store, files: &[PathBuf], issues: &mut Vec<Issue>) {
1604 let mut type_folders: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1608 for rel in files {
1609 if let Some(tf) = type_folder_of(rel) {
1610 type_folders.entry(tf).or_default().push(rel.clone());
1611 }
1612 }
1613
1614 let mut layers_with_type_folders: BTreeSet<&'static str> = BTreeSet::new();
1626 for tf in type_folders.keys() {
1627 match tf.iter().next().and_then(|s| s.to_str()) {
1628 Some("sources") => {
1629 layers_with_type_folders.insert("sources");
1630 }
1631 Some("records") => {
1632 layers_with_type_folders.insert("records");
1633 }
1634 _ => {}
1635 }
1636 }
1637
1638 if !type_folders.is_empty() {
1640 let root_index = store.root.join("index.md");
1641 if !root_index.is_file() || !store.owns_path(&root_index) {
1642 push(
1643 issues,
1644 Severity::Error,
1645 codes::INDEX_MISSING,
1646 Path::new("index.md"),
1647 None,
1648 None,
1649 "store has files but no root `index.md`".into(),
1650 Some("run `dbmd index rebuild`".into()),
1651 vec![],
1652 );
1653 } else {
1654 check_index_scope(store, Path::new("index.md"), "root", None, issues);
1655 }
1656 }
1657
1658 for layer in &layers_with_type_folders {
1660 let layer_index_rel = PathBuf::from(layer).join("index.md");
1661 let abs = store.root.join(&layer_index_rel);
1662 if !abs.is_file() || !store.owns_path(&abs) {
1663 push(
1664 issues,
1665 Severity::Error,
1666 codes::INDEX_MISSING,
1667 &layer_index_rel,
1668 None,
1669 None,
1670 format!("layer `{layer}/` has files but no `index.md`"),
1671 Some("run `dbmd index rebuild`".into()),
1672 vec![],
1673 );
1674 } else {
1675 check_index_scope(store, &layer_index_rel, "layer", Some(layer), issues);
1676 }
1677 }
1678
1679 for (tf, members) in &type_folders {
1681 let index_md_rel = tf.join("index.md");
1682 let index_md_abs = store.root.join(&index_md_rel);
1683 let index_md_present = index_md_abs.is_file() && store.owns_path(&index_md_abs);
1684 if !index_md_present {
1685 push(
1691 issues,
1692 Severity::Error,
1693 codes::INDEX_MISSING,
1694 tf,
1695 None,
1696 None,
1697 format!("non-empty folder `{}` has no index.md", tf.display()),
1698 Some(format!(
1699 "run `dbmd index rebuild --folder {}`",
1700 tf.display()
1701 )),
1702 vec![],
1703 );
1704 continue;
1705 }
1706
1707 check_index_scope(store, &index_md_rel, "type-folder", tf.to_str(), issues);
1708 check_type_folder_index_md(store, tf, &index_md_rel, members, issues);
1709
1710 let jsonl_rel = tf.join("index.jsonl");
1714 let jsonl_abs = store.root.join(&jsonl_rel);
1715 if !jsonl_abs.is_file() || !store.owns_path(&jsonl_abs) {
1716 push(
1717 issues,
1718 Severity::Error,
1719 codes::INDEX_JSONL_MISSING,
1720 &jsonl_rel,
1721 None,
1722 None,
1723 format!("type-folder `{}/` has no `index.jsonl` twin", tf.display()),
1724 Some("run `dbmd index rebuild`".into()),
1725 vec![],
1726 );
1727 } else {
1728 check_type_folder_index_jsonl(store, tf, &jsonl_rel, members, issues);
1729 }
1730 }
1731
1732 let mut loose_by_layer: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1740 for rel in files {
1741 if !is_content_file(rel) || type_folder_of(rel).is_some() {
1742 continue;
1743 }
1744 if let Some(layer_dir) = loose_layer_dir(rel) {
1745 loose_by_layer
1746 .entry(layer_dir)
1747 .or_default()
1748 .push(rel.clone());
1749 }
1750 }
1751 for (layer_dir, members) in &loose_by_layer {
1752 let jsonl_rel = layer_dir.join("index.jsonl");
1753 let jsonl_abs = store.root.join(&jsonl_rel);
1754 if !jsonl_abs.is_file() || !store.owns_path(&jsonl_abs) {
1755 push(
1756 issues,
1757 Severity::Error,
1758 codes::INDEX_JSONL_MISSING,
1759 &jsonl_rel,
1760 None,
1761 None,
1762 format!(
1763 "loose files at `{}/` are not catalogued — the layer has no `index.jsonl`",
1764 layer_dir.display()
1765 ),
1766 Some("run `dbmd index rebuild`".into()),
1767 members.clone(),
1768 );
1769 } else {
1770 check_type_folder_index_jsonl(store, layer_dir, &jsonl_rel, members, issues);
1774 }
1775 }
1776
1777 for rel in walk_index_files(store) {
1779 let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
1780 let parent_str = parent.to_string_lossy().to_string();
1781 let is_canonical = parent_str.is_empty() || matches!(parent_str.as_str(), "sources" | "records")
1783 || type_folders.contains_key(&parent);
1784 if !is_canonical {
1785 push(
1786 issues,
1787 Severity::Warning,
1788 codes::INDEX_ORPHAN,
1789 &rel,
1790 None,
1791 None,
1792 format!(
1793 "`{}` sits in an empty or non-canonical folder",
1794 rel.display()
1795 ),
1796 Some("remove it, or run `dbmd index rebuild`".into()),
1797 vec![],
1798 );
1799 }
1800 }
1801}
1802
1803fn check_type_folder_index_md(
1807 store: &Store,
1808 tf: &Path,
1809 index_rel: &Path,
1810 members: &[PathBuf],
1811 issues: &mut Vec<Issue>,
1812) {
1813 let abs = store.root.join(index_rel);
1814 let Ok(text) = std::fs::read_to_string(&abs) else {
1815 return;
1816 };
1817 let entries = parse_index_entries(&text);
1818
1819 let listed: BTreeSet<PathBuf> = entries
1820 .iter()
1821 .map(|e| PathBuf::from(e.target.trim_end_matches(".md")))
1822 .collect();
1823
1824 for entry in &entries {
1826 let bare = entry.target.trim_end_matches(".md");
1827 let target_abs = match resolved_target_abs(store, bare) {
1830 Some(abs) => abs,
1831 None => {
1832 if matches!(resolve_wiki_target(store, bare), TargetResolution::Unsafe) {
1833 push(
1834 issues,
1835 Severity::Error,
1836 codes::INDEX_STALE_ENTRY,
1837 index_rel,
1838 Some(entry.line),
1839 None,
1840 format!("index entry `[[{bare}]]` is not a safe store-relative path"),
1841 Some("run `dbmd index rebuild`".into()),
1842 vec![],
1843 );
1844 } else {
1845 push(
1846 issues,
1847 Severity::Error,
1848 codes::INDEX_STALE_ENTRY,
1849 index_rel,
1850 Some(entry.line),
1851 None,
1852 format!("index entry `[[{bare}]]` points at a missing file"),
1853 Some("run `dbmd index rebuild`".into()),
1854 vec![PathBuf::from(format!("{bare}.md"))],
1858 );
1859 }
1860 continue;
1861 }
1862 };
1863 if let Some(expected) = read_summary(&target_abs) {
1870 match &entry.summary_text {
1871 Some(text_part)
1882 if crate::summary::collapse_whitespace(text_part)
1883 != crate::summary::collapse_whitespace(&expected) =>
1884 {
1885 push(
1886 issues,
1887 Severity::Error,
1888 codes::INDEX_SUMMARY_MISMATCH,
1889 index_rel,
1890 Some(entry.line),
1891 None,
1892 format!("index entry for `{bare}` text doesn't match the file's `summary`"),
1893 Some("run `dbmd index rebuild`".into()),
1894 vec![PathBuf::from(format!("{bare}.md"))],
1895 );
1896 }
1897 None if !expected.trim().is_empty() => {
1898 push(
1899 issues,
1900 Severity::Error,
1901 codes::INDEX_SUMMARY_MISMATCH,
1902 index_rel,
1903 Some(entry.line),
1904 None,
1905 format!("index entry for `{bare}` is missing its summary text (the file has a `summary`)"),
1906 Some("run `dbmd index rebuild`".into()),
1907 vec![PathBuf::from(format!("{bare}.md"))],
1908 );
1909 }
1910 _ => {}
1911 }
1912 }
1913 }
1914
1915 let content_members: Vec<&PathBuf> = members.iter().filter(|m| is_content_file(m)).collect();
1919 if content_members.len() <= 500 {
1920 for m in content_members {
1921 let bare = PathBuf::from(m.to_string_lossy().trim_end_matches(".md").to_string());
1922 if !listed.contains(&bare) {
1923 push(
1924 issues,
1925 Severity::Error,
1926 codes::INDEX_MISSING_ENTRY,
1927 index_rel,
1928 None,
1929 None,
1930 format!(
1931 "file `{}` is not listed in its folder's `index.md`",
1932 m.display()
1933 ),
1934 Some("run `dbmd index rebuild`".into()),
1935 vec![(*m).clone()],
1936 );
1937 }
1938 }
1939 }
1940 let _ = tf;
1941}
1942
1943fn check_type_folder_index_jsonl(
1947 store: &Store,
1948 tf: &Path,
1949 jsonl_rel: &Path,
1950 members: &[PathBuf],
1951 issues: &mut Vec<Issue>,
1952) {
1953 let abs = store.root.join(jsonl_rel);
1954 let Ok(text) = std::fs::read_to_string(&abs) else {
1955 return;
1956 };
1957
1958 let mut records: BTreeMap<PathBuf, serde_json::Value> = BTreeMap::new();
1960 for (i, line) in text.lines().enumerate() {
1961 let line = line.trim();
1962 if line.is_empty() {
1963 continue;
1964 }
1965 let rec: serde_json::Value = match serde_json::from_str(line) {
1966 Ok(v) => v,
1967 Err(e) => {
1968 push(
1969 issues,
1970 Severity::Error,
1971 codes::INDEX_JSONL_DESYNC,
1972 jsonl_rel,
1973 Some((i + 1) as u32),
1974 None,
1975 format!("`index.jsonl` line {} is not valid JSON: {e}", i + 1),
1976 Some("run `dbmd index rebuild`".into()),
1977 vec![],
1978 );
1979 continue;
1980 }
1981 };
1982 if let Some(path) = rec.get("path").and_then(|v| v.as_str()) {
1983 if !is_safe_store_relative_path(Path::new(path)) {
1984 push(
1985 issues,
1986 Severity::Error,
1987 codes::INDEX_JSONL_DESYNC,
1988 jsonl_rel,
1989 Some((i + 1) as u32),
1990 None,
1991 format!("`index.jsonl` record path `{path}` is not a safe store-relative path"),
1992 Some("run `dbmd index rebuild`".into()),
1993 vec![],
1994 );
1995 continue;
1996 }
1997 records.insert(PathBuf::from(path), rec);
1998 }
1999 }
2000
2001 let member_set: BTreeSet<PathBuf> = members
2002 .iter()
2003 .filter(|m| is_content_file(m))
2004 .cloned()
2005 .collect();
2006
2007 for path in records.keys() {
2009 let target_abs = store.root.join(path);
2010 if !target_abs.is_file() {
2011 push(
2012 issues,
2013 Severity::Error,
2014 codes::INDEX_JSONL_DESYNC,
2015 jsonl_rel,
2016 None,
2017 None,
2018 format!(
2019 "`index.jsonl` record points at missing file `{}`",
2020 path.display()
2021 ),
2022 Some("run `dbmd index rebuild`".into()),
2023 vec![],
2024 );
2025 }
2026 }
2027
2028 for m in &member_set {
2030 if !records.contains_key(m) {
2031 push(
2032 issues,
2033 Severity::Error,
2034 codes::INDEX_JSONL_DESYNC,
2035 jsonl_rel,
2036 None,
2037 None,
2038 format!(
2039 "file `{}` is missing from the complete `index.jsonl`",
2040 m.display()
2041 ),
2042 Some("run `dbmd index rebuild`".into()),
2043 vec![m.clone()],
2044 );
2045 }
2046 }
2047
2048 for (path, rec) in &records {
2062 let target_abs = store.root.join(path);
2063 if !target_abs.is_file() {
2064 continue;
2065 }
2066 let Ok(expected) = crate::index::IndexRecord::expected_from_file(&target_abs, path.clone())
2067 else {
2068 continue; };
2070 let Ok(expected_json) = serde_json::to_value(&expected) else {
2071 continue;
2072 };
2073 let (Some(have), Some(want)) = (rec.as_object(), expected_json.as_object()) else {
2074 continue;
2075 };
2076
2077 let mut mismatched_keys: BTreeSet<&str> = BTreeSet::new();
2080 for key in have.keys().chain(want.keys()) {
2081 if key == "path" {
2082 continue;
2083 }
2084 if have.get(key) != want.get(key) {
2085 mismatched_keys.insert(key);
2086 }
2087 }
2088
2089 if !mismatched_keys.is_empty() {
2090 let keys: Vec<&str> = mismatched_keys.into_iter().collect();
2091 push(
2092 issues,
2093 Severity::Error,
2094 codes::INDEX_JSONL_STALE,
2095 jsonl_rel,
2096 None,
2097 Some(keys.join(",")),
2098 format!(
2099 "`index.jsonl` record for `{}` is stale ({})",
2100 path.display(),
2101 keys.join(", ")
2102 ),
2103 Some("run `dbmd index rebuild`".into()),
2104 vec![path.clone()],
2105 );
2106 }
2107 }
2108 let _ = tf;
2109}
2110
2111fn check_index_scope(
2113 store: &Store,
2114 index_rel: &Path,
2115 expected_scope: &str,
2116 expected_folder: Option<&str>,
2117 issues: &mut Vec<Issue>,
2118) {
2119 let abs = store.root.join(index_rel);
2120 let Ok(text) = std::fs::read_to_string(&abs) else {
2121 return;
2122 };
2123 let Some((yaml, _, _)) = split_frontmatter(&text) else {
2124 return;
2125 };
2126 let Ok(Value::Mapping(map)) = serde_norway::from_str::<Value>(&yaml) else {
2127 return;
2128 };
2129 let fm = yaml_map_to_btree(&map);
2130
2131 if let Some(scope) = fm.get("scope").and_then(scalar_string) {
2132 let scope_ok =
2134 scope == expected_scope || (expected_scope == "type-folder" && scope == "folder");
2135 if !scope_ok {
2136 push(
2137 issues,
2138 Severity::Warning,
2139 codes::INDEX_WRONG_SCOPE,
2140 index_rel,
2141 fm_key_line(&yaml, "scope"),
2142 Some("scope".into()),
2143 format!(
2144 "index `scope: {scope}` doesn't match location (expected `{expected_scope}`)"
2145 ),
2146 Some(format!("set `scope: {expected_scope}`")),
2147 vec![],
2148 );
2149 }
2150 }
2151 if let Some(expected) = expected_folder {
2153 if let Some(folder) = fm.get("folder").and_then(scalar_string) {
2154 if folder.trim_end_matches('/') != expected.trim_end_matches('/') {
2155 push(
2156 issues,
2157 Severity::Warning,
2158 codes::INDEX_WRONG_SCOPE,
2159 index_rel,
2160 fm_key_line(&yaml, "folder"),
2161 Some("folder".into()),
2162 format!("index `folder: {folder}` doesn't match location `{expected}`"),
2163 Some(format!("set `folder: {expected}`")),
2164 vec![],
2165 );
2166 }
2167 }
2168 }
2169}
2170
2171fn check_log(store: &Store, issues: &mut Vec<Issue>) {
2190 let mut prev: Option<DateTime<FixedOffset>> = None;
2191 for rel in log_files_chronological(store) {
2192 check_log_file(store, &rel, &mut prev, issues);
2193 }
2194}
2195
2196fn log_files_chronological(store: &Store) -> Vec<PathBuf> {
2200 let mut files: Vec<PathBuf> = Vec::new();
2201 let archive_dir = store.root.join("log");
2202 if let Ok(entries) = std::fs::read_dir(&archive_dir) {
2203 let mut archives: Vec<PathBuf> = entries
2204 .flatten()
2205 .map(|e| e.path())
2206 .filter(|p| {
2207 p.is_file()
2208 && store.owns_path(p)
2209 && p.file_name()
2210 .and_then(|s| s.to_str())
2211 .and_then(|n| n.strip_suffix(".md"))
2212 .is_some_and(is_year_month_archive)
2213 })
2214 .filter_map(|p| p.strip_prefix(&store.root).ok().map(Path::to_path_buf))
2215 .collect();
2216 archives.sort();
2218 files.extend(archives);
2219 }
2220 let active = store.root.join("log.md");
2222 if active.is_file() && store.owns_path(&active) {
2223 files.push(PathBuf::from("log.md"));
2224 }
2225 files
2226}
2227
2228fn check_log_file(
2232 store: &Store,
2233 log_rel: &Path,
2234 prev: &mut Option<DateTime<FixedOffset>>,
2235 issues: &mut Vec<Issue>,
2236) {
2237 let abs = store.root.join(log_rel);
2238 if !store.owns_path(&abs) {
2239 return;
2240 }
2241 let Ok(text) = std::fs::read_to_string(&abs) else {
2242 return;
2243 };
2244
2245 for (i, line) in text.lines().enumerate() {
2246 if !line.starts_with("## [") {
2247 continue;
2248 }
2249 let line_no = (i + 1) as u32;
2250 match parse_log_header(line) {
2251 None => push(
2252 issues,
2253 Severity::Error,
2254 codes::LOG_BAD_TIMESTAMP,
2255 log_rel,
2256 Some(line_no),
2257 None,
2258 format!("log entry header has an unparseable timestamp: {line:?}"),
2259 Some("use `## [YYYY-MM-DD HH:MM] <kind> | <object>`".into()),
2260 vec![],
2261 ),
2262 Some((ts, kind, _object)) => {
2263 if !RECOGNIZED_LOG_KINDS.contains(&kind.as_str()) {
2264 push(
2265 issues,
2266 Severity::Warning,
2267 codes::LOG_UNKNOWN_KIND,
2268 log_rel,
2269 Some(line_no),
2270 None,
2271 format!("log entry kind `{kind}` is not recognized"),
2272 Some(format!("use one of: {}", RECOGNIZED_LOG_KINDS.join(", "))),
2273 vec![],
2274 );
2275 }
2276 if let Some(p) = *prev {
2277 if ts < p {
2278 push(
2279 issues,
2280 Severity::Warning,
2281 codes::LOG_OUT_OF_ORDER,
2282 log_rel,
2283 Some(line_no),
2284 None,
2285 "log entry is older than the entry above it (possible rewrite)".into(),
2286 Some("append corrective entries; never reorder past ones".into()),
2287 vec![],
2288 );
2289 }
2290 }
2291 *prev = Some(ts);
2292 }
2293 }
2294 }
2295}
2296
2297#[derive(Debug)]
2303struct Link {
2304 target: String,
2305 line: u32,
2306}
2307
2308fn store_marker_present(store: &Store) -> bool {
2312 Store::is_db_md_store(&store.root)
2313}
2314
2315fn check_db_md(store: &Store, issues: &mut Vec<Issue>) {
2326 let rel = Path::new("DB.md");
2327 let abs = store.root.join("DB.md");
2328 let Ok(owned) = crate::store::ensure_path_within_store(&store.root, &abs) else {
2329 return;
2330 };
2331 let Ok(text) = std::fs::read_to_string(&owned) else {
2332 return; };
2334
2335 let Some((fm_yaml, body, fm_end_line)) = split_frontmatter(&text) else {
2336 push(
2340 issues,
2341 Severity::Error,
2342 codes::DB_MD_BAD_TYPE,
2343 rel,
2344 Some(1),
2345 Some("type".into()),
2346 "DB.md has no frontmatter; it must declare `type: db-md`".into(),
2347 Some("add a `---` frontmatter block with `type: db-md`".into()),
2348 vec![],
2349 );
2350 for field in ["scope", "owner"] {
2351 push(
2352 issues,
2353 Severity::Error,
2354 codes::DB_MD_MISSING_FIELD,
2355 rel,
2356 Some(1),
2357 Some(field.into()),
2358 format!("DB.md frontmatter is missing required field `{field}`"),
2359 Some(format!("add `{field}:` to the DB.md frontmatter")),
2360 vec![],
2361 );
2362 }
2363 return;
2364 };
2365
2366 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
2369 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
2370 Ok(Value::Null) => Some(BTreeMap::new()),
2371 _ => None,
2372 };
2373
2374 match &fm {
2375 Some(map) => {
2376 let type_ = map.get("type").and_then(scalar_string);
2378 if type_.as_deref() != Some("db-md") {
2379 let (line, msg) = match &type_ {
2380 Some(t) => (
2381 fm_key_line(&fm_yaml, "type"),
2382 format!("DB.md has `type: {t}`; a store's DB.md must be `type: db-md`"),
2383 ),
2384 None => (
2385 Some(1),
2386 "DB.md frontmatter has no `type:`; it must be `type: db-md`".to_string(),
2387 ),
2388 };
2389 push(
2390 issues,
2391 Severity::Error,
2392 codes::DB_MD_BAD_TYPE,
2393 rel,
2394 line,
2395 Some("type".into()),
2396 msg,
2397 Some("set `type: db-md` in the DB.md frontmatter".into()),
2398 vec![],
2399 );
2400 }
2401
2402 for field in ["scope", "owner"] {
2404 let present = map
2405 .get(field)
2406 .and_then(scalar_string)
2407 .map(|s| !s.trim().is_empty())
2408 .unwrap_or(false);
2409 if !present {
2410 push(
2411 issues,
2412 Severity::Error,
2413 codes::DB_MD_MISSING_FIELD,
2414 rel,
2415 fm_key_line_or_top(&fm_yaml, field),
2418 Some(field.into()),
2419 format!("DB.md frontmatter is missing required field `{field}`"),
2420 Some(format!("add `{field}:` to the DB.md frontmatter")),
2421 vec![],
2422 );
2423 }
2424 }
2425 }
2426 None => {
2427 push(
2430 issues,
2431 Severity::Error,
2432 codes::DB_MD_BAD_TYPE,
2433 rel,
2434 Some(1),
2435 Some("type".into()),
2436 "DB.md frontmatter isn't valid YAML; it must declare `type: db-md`".into(),
2437 Some("fix the DB.md frontmatter and set `type: db-md`".into()),
2438 vec![],
2439 );
2440 for field in ["scope", "owner"] {
2441 push(
2442 issues,
2443 Severity::Error,
2444 codes::DB_MD_MISSING_FIELD,
2445 rel,
2446 Some(1),
2447 Some(field.into()),
2448 format!("DB.md frontmatter is missing required field `{field}`"),
2449 Some(format!("add `{field}:` to the DB.md frontmatter")),
2450 vec![],
2451 );
2452 }
2453 }
2454 }
2455
2456 for section in crate::parser::extract_sections(&body) {
2470 if section.level != 2 {
2471 continue;
2472 }
2473 let name = section.heading.trim().to_ascii_lowercase();
2474 if matches!(
2475 name.as_str(),
2476 "agent instructions" | "policies" | "schemas" | "folders"
2477 ) {
2478 continue;
2479 }
2480 let file_line = fm_end_line + section.line;
2483 push(
2484 issues,
2485 Severity::Warning,
2486 codes::DB_MD_UNKNOWN_SECTION,
2487 rel,
2488 Some(file_line),
2489 None,
2490 format!(
2491 "DB.md has an unrecognized `## {}` section",
2492 section.heading.trim()
2493 ),
2494 Some(
2495 "DB.md sections are `## Agent instructions`, `## Policies`, `## Schemas`, \
2496 `## Folders` — remove or rename this heading"
2497 .into(),
2498 ),
2499 vec![],
2500 );
2501 }
2502
2503 check_db_md_schemas(store, rel, &body, fm_end_line, issues);
2508}
2509
2510fn check_db_md_schemas(
2517 store: &Store,
2518 rel: &Path,
2519 body: &str,
2520 fm_end_line: u32,
2521 issues: &mut Vec<Issue>,
2522) {
2523 if store.config.schemas.is_empty() {
2524 return;
2525 }
2526
2527 let mut type_line: BTreeMap<String, u32> = BTreeMap::new();
2532 let mut current_h2: Option<String> = None;
2533 for section in crate::parser::extract_sections(body) {
2534 match section.level {
2535 2 => current_h2 = Some(section.heading.trim().to_ascii_lowercase()),
2536 3 if current_h2.as_deref() == Some("schemas") => {
2537 type_line
2540 .entry(section.heading.trim().to_string())
2541 .or_insert(fm_end_line + section.line);
2542 }
2543 _ => {}
2544 }
2545 }
2546
2547 for (type_name, schema) in &store.config.schemas {
2548 let line = type_line.get(type_name).copied();
2549 let mut seen: BTreeSet<String> = BTreeSet::new();
2550 for field in &schema.fields {
2551 let name = field.name.trim();
2552
2553 if name.is_empty() {
2557 push(
2558 issues,
2559 Severity::Warning,
2560 codes::DB_MD_SCHEMA_FIELD,
2561 rel,
2562 line,
2563 None,
2564 format!("`### {type_name}` has a schema field bullet with no field name"),
2565 Some(
2566 "write each field as `- <name> (<modifiers>)`, e.g. `- email (required, email)`"
2567 .into(),
2568 ),
2569 vec![],
2570 );
2571 continue;
2572 }
2573
2574 if !seen.insert(name.to_string()) {
2578 push(
2579 issues,
2580 Severity::Warning,
2581 codes::DB_MD_SCHEMA_FIELD,
2582 rel,
2583 line,
2584 Some(name.to_string()),
2585 format!("`### {type_name}` declares field `{name}` more than once"),
2586 Some(
2587 "remove the duplicate field bullet, or merge the modifiers onto one".into(),
2588 ),
2589 vec![],
2590 );
2591 }
2592
2593 for modifier in &field.unknown_modifiers {
2598 let modifier = modifier.trim();
2599 if modifier.is_empty() {
2600 continue;
2601 }
2602 push(
2603 issues,
2604 Severity::Info,
2605 codes::DB_MD_SCHEMA_FIELD,
2606 rel,
2607 line,
2608 Some(name.to_string()),
2609 format!(
2610 "`### {type_name}` field `{name}` has an unrecognized modifier `{modifier}`"
2611 ),
2612 Some(
2613 "recognized modifiers are `required`, a shape (`string`/`int`/`bool`/`date`/`email`/`currency`/`url`), `link to <prefix>/`, `default <value>`, `enum: <v1>, <v2>, …`"
2614 .into(),
2615 ),
2616 vec![],
2617 );
2618 }
2619 }
2620
2621 let mut declared: BTreeMap<&str, bool> = BTreeMap::new();
2630 for f in &schema.fields {
2631 let e = declared.entry(f.name.trim()).or_insert(false);
2632 *e = *e || f.required;
2633 }
2634 let mut flagged: BTreeSet<&str> = BTreeSet::new();
2635 for key_fields in &schema.unique_keys {
2636 for field in key_fields {
2637 let name = field.trim();
2638 if name.is_empty()
2639 || declared.get(name).copied() == Some(true)
2640 || !flagged.insert(name)
2641 {
2642 continue;
2643 }
2644 let message = if declared.contains_key(name) {
2645 format!(
2646 "`### {type_name}` `unique:` key field `{name}` is not `required` — a record missing or leaving it empty is silently skipped by the unique check"
2647 )
2648 } else {
2649 format!(
2650 "`### {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"
2651 )
2652 };
2653 push(
2654 issues,
2655 Severity::Warning,
2656 codes::DB_MD_SCHEMA_FIELD,
2657 rel,
2658 line,
2659 Some(name.to_string()),
2660 message,
2661 Some(format!(
2662 "mark `{name}` `required` in `### {type_name}`, or build the `unique:` key from required fields only"
2663 )),
2664 vec![],
2665 );
2666 }
2667 }
2668 }
2669}
2670
2671fn not_a_store_issue(store: &Store) -> Issue {
2673 Issue {
2674 severity: Severity::Error,
2675 code: codes::NOT_A_STORE,
2676 file: store.root.clone(),
2677 line: None,
2678 key: None,
2679 message: format!("{} has no DB.md; not a db.md store", store.root.display()),
2680 suggestion: Some("create a `DB.md` at the store root".into()),
2681 related: vec![],
2682 }
2683}
2684
2685fn is_content_file(rel: &Path) -> bool {
2688 if !is_safe_store_relative_path(rel) {
2694 return false;
2695 }
2696 let Some(first) = rel.iter().next().and_then(|s| s.to_str()) else {
2697 return false;
2698 };
2699 if !matches!(first, "sources" | "records") {
2700 return false;
2701 }
2702 let name = rel.file_name().and_then(|s| s.to_str()).unwrap_or("");
2703 if matches!(name, "index.md" | "index.jsonl") {
2707 return false;
2708 }
2709 name.ends_with(".md")
2710}
2711
2712fn is_root_meta_file(rel: &Path) -> bool {
2719 let mut comps = rel.components();
2720 let Some(Component::Normal(only)) = comps.next() else {
2721 return false;
2722 };
2723 if comps.next().is_some() {
2724 return false; }
2726 matches!(only.to_str(), Some("DB.md") | Some("log.md"))
2727}
2728
2729fn is_index_catalog_file(rel: &Path) -> bool {
2737 matches!(
2738 rel.file_name().and_then(|n| n.to_str()),
2739 Some("index.md") | Some("index.jsonl")
2740 )
2741}
2742
2743fn split_frontmatter(text: &str) -> Option<(String, String, u32)> {
2747 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
2752 let mut lines = text.lines();
2753 let first = lines.next()?;
2754 if first.trim_end() != "---" {
2755 return None;
2756 }
2757 let mut yaml = String::new();
2758 let mut close_line: Option<u32> = None;
2759 let mut current = 1u32;
2761 for line in lines {
2762 current += 1;
2763 if line.trim_end() == "---" {
2764 close_line = Some(current);
2765 break;
2766 }
2767 yaml.push_str(line);
2768 yaml.push('\n');
2769 }
2770 let close_line = close_line?;
2771 let body: String = text
2773 .lines()
2774 .skip(close_line as usize)
2775 .collect::<Vec<_>>()
2776 .join("\n");
2777 Some((yaml, body, close_line))
2778}
2779
2780fn body_opens_with_frontmatter(body: &str) -> bool {
2788 let start: String = body
2789 .lines()
2790 .skip_while(|l| l.trim().is_empty())
2791 .collect::<Vec<_>>()
2792 .join("\n");
2793 match split_frontmatter(&start) {
2794 Some((yaml, _, _)) => matches!(
2795 serde_norway::from_str::<Value>(&yaml),
2796 Ok(Value::Mapping(m)) if !m.is_empty()
2797 ),
2798 None => false,
2799 }
2800}
2801
2802fn read_summary(abs: &Path) -> Option<String> {
2804 let text = std::fs::read_to_string(abs).ok()?;
2805 let (yaml, _, _) = split_frontmatter(&text)?;
2806 let value: Value = serde_norway::from_str(&yaml).ok()?;
2807 if let Value::Mapping(m) = value {
2808 m.get(Value::String("summary".into()))
2809 .and_then(scalar_string)
2810 } else {
2811 None
2812 }
2813}
2814
2815fn yaml_map_to_btree(map: &serde_norway::Mapping) -> BTreeMap<String, Value> {
2818 let mut out = BTreeMap::new();
2819 for (k, v) in map {
2820 if let Value::String(s) = k {
2821 out.insert(s.clone(), v.clone());
2822 }
2823 }
2824 out
2825}
2826
2827fn scalar_string(v: &Value) -> Option<String> {
2830 match v {
2831 Value::String(s) => Some(s.clone()),
2832 Value::Number(n) => Some(n.to_string()),
2833 Value::Bool(b) => Some(b.to_string()),
2834 _ => None,
2835 }
2836}
2837
2838fn is_empty_value(v: &Value) -> bool {
2845 match v {
2846 Value::Null => true,
2847 Value::Sequence(items) => items.is_empty(),
2848 Value::Mapping(map) => map.is_empty(),
2849 other => scalar_string(other)
2850 .map(|s| s.trim().is_empty())
2851 .unwrap_or(true),
2852 }
2853}
2854
2855fn is_flat_scalar_list(v: &Value) -> bool {
2858 match v {
2859 Value::Sequence(items) => items.iter().all(|it| scalar_string(it).is_some()),
2860 _ => false,
2861 }
2862}
2863
2864fn frontmatter_link_fields_text(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, Link)> {
2874 let mut out = Vec::new();
2875 for (key, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2876 for link in links {
2877 out.push((key.clone(), link));
2878 }
2879 }
2880 out
2881}
2882
2883fn frontmatter_links_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Vec<Link> {
2887 for (k, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2888 if k == key {
2889 return links;
2890 }
2891 }
2892 Vec::new()
2893}
2894
2895fn frontmatter_raw_value_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Option<String> {
2899 for (k, value_text, _links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2900 if k == key {
2901 return Some(value_text);
2902 }
2903 }
2904 None
2905}
2906
2907fn frontmatter_key_blocks(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, String, Vec<Link>)> {
2914 let mut blocks: Vec<(String, String, Vec<Link>)> = Vec::new();
2915 let mut current: Option<(String, String, Vec<Link>)> = None;
2916
2917 for (idx, raw_line) in fm_yaml.lines().enumerate() {
2918 let file_line = fm_start_line + idx as u32;
2919 let indented = raw_line.starts_with(' ') || raw_line.starts_with('\t');
2920 let trimmed = raw_line.trim();
2921
2922 let new_key = if !indented && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
2925 top_level_key(raw_line)
2926 } else {
2927 None
2928 };
2929
2930 if let Some((key, after)) = new_key {
2931 if let Some(done) = current.take() {
2932 blocks.push(done);
2933 }
2934 let mut links = Vec::new();
2935 collect_line_links(after, file_line, &mut links);
2936 current = Some((key, after.trim().to_string(), links));
2937 } else if let Some((_k, value_text, links)) = current.as_mut() {
2938 if !value_text.is_empty() {
2940 value_text.push('\n');
2941 }
2942 value_text.push_str(trimmed);
2943 collect_line_links(raw_line, file_line, links);
2944 }
2945 }
2946 if let Some(done) = current.take() {
2947 blocks.push(done);
2948 }
2949 blocks
2950}
2951
2952fn top_level_key(line: &str) -> Option<(String, &str)> {
2955 let (key, rest) = line.split_once(':')?;
2956 let key = key.trim();
2957 if key.is_empty()
2958 || !key
2959 .chars()
2960 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2961 {
2962 return None;
2963 }
2964 Some((key.to_string(), rest))
2965}
2966
2967fn collect_line_links(s: &str, file_line: u32, links: &mut Vec<Link>) {
2970 let bytes = s.as_bytes();
2971 let mut i = 0;
2972 while i + 1 < bytes.len() {
2973 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2974 if let Some(close) = s[i + 2..].find("]]") {
2975 let inner = &s[i + 2..i + 2 + close];
2976 let target = inner
2979 .trim_start_matches('[')
2980 .split('|')
2981 .next()
2982 .unwrap_or(inner)
2983 .trim()
2984 .to_string();
2985 if !target.is_empty() {
2986 links.push(Link {
2987 target,
2988 line: file_line,
2989 });
2990 }
2991 i = i + 2 + close + 2;
2992 continue;
2993 }
2994 }
2995 i += 1;
2996 }
2997}
2998
2999fn extract_wiki_links(body: &str) -> Vec<Link> {
3011 let mut out = Vec::new();
3012 let mut fence: Option<(u8, usize)> = None;
3013 for (idx, line) in body.lines().enumerate() {
3014 let content = line.trim_end_matches('\r');
3015 if let Some(f) = fence {
3016 if fence_closes(content, f) {
3020 fence = None;
3021 }
3022 continue;
3023 }
3024 if let Some(opened) = fence_opens(content) {
3025 fence = Some(opened);
3026 continue;
3027 }
3028 let line_no = (idx + 1) as u32;
3029 let bytes = line.as_bytes();
3030 let mut i = 0;
3031 while i + 1 < bytes.len() {
3032 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
3033 if let Some(close) = line[i + 2..].find("]]") {
3034 let inner = &line[i + 2..i + 2 + close];
3035 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3036 if !target.is_empty() && !target.starts_with('[') {
3044 out.push(Link {
3045 target,
3046 line: line_no,
3047 });
3048 }
3049 i = i + 2 + close + 2;
3050 continue;
3051 }
3052 }
3053 i += 1;
3054 }
3055 }
3056 out
3057}
3058
3059fn fence_opens(line: &str) -> Option<(u8, usize)> {
3065 let indent = line.len() - line.trim_start_matches(' ').len();
3066 if indent > 3 {
3067 return None;
3068 }
3069 let rest = &line[indent..];
3070 let byte = rest.bytes().next()?;
3071 if byte != b'`' && byte != b'~' {
3072 return None;
3073 }
3074 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3075 if run < 3 {
3076 return None;
3077 }
3078 if byte == b'`' && rest[run..].contains('`') {
3080 return None;
3081 }
3082 Some((byte, run))
3083}
3084
3085fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
3090 let (byte, open_len) = fence;
3091 let indent = line.len() - line.trim_start_matches(' ').len();
3092 if indent > 3 {
3093 return false;
3094 }
3095 let rest = &line[indent..];
3096 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3097 if run < open_len {
3098 return false;
3099 }
3100 rest[run..].trim().is_empty()
3101}
3102
3103fn detect_flow_form_link_lists(fm_yaml: &str) -> Vec<String> {
3120 let mut out = Vec::new();
3121 for line in fm_yaml.lines() {
3122 if line.starts_with(' ') || line.starts_with('\t') {
3124 continue;
3125 }
3126 let Some((key, rest)) = line.split_once(':') else {
3127 continue;
3128 };
3129 let key = key.trim();
3130 if key.is_empty()
3131 || key.starts_with('#')
3132 || key.starts_with('-')
3133 || !key
3134 .chars()
3135 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
3136 {
3137 continue;
3138 }
3139 let rest = rest.trim();
3140 if !rest.starts_with('[') {
3143 continue;
3144 }
3145 if let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(rest) {
3150 let nested = items.iter().any(|item| match item {
3151 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
3152 _ => false,
3153 });
3154 if nested {
3155 out.push(key.to_string());
3156 }
3157 }
3158 }
3159 out
3160}
3161
3162fn is_full_store_path(bare: &str) -> bool {
3165 let mut parts = bare.splitn(2, '/');
3166 let first = parts.next().unwrap_or("");
3167 let has_rest = parts.next().map(|r| !r.is_empty()).unwrap_or(false);
3168 matches!(first, "sources" | "records") && has_rest
3169}
3170
3171fn is_safe_store_relative_path(path: &Path) -> bool {
3175 let mut saw_component = false;
3176 for component in path.components() {
3177 match component {
3178 Component::Normal(_) => saw_component = true,
3179 Component::CurDir => {}
3180 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
3181 }
3182 }
3183 saw_component
3184}
3185
3186fn safe_md_target_rel(bare: &str) -> Option<PathBuf> {
3187 let path = Path::new(bare);
3188 if !is_safe_store_relative_path(path) {
3189 return None;
3190 }
3191 Some(PathBuf::from(format!("{bare}.md")))
3192}
3193
3194enum TargetResolution {
3196 Exists,
3198 Missing,
3200 Unsafe,
3202}
3203
3204fn resolve_wiki_target(store: &Store, bare: &str) -> TargetResolution {
3213 if !is_safe_store_relative_path(Path::new(bare)) {
3217 return TargetResolution::Unsafe;
3218 }
3219 match resolved_target_abs(store, bare) {
3220 Some(_) => TargetResolution::Exists,
3221 None => TargetResolution::Missing,
3222 }
3223}
3224
3225fn resolved_target_abs(store: &Store, bare: &str) -> Option<PathBuf> {
3251 if !is_safe_store_relative_path(Path::new(bare)) {
3252 return None;
3253 }
3254 let literal = store.root.join(bare);
3257 if literal.is_file() && store.owns_path(&literal) && disk_case_matches(store, &literal, bare) {
3258 return Some(literal);
3259 }
3260 let with_md_rel = format!("{bare}.md");
3262 let with_md = store.root.join(&with_md_rel);
3263 if with_md.is_file()
3264 && store.owns_path(&with_md)
3265 && disk_case_matches(store, &with_md, &with_md_rel)
3266 {
3267 return Some(with_md);
3268 }
3269 None
3270}
3271
3272fn disk_case_matches(store: &Store, abs: &Path, requested: &str) -> bool {
3289 let Ok(canon_abs) = abs.canonicalize() else {
3290 return true; };
3292 let Ok(canon_root) = store.root.canonicalize() else {
3297 return true;
3298 };
3299 let Ok(disk_rel) = canon_abs.strip_prefix(&canon_root) else {
3300 return true;
3305 };
3306 disk_rel == Path::new(requested)
3309}
3310
3311fn path_under_prefix(bare: &str, prefix: &str) -> bool {
3313 let prefix = prefix.trim_end_matches('/');
3314 bare == prefix || bare.starts_with(&format!("{prefix}/"))
3315}
3316
3317fn type_folder_of(rel: &Path) -> Option<PathBuf> {
3321 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3322 if comps.len() < 3 {
3323 return None; }
3325 if !matches!(comps[0], "sources" | "records") {
3326 return None;
3327 }
3328 Some(PathBuf::from(comps[0]).join(comps[1]))
3329}
3330
3331fn loose_layer_dir(rel: &Path) -> Option<PathBuf> {
3336 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3337 if comps.len() != 2 || !matches!(comps[0], "sources" | "records") {
3338 return None;
3339 }
3340 Some(PathBuf::from(comps[0]))
3341}
3342
3343fn walk_index_files(store: &Store) -> Vec<PathBuf> {
3348 let root = &store.root;
3349 let mut out = Vec::new();
3350 if root.join("index.md").is_file() {
3351 out.push(PathBuf::from("index.md"));
3352 }
3353 for layer in ["sources", "records"] {
3354 let base = root.join(layer);
3355 if !base.is_dir() {
3356 continue;
3357 }
3358 for entry in walkdir::WalkDir::new(&base)
3359 .follow_links(true)
3370 .into_iter()
3371 .filter_entry(|e| {
3372 let name = e.file_name().to_str().unwrap_or("");
3373 !name.starts_with('.') && store.owns_path(e.path())
3374 })
3375 .flatten()
3376 {
3377 if entry.file_type().is_file() && entry.file_name().to_str() == Some("index.md") {
3378 if let Ok(rel) = entry.path().strip_prefix(root) {
3379 out.push(rel.to_path_buf());
3380 }
3381 }
3382 }
3383 }
3384 out.sort();
3385 out
3386}
3387
3388struct IndexEntry {
3391 target: String,
3392 summary_text: Option<String>,
3393 line: u32,
3394}
3395
3396fn parse_index_entries(text: &str) -> Vec<IndexEntry> {
3401 let mut out = Vec::new();
3402 let mut in_more = false;
3403 for (idx, line) in text.lines().enumerate() {
3404 let trimmed = line.trim_start();
3405 if trimmed.starts_with("## More") {
3406 in_more = true;
3407 continue;
3408 }
3409 if in_more {
3410 continue;
3411 }
3412 if !trimmed.starts_with("- ") {
3413 continue;
3414 }
3415 let Some(open) = trimmed.find("[[") else {
3417 continue;
3418 };
3419 let Some(close_rel) = trimmed[open + 2..].find("]]") else {
3420 continue;
3421 };
3422 let inner = &trimmed[open + 2..open + 2 + close_rel];
3423 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3424
3425 let after = &trimmed[open + 2 + close_rel + 2..];
3427 let summary_text = extract_index_entry_summary(after);
3428
3429 out.push(IndexEntry {
3430 target,
3431 summary_text,
3432 line: (idx + 1) as u32,
3433 });
3434 }
3435 out
3436}
3437
3438fn extract_index_entry_summary(after: &str) -> Option<String> {
3444 let mut s = after.trim();
3445 if s.starts_with('(') {
3447 if let Some(close) = s.find(')') {
3448 s = s[close + 1..].trim_start();
3449 }
3450 }
3451 let s = s.strip_prefix('—').or_else(|| s.strip_prefix('-'))?.trim();
3453 if s.is_empty() {
3454 return None;
3455 }
3456 let s = match s.rsplit_once(" · ") {
3471 Some((summary, tags)) if is_tag_suffix(tags) => summary.trim(),
3472 _ => s,
3473 };
3474 Some(s.to_string())
3475}
3476
3477fn is_tag_suffix(s: &str) -> bool {
3482 let mut any = false;
3483 for tok in s.split_whitespace() {
3484 if !tok.starts_with('#') || tok.len() < 2 {
3485 return false;
3486 }
3487 any = true;
3488 }
3489 any
3490}
3491
3492fn parse_log_header(line: &str) -> Option<(DateTime<FixedOffset>, String, Option<String>)> {
3496 let rest = line.strip_prefix("## [")?;
3497 let close = rest.find(']')?;
3498 let ts_str = &rest[..close];
3499 let tail = rest[close + 1..].trim();
3500
3501 let naive = NaiveDateTime::parse_from_str(ts_str.trim(), "%Y-%m-%d %H:%M").ok()?;
3504 let offset = FixedOffset::east_opt(0)?;
3505 let ts = naive.and_local_timezone(offset).single()?;
3506
3507 let (kind, object) = match tail.split_once('|') {
3509 Some((k, o)) => {
3510 let o = o.trim();
3511 (
3512 k.trim().to_string(),
3513 if o.is_empty() {
3514 None
3515 } else {
3516 Some(o.to_string())
3517 },
3518 )
3519 }
3520 None => (tail.to_string(), None),
3521 };
3522 if kind.is_empty() {
3523 return None;
3524 }
3525 Some((ts, kind, object))
3526}
3527
3528fn log_files_for_working_set(store: &Store) -> Vec<PathBuf> {
3538 let mut files = vec![store.root.join("log.md")];
3539 let archive_dir = store.root.join("log");
3540 if let Ok(entries) = std::fs::read_dir(&archive_dir) {
3541 let mut archives: Vec<PathBuf> = entries
3542 .flatten()
3543 .map(|e| e.path())
3544 .filter(|p| {
3545 p.is_file()
3546 && store.owns_path(p)
3547 && p.file_name()
3548 .and_then(|s| s.to_str())
3549 .and_then(|n| n.strip_suffix(".md"))
3550 .is_some_and(is_year_month_archive)
3551 })
3552 .collect();
3553 archives.sort();
3557 files.extend(archives);
3558 }
3559 files.retain(|path| path.is_file() && store.owns_path(path));
3560 files
3561}
3562
3563fn is_year_month_archive(s: &str) -> bool {
3566 let b = s.as_bytes();
3567 b.len() == 7
3568 && b[..4].iter().all(u8::is_ascii_digit)
3569 && b[4] == b'-'
3570 && b[5..7].iter().all(u8::is_ascii_digit)
3571}
3572
3573fn last_validate_at(store: &Store) -> Option<DateTime<FixedOffset>> {
3579 let mut latest: Option<DateTime<FixedOffset>> = None;
3580 for file in log_files_for_working_set(store) {
3581 let Ok(text) = std::fs::read_to_string(&file) else {
3582 continue;
3583 };
3584 for line in text.lines() {
3585 if !line.starts_with("## [") {
3586 continue;
3587 }
3588 if let Some((ts, kind, _)) = parse_log_header(line) {
3589 if kind == "validate" {
3590 latest = Some(match latest {
3591 Some(p) if p >= ts => p,
3592 _ => ts,
3593 });
3594 }
3595 }
3596 }
3597 }
3598 latest
3599}
3600
3601fn changed_objects_since(
3612 store: &Store,
3613 cutoff: Option<DateTime<FixedOffset>>,
3614) -> BTreeSet<PathBuf> {
3615 let mut out = BTreeSet::new();
3616 for file in log_files_for_working_set(store) {
3617 let Ok(text) = std::fs::read_to_string(&file) else {
3618 continue;
3619 };
3620 for line in text.lines() {
3621 if !line.starts_with("## [") {
3622 continue;
3623 }
3624 let Some((ts, kind, object)) = parse_log_header(line) else {
3625 continue;
3626 };
3627 if let Some(c) = cutoff {
3628 if ts < c {
3629 continue;
3630 }
3631 }
3632 if !matches!(
3633 kind.as_str(),
3634 "create" | "update" | "ingest" | "rename" | "delete" | "link"
3635 ) {
3636 continue;
3637 }
3638 if let Some(obj) = object {
3639 let bare = obj
3641 .trim()
3642 .trim_start_matches("[[")
3643 .trim_end_matches("]]")
3644 .split('|')
3645 .next()
3646 .unwrap_or("")
3647 .trim()
3648 .trim_end_matches(".md")
3649 .to_string();
3650 if bare.is_empty() {
3651 continue;
3652 }
3653 if let Some(rel) = safe_md_target_rel(&bare) {
3663 out.insert(rel);
3664 }
3665 }
3666 }
3667 }
3668 out
3669}
3670
3671#[derive(Debug, Clone, PartialEq, Eq)]
3676pub struct DerivedFromIgnored {
3677 pub target: String,
3680 pub target_type: String,
3683}
3684
3685pub fn derived_from_ignored_type<I, S>(
3699 store: &Store,
3700 meta_type: &str,
3701 derived_from_targets: I,
3702) -> Option<DerivedFromIgnored>
3703where
3704 I: IntoIterator<Item = S>,
3705 S: AsRef<str>,
3706{
3707 if meta_type != "conclusion" || store.config.ignored_types.is_empty() {
3708 return None;
3709 }
3710 for target in derived_from_targets {
3711 let target = target.as_ref();
3712 if let Some(target_type) = link_target_type(store, target) {
3713 if store.config.ignored_types.contains(&target_type) {
3714 return Some(DerivedFromIgnored {
3715 target: target.to_string(),
3716 target_type,
3717 });
3718 }
3719 }
3720 }
3721 None
3722}
3723
3724fn link_target_type(store: &Store, target: &str) -> Option<String> {
3726 let bare = target.trim_end_matches(".md");
3727 let abs = store.root.join(safe_md_target_rel(bare)?);
3728 let text = std::fs::read_to_string(&abs).ok()?;
3729 let (yaml, _, _) = split_frontmatter(&text)?;
3730 let value: Value = serde_norway::from_str(&yaml).ok()?;
3731 if let Value::Mapping(m) = value {
3732 m.get(Value::String("type".into())).and_then(scalar_string)
3733 } else {
3734 None
3735 }
3736}
3737
3738fn is_iso8601(s: &str) -> bool {
3743 DateTime::parse_from_rfc3339(s.trim()).is_ok()
3744}
3745
3746fn is_iso8601_date_or_datetime(s: &str) -> bool {
3750 let s = s.trim();
3751 if DateTime::parse_from_rfc3339(s).is_ok() {
3752 return true;
3753 }
3754 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
3755}
3756
3757fn is_email(s: &str) -> bool {
3762 let s = s.trim();
3763 let Some((local, domain)) = s.split_once('@') else {
3764 return false;
3765 };
3766 !local.is_empty()
3767 && !domain.contains('@')
3768 && domain.contains('.')
3769 && !domain.starts_with('.')
3770 && !domain.ends_with('.')
3771 && !domain.contains(' ')
3772 && !local.contains(' ')
3773}
3774
3775fn is_currency(s: &str) -> bool {
3782 let mut t = s.trim();
3783 for sym in ["$", "€", "£", "¥"] {
3785 if let Some(rest) = t.strip_prefix(sym) {
3786 t = rest.trim_start();
3787 break;
3788 }
3789 }
3790 if let Some((head, rest)) = t.split_once(char::is_whitespace) {
3794 if head.len() == 3 && head.chars().all(|c| c.is_ascii_alphabetic()) {
3795 t = rest.trim_start();
3796 }
3797 }
3798
3799 let cleaned: String = t.chars().filter(|c| *c != ',').collect();
3800 is_plain_amount(cleaned.trim())
3801}
3802
3803fn is_plain_amount(s: &str) -> bool {
3806 let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
3807 let (int_part, frac_part) = match digits.split_once('.') {
3808 Some((i, f)) => (i, Some(f)),
3809 None => (digits, None),
3810 };
3811 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3812 return false;
3813 }
3814 match frac_part {
3815 None => true,
3816 Some(f) => (1..=2).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()),
3817 }
3818}
3819
3820fn is_url(s: &str) -> bool {
3826 let s = s.trim();
3827 for scheme in ["http://", "https://"] {
3828 if let Some(rest) = s.strip_prefix(scheme) {
3829 return !rest.is_empty();
3830 }
3831 }
3832 false
3833}
3834
3835fn shape_suggestion(shape: Shape) -> String {
3837 match shape {
3838 Shape::String => "use a scalar string".into(),
3839 Shape::Int => "use an integer".into(),
3840 Shape::Bool => "use `true` or `false`".into(),
3841 Shape::Date => "use an ISO-8601 date, e.g. 2026-05-27".into(),
3842 Shape::Email => "use a `<local>@<domain>` address".into(),
3843 Shape::Currency => "use a numeric amount, e.g. 1234.56".into(),
3844 Shape::Url => "use an http(s) URL".into(),
3845 }
3846}
3847
3848fn short_form_suggestion(bare: &str) -> Option<String> {
3851 Some(format!(
3852 "use a full store-relative path, e.g. [[records/contacts/{}]]",
3853 slugish(bare)
3854 ))
3855}
3856
3857fn slugish(s: &str) -> String {
3859 s.trim()
3860 .to_lowercase()
3861 .chars()
3862 .map(|c| if c.is_whitespace() { '-' } else { c })
3863 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == '_')
3864 .collect()
3865}
3866
3867fn check_assets(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
3873 use crate::assets;
3874
3875 let manifest_rel = Path::new(assets::MANIFEST_FILE);
3876 let manifest_abs = store.root.join(assets::MANIFEST_FILE);
3877
3878 let mut manifest: BTreeMap<String, assets::AssetRecord> = BTreeMap::new();
3880 if store.owns_path(&manifest_abs) {
3881 if let Ok(text) = std::fs::read_to_string(&manifest_abs) {
3882 for (i, line) in text.lines().enumerate() {
3883 if line.trim().is_empty() {
3884 continue;
3885 }
3886 match serde_json::from_str::<assets::AssetRecord>(line) {
3887 Ok(rec) => {
3888 manifest.insert(rec.path.clone(), rec);
3889 }
3890 Err(e) => push(
3891 issues,
3892 Severity::Error,
3893 codes::ASSET_MANIFEST_MALFORMED,
3894 manifest_rel,
3895 Some((i as u32) + 1),
3896 None,
3897 format!("invalid {} record: {e}", assets::MANIFEST_FILE),
3898 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3899 vec![],
3900 ),
3901 }
3902 }
3903 }
3904 }
3905
3906 let mut declared: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3909 for (rel, p) in parsed {
3910 let Some(map) = &p.fm else {
3911 continue;
3912 };
3913 for decl in assets::declarations_from_yaml_map(map) {
3914 let norm = match assets::normalize_asset_path(&decl.path) {
3915 Ok(n) => n,
3916 Err(_) => continue, };
3918 declared.insert(norm.clone());
3919 let is_md = Path::new(&norm)
3920 .extension()
3921 .and_then(|e| e.to_str())
3922 .map(|e| e.eq_ignore_ascii_case("md"))
3923 .unwrap_or(false);
3924 if is_md {
3925 push(
3926 issues,
3927 Severity::Warning,
3928 codes::ASSET_PATH_IS_CONTENT,
3929 rel,
3930 None,
3931 Some("asset".to_string()),
3932 format!("asset path `{norm}` points at a markdown content file"),
3933 Some("assets are raw binaries; reference a non-markdown path".to_string()),
3934 vec![PathBuf::from(&norm)],
3935 );
3936 }
3937 if !manifest.contains_key(&norm) {
3938 push(
3939 issues,
3940 Severity::Error,
3941 codes::ASSET_UNDECLARED,
3942 rel,
3943 None,
3944 Some("asset".to_string()),
3945 format!(
3946 "references asset `{norm}` with no record in {}",
3947 assets::MANIFEST_FILE
3948 ),
3949 Some("run `dbmd assets scan` to catalog it".to_string()),
3950 vec![PathBuf::from(&norm)],
3951 );
3952 }
3953 }
3954 }
3955
3956 for (path, rec) in &manifest {
3958 for w in &rec.wrappers {
3959 let wrapper = store.root.join(w);
3960 if !wrapper.is_file() || !store.owns_path(&wrapper) {
3961 push(
3962 issues,
3963 Severity::Error,
3964 codes::ASSET_WRAPPER_BROKEN,
3965 Path::new(path),
3966 None,
3967 None,
3968 format!("manifest record for `{path}` names a missing wrapper `{w}`"),
3969 Some("run `dbmd assets scan` to reconcile the manifest".to_string()),
3970 vec![PathBuf::from(w)],
3971 );
3972 }
3973 }
3974 if !declared.contains(path) {
3975 push(
3976 issues,
3977 Severity::Warning,
3978 codes::ASSET_MANIFEST_ORPHAN,
3979 Path::new(path),
3980 None,
3981 None,
3982 format!(
3983 "`{path}` is in {} but no wrapper references it",
3984 assets::MANIFEST_FILE
3985 ),
3986 Some("run `dbmd assets scan` to drop the orphan, or add a wrapper".to_string()),
3987 vec![],
3988 );
3989 }
3990 }
3991}
3992
3993#[allow(clippy::too_many_arguments)]
3995fn push(
3996 issues: &mut Vec<Issue>,
3997 severity: Severity,
3998 code: &'static str,
3999 file: &Path,
4000 line: Option<u32>,
4001 key: Option<String>,
4002 message: String,
4003 suggestion: Option<String>,
4004 related: Vec<PathBuf>,
4005) {
4006 issues.push(Issue {
4007 severity,
4008 code,
4009 file: file.to_path_buf(),
4010 line,
4011 key,
4012 message,
4013 suggestion,
4014 related,
4015 });
4016}
4017
4018fn fm_key_line(fm_yaml: &str, key: &str) -> Option<u32> {
4021 for (i, line) in fm_yaml.lines().enumerate() {
4022 let trimmed = line.trim_start();
4023 if let Some(rest) = trimmed.strip_prefix(key) {
4025 if rest.starts_with(':') && line.starts_with(key) {
4026 return Some((i as u32) + 2);
4028 }
4029 }
4030 }
4031 None
4032}
4033
4034fn fm_key_line_or_top(fm_yaml: &str, key: &str) -> Option<u32> {
4040 fm_key_line(fm_yaml, key).or(Some(1))
4041}
4042
4043fn issue_order(a: &Issue, b: &Issue) -> std::cmp::Ordering {
4046 a.file
4047 .cmp(&b.file)
4048 .then(a.line.cmp(&b.line))
4049 .then(a.code.cmp(b.code))
4050 .then(a.key.cmp(&b.key))
4051}
4052
4053#[cfg(test)]
4058mod tests {
4059 use super::*;
4060 use crate::parser::{Config, FieldSpec};
4061 use std::fs;
4062 use tempfile::TempDir;
4063
4064 #[test]
4065 fn split_frontmatter_tolerates_leading_bom() {
4066 let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody\n";
4071 let parsed = split_frontmatter(text);
4072 assert!(
4073 parsed.is_some(),
4074 "a leading BOM must not hide frontmatter from validate"
4075 );
4076 let (yaml, body, close_line) = parsed.unwrap();
4077 assert_eq!(yaml, "type: contact\nsummary: hi\n");
4078 assert_eq!(body, "body");
4079 assert_eq!(close_line, 4, "BOM is inline on line 1, not a new line");
4080 }
4081
4082 struct Fixture {
4085 dir: TempDir,
4086 config: Config,
4087 }
4088
4089 impl Fixture {
4090 fn new() -> Self {
4095 let dir = TempDir::new().unwrap();
4096 fs::write(
4097 dir.path().join("DB.md"),
4098 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
4099 )
4100 .unwrap();
4101 for layer in ["sources", "records"] {
4102 fs::create_dir_all(dir.path().join(layer)).unwrap();
4103 }
4104 Fixture {
4105 dir,
4106 config: Config::default(),
4107 }
4108 }
4109
4110 fn bare() -> Self {
4112 let dir = TempDir::new().unwrap();
4113 Fixture {
4114 dir,
4115 config: Config::default(),
4116 }
4117 }
4118
4119 fn write(&self, rel: &str, contents: &str) {
4121 let abs = self.dir.path().join(rel);
4122 fs::create_dir_all(abs.parent().unwrap()).unwrap();
4123 fs::write(abs, contents).unwrap();
4124 }
4125
4126 fn store(&self) -> Store {
4127 Store {
4128 root: self.dir.path().to_path_buf(),
4129 config: self.config.clone(),
4130 }
4131 }
4132
4133 fn store_all(&self) -> Vec<Issue> {
4134 validate_all(&self.store()).unwrap()
4135 }
4136
4137 fn rebuild_indexes(&self) {
4144 crate::index::Index::rebuild_all(&self.store()).unwrap();
4145 }
4146 }
4147
4148 fn has(issues: &[Issue], code: &str) -> bool {
4150 issues.iter().any(|i| i.code == code)
4151 }
4152
4153 fn count(issues: &[Issue], code: &str) -> usize {
4155 issues.iter().filter(|i| i.code == code).count()
4156 }
4157
4158 fn find<'a>(issues: &'a [Issue], code: &str) -> &'a Issue {
4160 issues
4161 .iter()
4162 .find(|i| i.code == code)
4163 .unwrap_or_else(|| panic!("expected an issue with code {code}; got {issues:#?}"))
4164 }
4165
4166 fn valid_contact(summary: &str) -> String {
4168 format!(
4169 "---\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"
4170 )
4171 }
4172
4173 #[test]
4176 fn not_a_store_when_db_md_absent() {
4177 let fx = Fixture::bare();
4178 let issues = fx.store_all();
4179 assert_eq!(issues.len(), 1, "only NOT_A_STORE expected: {issues:#?}");
4180 assert_eq!(issues[0].code, codes::NOT_A_STORE);
4181 assert!(issues[0].is_error());
4182 }
4183
4184 #[test]
4185 fn working_set_also_reports_not_a_store() {
4186 let fx = Fixture::bare();
4187 let issues = validate_working_set(&fx.store(), None).unwrap();
4188 assert!(has(&issues, codes::NOT_A_STORE));
4189 }
4190
4191 #[test]
4192 fn both_scopes_report_nested_store_without_validating_its_content() {
4193 let fx = Fixture::new();
4194 fx.write(
4195 "records/nested/DB.md",
4196 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
4197 );
4198 fx.write("records/nested/records/notes/bad.md", "not frontmatter");
4201
4202 for issues in [
4203 validate_working_set(&fx.store(), None).unwrap(),
4204 validate_all(&fx.store()).unwrap(),
4205 ] {
4206 assert_eq!(count(&issues, codes::NESTED_STORE), 1, "{issues:#?}");
4207 assert_eq!(
4208 find(&issues, codes::NESTED_STORE).file,
4209 PathBuf::from("records/nested/DB.md")
4210 );
4211 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4212 }
4213 }
4214
4215 #[test]
4216 fn clean_store_has_no_issues() {
4217 let fx = Fixture::new();
4218 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4219 fx.rebuild_indexes();
4223 let issues = fx.store_all();
4224 assert!(
4225 issues.is_empty(),
4226 "expected a clean store, got: {issues:#?}"
4227 );
4228 }
4229
4230 #[test]
4238 fn meta_type_enum_is_closed_for_scalars_and_non_scalars() {
4239 let fx = Fixture::new();
4240 let body = |mt: &str| {
4241 format!(
4242 "---\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"
4243 )
4244 };
4245
4246 for ok in ["fact", "operational", "conclusion"] {
4248 fx.write("records/profiles/ok.md", &body(ok));
4249 let issues = validate_working_set(&fx.store(), None).unwrap();
4250 assert!(
4251 !has(&issues, codes::FM_BAD_META_TYPE),
4252 "`meta-type: {ok}` must be accepted; got {issues:#?}"
4253 );
4254 }
4255 fx.write(
4256 "records/profiles/absent.md",
4257 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n",
4258 );
4259 assert!(
4260 !has(
4261 &validate_working_set(&fx.store(), None).unwrap(),
4262 codes::FM_BAD_META_TYPE
4263 ),
4264 "an absent meta-type is the default `fact` and must be accepted"
4265 );
4266
4267 for bad in ["xyz", "Fact", "[fact, conclusion]", "{kind: conclusion}"] {
4269 let fx2 = Fixture::new();
4270 fx2.write("records/profiles/bad.md", &body(bad));
4271 let issues = validate_working_set(&fx2.store(), None).unwrap();
4272 assert!(
4273 has(&issues, codes::FM_BAD_META_TYPE),
4274 "`meta-type: {bad}` must be rejected with FM_BAD_META_TYPE; got {issues:#?}"
4275 );
4276 }
4277 }
4278
4279 #[test]
4288 fn id_absent_slug_ulid_and_numeric_are_all_silent() {
4289 let body = |id_line: &str| {
4290 format!(
4291 "---\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"
4292 )
4293 };
4294 for (case, id_line) in [
4295 ("absent", ""),
4296 ("slug", "id: sarah-chen\n"),
4297 ("ulid", "id: 01j5qc3v9k4ym8rwbn2tqe6f7d\n"),
4298 ("numeric-scalar", "id: 100\n"),
4299 ] {
4300 let fx = Fixture::new();
4301 fx.write("records/contacts/a.md", &body(id_line));
4302 let issues = validate_working_set(&fx.store(), None).unwrap();
4303 assert!(
4304 !has(&issues, codes::FM_BAD_ID),
4305 "id case `{case}` must be silent; got {issues:#?}"
4306 );
4307 }
4308 }
4309
4310 #[test]
4315 fn id_unusable_as_identifier_warns_fm_bad_id() {
4316 let body = |id_line: &str| {
4317 format!(
4318 "---\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"
4319 )
4320 };
4321 for bad in [
4322 "id: \"\"",
4323 "id: \" \"",
4324 "id: two words",
4325 "id: [a, b]",
4326 "id: {k: v}",
4327 ] {
4328 let fx = Fixture::new();
4329 fx.write("records/contacts/a.md", &body(bad));
4330 let issues = validate_working_set(&fx.store(), None).unwrap();
4331 let issue = issues
4332 .iter()
4333 .find(|i| i.code == codes::FM_BAD_ID)
4334 .unwrap_or_else(|| panic!("`{bad}` must fire FM_BAD_ID; got {issues:#?}"));
4335 assert!(
4336 matches!(issue.severity, Severity::Warning),
4337 "FM_BAD_ID is a warning (additive v0.4 — it must never block a store): {issue:#?}"
4338 );
4339 assert_eq!(issue.key.as_deref(), Some("id"));
4340 assert!(
4341 !issue.is_error(),
4342 "FM_BAD_ID must not fail validation: {issue:#?}"
4343 );
4344 }
4345 }
4346
4347 #[test]
4351 fn dup_id_fires_on_shared_ulid_ids() {
4352 let fx = Fixture::new();
4353 let rec = |name: &str| {
4354 format!(
4355 "---\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"
4356 )
4357 };
4358 fx.write("records/contacts/a.md", &rec("A"));
4359 fx.write("records/contacts/b.md", &rec("B"));
4360 let issues = fx.store_all();
4361 assert_eq!(count(&issues, codes::DUP_ID), 1, "{issues:#?}");
4362 let issue = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
4363 assert!(issue.is_error());
4364 assert!(!has(&issues, codes::FM_BAD_ID), "{issues:#?}");
4366 }
4367
4368 #[test]
4374 fn valid_db_md_emits_no_structure_issue() {
4375 let fx = Fixture::new();
4376 let issues = fx.store_all();
4377 assert!(
4378 !has(&issues, codes::DB_MD_BAD_TYPE)
4379 && !has(&issues, codes::DB_MD_MISSING_FIELD)
4380 && !has(&issues, codes::DB_MD_UNKNOWN_SECTION),
4381 "a valid DB.md (type: db-md + scope + owner, recognized sections) is silent: {issues:#?}"
4382 );
4383 }
4384
4385 #[test]
4389 fn db_md_wrong_type_is_error() {
4390 let fx = Fixture::new();
4391 fx.write("DB.md", "---\ntype: notes\nscope: company\nowner: T\n---\n");
4392 let issues = fx.store_all();
4393 let i = find(&issues, codes::DB_MD_BAD_TYPE);
4394 assert!(i.is_error());
4395 assert_eq!(i.file, PathBuf::from("DB.md"));
4396 assert_eq!(i.key.as_deref(), Some("type"));
4397 assert_eq!(i.line, Some(2), "anchors to the `type:` line");
4398 }
4399
4400 #[test]
4403 fn db_md_missing_scope_and_owner_each_report() {
4404 let fx = Fixture::new();
4405 fx.write("DB.md", "---\ntype: db-md\n---\n");
4406 let issues = fx.store_all();
4407 assert_eq!(
4408 count(&issues, codes::DB_MD_MISSING_FIELD),
4409 2,
4410 "both scope and owner absent → two issues: {issues:#?}"
4411 );
4412 let keys: BTreeSet<Option<String>> = issues
4413 .iter()
4414 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4415 .map(|i| i.key.clone())
4416 .collect();
4417 assert_eq!(
4418 keys,
4419 BTreeSet::from([Some("scope".to_string()), Some("owner".to_string())]),
4420 "one issue keyed on each missing field"
4421 );
4422 for i in issues
4423 .iter()
4424 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4425 {
4426 assert!(i.is_error());
4427 assert_eq!(i.line, Some(1), "absent field anchors to the block top");
4428 }
4429 }
4430
4431 #[test]
4435 fn db_md_blank_required_field_is_missing() {
4436 let fx = Fixture::new();
4437 fx.write(
4438 "DB.md",
4439 "---\ntype: db-md\nscope: company\nowner: \"\"\n---\n",
4440 );
4441 let issues = fx.store_all();
4442 let i = find(&issues, codes::DB_MD_MISSING_FIELD);
4443 assert_eq!(i.key.as_deref(), Some("owner"));
4444 assert_eq!(
4445 i.line,
4446 Some(4),
4447 "a present-but-empty field anchors to its line"
4448 );
4449 assert!(
4450 count(&issues, codes::DB_MD_MISSING_FIELD) == 1,
4451 "scope is present and non-empty → only owner reported"
4452 );
4453 }
4454
4455 #[test]
4458 fn db_md_unknown_section_is_warning() {
4459 let fx = Fixture::new();
4460 fx.write(
4461 "DB.md",
4462 "---\ntype: db-md\nscope: company\nowner: T\n---\n\n## Agent instructions\n\nbe good\n\n## Glossary\n\nterms\n",
4466 );
4467 let issues = fx.store_all();
4468 let i = find(&issues, codes::DB_MD_UNKNOWN_SECTION);
4469 assert!(!i.is_error(), "unknown section is a warning, not an error");
4470 assert_eq!(i.severity, Severity::Warning);
4471 assert_eq!(
4472 i.line,
4473 Some(11),
4474 "anchors to the `## Glossary` heading line"
4475 );
4476 assert!(
4477 i.message.contains("Glossary"),
4478 "the message names the offending section: {}",
4479 i.message
4480 );
4481 assert_eq!(
4483 count(&issues, codes::DB_MD_UNKNOWN_SECTION),
4484 1,
4485 "only the unrecognized section is flagged: {issues:#?}"
4486 );
4487 }
4488
4489 #[test]
4492 fn db_md_no_frontmatter_reports_type_and_both_fields() {
4493 let fx = Fixture::new();
4494 fx.write("DB.md", "# just a heading, no frontmatter\n");
4495 let issues = fx.store_all();
4496 assert!(has(&issues, codes::DB_MD_BAD_TYPE));
4497 assert_eq!(count(&issues, codes::DB_MD_MISSING_FIELD), 2);
4498 }
4499
4500 #[test]
4503 fn missing_type_is_error() {
4504 let fx = Fixture::new();
4505 fx.write(
4506 "records/contacts/a.md",
4507 "---\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\n# A\n",
4508 );
4509 let issues = fx.store_all();
4510 assert!(has(&issues, codes::FM_MISSING_TYPE));
4511 assert!(find(&issues, codes::FM_MISSING_TYPE).is_error());
4512 }
4513
4514 #[test]
4515 fn missing_universal_timestamps_are_errors_on_content_files() {
4516 let fx = Fixture::new();
4517 fx.write(
4518 "records/contacts/a.md",
4519 "---\ntype: contact\nsummary: x\nname: A\n---\n\n# A\n",
4520 );
4521 let issues = fx.store_all();
4522
4523 let missing_created = find(&issues, codes::FM_MISSING_CREATED);
4524 assert_eq!(missing_created.key.as_deref(), Some("created"));
4525 assert!(missing_created.is_error());
4526
4527 let missing_updated = find(&issues, codes::FM_MISSING_UPDATED);
4528 assert_eq!(missing_updated.key.as_deref(), Some("updated"));
4529 assert!(missing_updated.is_error());
4530 }
4531
4532 #[test]
4533 fn meta_files_do_not_require_universal_timestamps() {
4534 let fx = Fixture::new();
4535 let issues = fx.store_all();
4536
4537 assert!(
4538 !has(&issues, codes::FM_MISSING_CREATED),
4539 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4540 );
4541 assert!(
4542 !has(&issues, codes::FM_MISSING_UPDATED),
4543 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4544 );
4545 }
4546
4547 #[test]
4548 fn content_file_with_no_frontmatter_block_reports_type_and_summary() {
4549 let fx = Fixture::new();
4550 fx.write(
4551 "records/profiles/a.md",
4552 "# Just a heading\n\nNo frontmatter here.\n",
4553 );
4554 let issues = fx.store_all();
4555 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4556 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4557 }
4558
4559 #[test]
4560 fn content_file_with_empty_frontmatter_reports_type_and_summary() {
4561 let fx = Fixture::new();
4562 fx.write("records/profiles/a.md", "---\n---\n\nbody\n");
4563 let issues = fx.store_all();
4564 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4565 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4566 }
4567
4568 #[test]
4569 fn malformed_yaml_is_error_and_suppresses_field_checks() {
4570 let fx = Fixture::new();
4571 fx.write(
4573 "records/contacts/a.md",
4574 "---\ntype: contact\n bad: : : :\n: : nope\n---\n\nbody\n",
4575 );
4576 let issues = fx.store_all();
4577 let issue = find(&issues, codes::FM_MALFORMED_YAML);
4578 assert!(issue.is_error());
4579 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4580 assert!(
4583 !has(&issues, codes::SUMMARY_MISSING),
4584 "malformed YAML should suppress SUMMARY_MISSING: {issues:#?}"
4585 );
4586 }
4587
4588 #[test]
4589 fn bad_created_timestamp_is_error() {
4590 let fx = Fixture::new();
4591 fx.write(
4592 "records/contacts/a.md",
4593 "---\ntype: contact\ncreated: not-a-date\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
4594 );
4595 let issues = fx.store_all();
4596 let issue = find(&issues, codes::FM_BAD_TIMESTAMP);
4597 assert_eq!(issue.key.as_deref(), Some("created"));
4598 assert!(issue.is_error());
4599 }
4600
4601 #[test]
4602 fn date_only_created_is_rejected_but_type_date_field_accepted() {
4603 let fx = Fixture::new();
4604 fx.write(
4607 "records/contacts/a.md",
4608 "---\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",
4609 );
4610 let issues = fx.store_all();
4611 let created_issues: Vec<_> = issues
4612 .iter()
4613 .filter(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created"))
4614 .collect();
4615 assert_eq!(
4616 created_issues.len(),
4617 1,
4618 "date-only `created` must fail: {issues:#?}"
4619 );
4620 assert!(
4621 !issues.iter().any(
4622 |i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("last_touch")
4623 ),
4624 "date-only `last_touch` is valid: {issues:#?}"
4625 );
4626 }
4627
4628 #[test]
4631 fn summary_missing_empty_multiline_toolong() {
4632 let fx = Fixture::new();
4633 fx.write(
4634 "records/profiles/missing.md",
4635 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\n---\n\nbody\n",
4636 );
4637 fx.write(
4638 "records/profiles/empty.md",
4639 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \" \"\n---\n\nbody\n",
4640 );
4641 let long = "x".repeat(201);
4642 fx.write(
4643 "records/profiles/long.md",
4644 &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"),
4645 );
4646 let issues = fx.store_all();
4647 assert!(has(&issues, codes::SUMMARY_MISSING));
4648 assert_eq!(
4649 find(&issues, codes::SUMMARY_MISSING).file,
4650 PathBuf::from("records/profiles/missing.md")
4651 );
4652 assert!(has(&issues, codes::SUMMARY_EMPTY));
4653 assert!(has(&issues, codes::SUMMARY_TOO_LONG));
4654 assert_eq!(
4655 find(&issues, codes::SUMMARY_TOO_LONG).severity,
4656 Severity::Warning
4657 );
4658 }
4659
4660 #[test]
4661 fn summary_multiline_via_yaml_block_scalar() {
4662 let fx = Fixture::new();
4663 fx.write(
4665 "records/profiles/a.md",
4666 "---\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",
4667 );
4668 let issues = fx.store_all();
4669 assert!(has(&issues, codes::SUMMARY_MULTILINE), "{issues:#?}");
4670 }
4671
4672 #[test]
4673 fn summary_exactly_200_chars_is_ok() {
4674 let fx = Fixture::new();
4675 let s = "y".repeat(200);
4676 fx.write(
4677 "records/profiles/a.md",
4678 &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"),
4679 );
4680 let issues = fx.store_all();
4681 assert!(
4682 !has(&issues, codes::SUMMARY_TOO_LONG),
4683 "200 is the bound, inclusive: {issues:#?}"
4684 );
4685 }
4686
4687 #[test]
4688 fn meta_files_need_no_summary() {
4689 let fx = Fixture::new();
4690 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4693 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n# I\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
4694 fx.write(
4695 "records/index.md",
4696 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
4697 );
4698 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — A contact\n");
4699 fx.write(
4700 "records/contacts/index.jsonl",
4701 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"A contact\"}\n",
4702 );
4703 fx.write("log.md", "---\ntype: log\n---\n\n# Log\n");
4704 let issues = fx.store_all();
4705 assert!(!has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4706 }
4707
4708 #[test]
4711 fn nested_tags_warns_flat_tags_ok() {
4712 let fx = Fixture::new();
4713 fx.write(
4714 "records/contacts/nested.md",
4715 "---\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",
4716 );
4717 fx.write(
4718 "records/contacts/flat.md",
4719 "---\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",
4720 );
4721 let issues = fx.store_all();
4722 let tag_issues: Vec<_> = issues
4723 .iter()
4724 .filter(|i| i.code == codes::TAGS_MALFORMED)
4725 .collect();
4726 assert_eq!(
4727 tag_issues.len(),
4728 1,
4729 "only the nested-tags file should warn: {issues:#?}"
4730 );
4731 assert_eq!(
4732 tag_issues[0].file,
4733 PathBuf::from("records/contacts/nested.md")
4734 );
4735 assert_eq!(tag_issues[0].severity, Severity::Warning);
4736 }
4737
4738 #[test]
4741 fn short_form_wiki_link_is_error() {
4742 let fx = Fixture::new();
4743 let mut body = valid_contact("links to a short form");
4744 body.push_str("\nSee [[sarah-chen]] for details.\n");
4745 fx.write("records/contacts/a.md", &body);
4746 let issues = fx.store_all();
4747 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4748 assert!(issue.is_error());
4749 assert!(issue.message.contains("sarah-chen"));
4750 assert!(
4752 !issues
4753 .iter()
4754 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("sarah-chen")),
4755 "short-form should suppress broken: {issues:#?}"
4756 );
4757 }
4758
4759 #[test]
4760 fn broken_full_path_wiki_link_is_error() {
4761 let fx = Fixture::new();
4762 let mut body = valid_contact("links to a missing file");
4763 body.push_str("\nSee [[records/contacts/ghost]].\n");
4764 fx.write("records/contacts/a.md", &body);
4765 let issues = fx.store_all();
4766 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4767 assert!(issue.is_error());
4768 assert!(issue.message.contains("records/contacts/ghost"));
4769 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4770 }
4771
4772 #[test]
4773 fn traversal_full_path_wiki_link_is_rejected_before_probe() {
4774 let fx = Fixture::new();
4775 let mut body = valid_contact("links with traversal");
4776 body.push_str("\nSee [[records/contacts/../../ghost]].\n");
4777 fx.write("records/contacts/a.md", &body);
4778 let issues = fx.store_all();
4779 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4780 assert!(issue.message.contains("not a safe store-relative path"));
4781 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4782 }
4783
4784 #[test]
4785 fn valid_full_path_wiki_link_passes() {
4786 let fx = Fixture::new();
4787 fx.write("records/contacts/target.md", &valid_contact("target"));
4788 let mut body = valid_contact("links to target");
4789 body.push_str("\nSee [[records/contacts/target]].\n");
4790 fx.write("records/contacts/a.md", &body);
4791 let issues = fx.store_all();
4792 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4793 assert!(!has(&issues, codes::WIKI_LINK_SHORT_FORM), "{issues:#?}");
4794 }
4795
4796 #[test]
4797 fn md_extension_wiki_link_warns_and_resolves() {
4798 let fx = Fixture::new();
4799 fx.write("records/contacts/target.md", &valid_contact("target"));
4800 let mut body = valid_contact("links with extension");
4801 body.push_str("\nSee [[records/contacts/target.md]].\n");
4802 fx.write("records/contacts/a.md", &body);
4803 let issues = fx.store_all();
4804 let issue = find(&issues, codes::WIKI_LINK_HAS_EXTENSION);
4805 assert_eq!(issue.severity, Severity::Warning);
4806 assert_eq!(
4807 issue.suggestion.as_deref(),
4808 Some("drop the extension: [[records/contacts/target]]")
4809 );
4810 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4812 }
4813
4814 #[test]
4815 fn wiki_links_in_code_fences_are_ignored() {
4816 let fx = Fixture::new();
4817 let mut body = valid_contact("has a fenced example");
4818 body.push_str("\n```\n[[sarah-chen]]\n```\n");
4819 fx.write("records/contacts/a.md", &body);
4820 let issues = fx.store_all();
4821 assert!(
4822 !has(&issues, codes::WIKI_LINK_SHORT_FORM),
4823 "fenced wiki-links must be ignored: {issues:#?}"
4824 );
4825 }
4826
4827 #[test]
4828 fn flow_form_link_list_in_frontmatter_is_error() {
4829 let fx = Fixture::new();
4830 fx.write(
4831 "records/meetings/m.md",
4832 "---\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",
4833 );
4834 let issues = fx.store_all();
4835 let issue = find(&issues, codes::WIKI_LINK_FLOW_FORM_LIST);
4836 assert!(issue.is_error());
4837 assert_eq!(issue.key.as_deref(), Some("attendees"));
4838 }
4839
4840 #[test]
4841 fn block_form_link_list_in_frontmatter_is_not_flow_form() {
4842 let fx = Fixture::new();
4843 fx.write("records/contacts/a.md", &valid_contact("a"));
4844 fx.write("records/contacts/b.md", &valid_contact("b"));
4845 fx.write(
4846 "records/meetings/m.md",
4847 "---\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",
4848 );
4849 let issues = fx.store_all();
4850 assert!(
4851 !has(&issues, codes::WIKI_LINK_FLOW_FORM_LIST),
4852 "{issues:#?}"
4853 );
4854 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4856 }
4857
4858 #[test]
4859 fn frontmatter_short_form_link_field_is_error() {
4860 let fx = Fixture::new();
4861 fx.write(
4864 "records/synthesis/a.md",
4865 "---\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",
4866 );
4867 let issues = fx.store_all();
4868 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4869 assert!(issue.is_error());
4870 assert_eq!(issue.key.as_deref(), Some("related"));
4871 }
4872
4873 #[test]
4874 fn unquoted_frontmatter_link_is_recognized() {
4875 let fx = Fixture::new();
4880 fx.write(
4881 "records/synthesis/short.md",
4882 "---\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",
4883 );
4884 fx.write(
4885 "records/synthesis/broken.md",
4886 "---\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",
4887 );
4888 let issues = fx.store_all();
4889 assert!(
4890 issues.iter().any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4891 && i.file == Path::new("records/synthesis/short.md")
4892 && i.key.as_deref() == Some("related")),
4893 "unquoted short-form frontmatter link must be caught: {issues:#?}"
4894 );
4895 assert!(
4896 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
4897 && i.file == Path::new("records/synthesis/broken.md")),
4898 "unquoted full-path frontmatter link to a missing file must be caught: {issues:#?}"
4899 );
4900 }
4901
4902 #[test]
4903 fn short_form_in_declared_link_field_is_prefix_mismatch_not_double_reported() {
4904 let mut fx = Fixture::new();
4909 fx.config.schemas.insert(
4910 "contact".into(),
4911 Schema {
4912 fields: vec![FieldSpec {
4913 name: "company".into(),
4914 link_prefix: Some(PathBuf::from("records/companies")),
4915 ..Default::default()
4916 }],
4917 ..Default::default()
4918 },
4919 );
4920 fx.write(
4921 "records/contacts/a.md",
4922 "---\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",
4923 );
4924 let issues = fx.store_all();
4925 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
4926 assert_eq!(issue.key.as_deref(), Some("company"));
4927 assert!(
4929 !issues
4930 .iter()
4931 .any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4932 && i.key.as_deref() == Some("company")),
4933 "schema link fields are checked once, by the schema path: {issues:#?}"
4934 );
4935 }
4936
4937 #[test]
4938 fn schema_link_field_with_md_extension_still_warns() {
4939 let mut fx = Fixture::new();
4940 fx.config.schemas.insert(
4941 "contact".into(),
4942 Schema {
4943 fields: vec![FieldSpec {
4944 name: "company".into(),
4945 link_prefix: Some(PathBuf::from("records/companies")),
4946 ..Default::default()
4947 }],
4948 ..Default::default()
4949 },
4950 );
4951 fx.write(
4952 "records/companies/acme.md",
4953 "---\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",
4954 );
4955 fx.write(
4956 "records/contacts/a.md",
4957 "---\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",
4958 );
4959 let issues = fx.store_all();
4960 let issue = issues
4961 .iter()
4962 .find(|i| {
4963 i.code == codes::WIKI_LINK_HAS_EXTENSION && i.key.as_deref() == Some("company")
4964 })
4965 .unwrap_or_else(|| panic!("schema link extension warning missing: {issues:#?}"));
4966 assert_eq!(issue.severity, Severity::Warning);
4967 assert!(
4968 !issues
4969 .iter()
4970 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.key.as_deref() == Some("company")),
4971 "extensionless existence check should still find acme.md: {issues:#?}"
4972 );
4973 }
4974
4975 #[test]
4978 fn explicit_schema_required_shape_enum() {
4979 let fx = {
4980 let mut fx = Fixture::new();
4981 let schema = Schema {
4984 fields: vec![
4985 FieldSpec {
4986 name: "name".into(),
4987 required: true,
4988 ..Default::default()
4989 },
4990 FieldSpec {
4991 name: "email".into(),
4992 required: true,
4993 shape: Some(Shape::Email),
4994 ..Default::default()
4995 },
4996 FieldSpec {
4997 name: "status".into(),
4998 enum_values: Some(vec!["active".into(), "inactive".into()]),
4999 ..Default::default()
5000 },
5001 ],
5002 ..Default::default()
5003 };
5004 fx.config.schemas.insert("contact".into(), schema);
5005 fx
5006 };
5007 fx.write(
5008 "records/contacts/a.md",
5009 "---\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",
5010 );
5011 let issues = fx.store_all();
5012 assert!(
5014 issues
5015 .iter()
5016 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
5017 && i.key.as_deref() == Some("name")),
5018 "{issues:#?}"
5019 );
5020 assert!(
5022 issues.iter().any(
5023 |i| i.code == codes::SCHEMA_SHAPE_MISMATCH && i.key.as_deref() == Some("email")
5024 ),
5025 "{issues:#?}"
5026 );
5027 assert!(
5029 issues
5030 .iter()
5031 .any(|i| i.code == codes::SCHEMA_ENUM_VIOLATION
5032 && i.key.as_deref() == Some("status")),
5033 "{issues:#?}"
5034 );
5035 }
5036
5037 #[test]
5038 fn schema_without_link_field_allows_plain_value() {
5039 let mut fx = Fixture::new();
5043 fx.config.schemas.insert(
5044 "contact".into(),
5045 Schema {
5046 fields: vec![FieldSpec {
5047 name: "name".into(),
5048 required: true,
5049 ..Default::default()
5050 }],
5051 ..Default::default()
5052 },
5053 );
5054 fx.write(
5055 "records/contacts/a.md",
5056 "---\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",
5057 );
5058 let issues = fx.store_all();
5059 assert!(
5060 !has(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH),
5061 "no declared link field for `company` → a plain value is fine: {issues:#?}"
5062 );
5063 }
5064
5065 #[test]
5066 fn schema_link_field_plain_value_is_prefix_mismatch() {
5067 let mut fx = Fixture::new();
5070 fx.config.schemas.insert(
5071 "contact".into(),
5072 Schema {
5073 fields: vec![FieldSpec {
5074 name: "company".into(),
5075 link_prefix: Some(PathBuf::from("records/companies")),
5076 ..Default::default()
5077 }],
5078 ..Default::default()
5079 },
5080 );
5081 fx.write(
5082 "records/contacts/a.md",
5083 "---\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",
5084 );
5085 let issues = fx.store_all();
5086 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
5087 assert_eq!(issue.key.as_deref(), Some("company"));
5088 assert!(issue
5089 .suggestion
5090 .as_deref()
5091 .unwrap()
5092 .contains("records/companies/"));
5093 }
5094
5095 #[test]
5096 fn schema_shape_int_and_url_and_currency() {
5097 let mut fx = Fixture::new();
5098 fx.config.schemas.insert(
5099 "widget".into(),
5100 Schema {
5101 fields: vec![
5102 FieldSpec {
5103 name: "qty".into(),
5104 shape: Some(Shape::Int),
5105 ..Default::default()
5106 },
5107 FieldSpec {
5108 name: "site".into(),
5109 shape: Some(Shape::Url),
5110 ..Default::default()
5111 },
5112 FieldSpec {
5113 name: "price".into(),
5114 shape: Some(Shape::Currency),
5115 ..Default::default()
5116 },
5117 ],
5118 ..Default::default()
5119 },
5120 );
5121 fx.write(
5124 "records/widgets/ok.md",
5125 "---\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",
5126 );
5127 fx.write(
5131 "records/widgets/bad.md",
5132 "---\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",
5133 );
5134 let issues = fx.store_all();
5135 let bad_shape: Vec<_> = issues
5136 .iter()
5137 .filter(|i| {
5138 i.code == codes::SCHEMA_SHAPE_MISMATCH
5139 && i.file == Path::new("records/widgets/bad.md")
5140 })
5141 .map(|i| i.key.clone().unwrap_or_default())
5142 .collect();
5143 assert!(bad_shape.contains(&"qty".to_string()), "{issues:#?}");
5144 assert!(bad_shape.contains(&"site".to_string()), "{issues:#?}");
5145 assert!(
5146 bad_shape.contains(&"price".to_string()),
5147 "inf must be rejected as currency: {issues:#?}"
5148 );
5149 assert!(
5150 !issues.iter().any(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH
5151 && i.file == Path::new("records/widgets/ok.md")),
5152 "valid shapes (incl. `USD 1,234.50`) must not fire: {issues:#?}"
5153 );
5154 }
5155
5156 #[test]
5157 fn schema_shape_or_enum_field_with_non_scalar_value_is_shape_mismatch() {
5158 let mut fx = Fixture::new();
5159 fx.config.schemas.insert(
5160 "contact".into(),
5161 Schema {
5162 fields: vec![
5163 FieldSpec {
5164 name: "email".into(),
5165 required: true,
5166 shape: Some(Shape::Email),
5167 ..Default::default()
5168 },
5169 FieldSpec {
5170 name: "status".into(),
5171 enum_values: Some(vec!["active".into(), "inactive".into()]),
5172 ..Default::default()
5173 },
5174 ],
5175 ..Default::default()
5176 },
5177 );
5178 fx.write(
5182 "records/contacts/bad.md",
5183 "---\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",
5184 );
5185 let issues = fx.store_all();
5186 let mismatched: Vec<_> = issues
5187 .iter()
5188 .filter(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH)
5189 .map(|i| i.key.clone().unwrap_or_default())
5190 .collect();
5191 assert!(
5192 mismatched.contains(&"email".to_string()),
5193 "list-valued required email must flag: {issues:#?}"
5194 );
5195 assert!(
5196 mismatched.contains(&"status".to_string()),
5197 "list-valued enum must flag: {issues:#?}"
5198 );
5199 }
5200
5201 #[test]
5202 fn is_currency_accepts_codes_and_rejects_non_numeric() {
5203 for ok in [
5205 "100",
5206 "1234.56",
5207 "$1,234.50",
5208 "USD 100", "usd 100", "EUR 9.50",
5211 "£12",
5212 "¥1000",
5213 "-5.00", "+5",
5215 "1,000,000",
5216 ] {
5217 assert!(is_currency(ok), "expected currency: {ok:?}");
5218 }
5219 for bad in [
5222 "inf", "-inf", "infinity", "NaN", "nan", "12.999", "1.2345", "USD", "$", "free", "", " ", "1e3", "1.", ".5", "1 000", "USDD 100", ] {
5233 assert!(!is_currency(bad), "expected NOT currency: {bad:?}");
5234 }
5235 }
5236
5237 #[test]
5240 fn ignored_type_present_is_info() {
5241 let mut fx = Fixture::new();
5242 fx.config.ignored_types.push("temp".into());
5243 fx.write(
5244 "records/temps/x.md",
5245 "---\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",
5246 );
5247 let issues = fx.store_all();
5248 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_PRESENT);
5249 assert_eq!(issue.severity, Severity::Info);
5250 assert!(!issue.is_error());
5251 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5252 }
5253
5254 #[test]
5255 fn conclusion_record_derived_from_ignored_type_warns() {
5256 let mut fx = Fixture::new();
5257 fx.config.ignored_types.push("temp".into());
5258 fx.write(
5259 "records/temps/x.md",
5260 "---\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",
5261 );
5262 fx.write(
5266 "records/synthesis/t.md",
5267 "---\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",
5268 );
5269 let issues = fx.store_all();
5270 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_DERIVED);
5271 assert_eq!(issue.severity, Severity::Warning);
5272 assert_eq!(issue.key.as_deref(), Some("derived_from"));
5273 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5274 }
5275
5276 #[test]
5284 fn derived_from_ignored_type_is_the_shared_policy_decision() {
5285 let mut fx = Fixture::new();
5286 fx.config.ignored_types.push("secret".into());
5287 fx.write(
5289 "records/secrets/s.md",
5290 "---\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",
5291 );
5292 fx.write(
5294 "records/contacts/c.md",
5295 "---\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",
5296 );
5297 let store = fx.store();
5298
5299 let hit =
5303 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s"))
5304 .expect("conclusion → ignored-type record must match");
5305 assert_eq!(hit.target, "records/secrets/s");
5306 assert_eq!(hit.target_type, "secret");
5307
5308 assert_eq!(
5311 derived_from_ignored_type(&store, "fact", std::iter::once("records/secrets/s")),
5312 None,
5313 "only conclusion derivation is policed"
5314 );
5315
5316 assert_eq!(
5318 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/contacts/c")),
5319 None,
5320 "deriving from a non-ignored type is allowed"
5321 );
5322
5323 let hit = derived_from_ignored_type(
5325 &store,
5326 "conclusion",
5327 ["records/contacts/c", "records/secrets/s"],
5328 )
5329 .expect("a later ignored-type target must still be found");
5330 assert_eq!(hit.target, "records/secrets/s");
5331
5332 fx.config.ignored_types.clear();
5334 let store = fx.store();
5335 assert_eq!(
5336 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s")),
5337 None,
5338 "an empty ignored-types policy short-circuits"
5339 );
5340 }
5341
5342 #[test]
5345 fn dup_id_is_hard_error_with_related() {
5346 let fx = Fixture::new();
5347 fx.write(
5348 "records/contacts/a.md",
5349 "---\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",
5350 );
5351 fx.write(
5352 "records/contacts/b.md",
5353 "---\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",
5354 );
5355 let issues = fx.store_all();
5356 assert_eq!(
5359 count(&issues, codes::DUP_ID),
5360 1,
5361 "one issue per group: {issues:#?}"
5362 );
5363 let a = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
5364 assert_eq!(a.file, PathBuf::from("records/contacts/a.md"));
5365 assert!(a.is_error());
5366 assert_eq!(a.key.as_deref(), Some("id"));
5367 assert_eq!(
5368 a.line,
5369 Some(3),
5370 "anchors to the `id` line on the reported file"
5371 );
5372 assert_eq!(a.related, vec![PathBuf::from("records/contacts/b.md")]);
5373 }
5374
5375 #[test]
5376 fn dup_id_not_fired_in_working_set() {
5377 let fx = Fixture::new();
5379 fx.write(
5380 "records/contacts/a.md",
5381 "---\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",
5382 );
5383 fx.write(
5384 "records/contacts/b.md",
5385 "---\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",
5386 );
5387 fx.write(
5389 "log.md",
5390 "---\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",
5391 );
5392 let issues = validate_working_set(&fx.store(), None).unwrap();
5393 assert!(
5394 !has(&issues, codes::DUP_ID),
5395 "DUP_ID is --all only: {issues:#?}"
5396 );
5397 }
5398
5399 #[test]
5400 fn dup_unique_key_single_field_is_warning() {
5401 let mut fx = Fixture::new();
5402 fx.config.schemas.insert(
5404 "contact".into(),
5405 Schema {
5406 unique_keys: vec![vec!["email".into()]],
5407 ..Default::default()
5408 },
5409 );
5410 for (f, name) in [("a", "A"), ("b", "B")] {
5411 fx.write(
5412 &format!("records/contacts/{f}.md"),
5413 &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"),
5414 );
5415 }
5416 let issues = fx.store_all();
5417 assert_eq!(count(&issues, codes::DUP_UNIQUE_KEY), 1);
5420 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5421 assert_eq!(dup.severity, Severity::Warning);
5422 assert_eq!(dup.file, PathBuf::from("records/contacts/a.md"));
5423 assert_eq!(dup.key.as_deref(), Some("email"));
5424 assert_eq!(dup.related, vec![PathBuf::from("records/contacts/b.md")]);
5425 }
5426
5427 #[test]
5428 fn dup_unique_key_compound_and_clean_when_one_field_differs() {
5429 let mut fx = Fixture::new();
5430 fx.config.schemas.insert(
5432 "expense".into(),
5433 Schema {
5434 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
5435 ..Default::default()
5436 },
5437 );
5438 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");
5439 let exp = |f: &str, amount: &str| {
5440 format!(
5441 "---\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"
5442 )
5443 };
5444 fx.write("records/expenses/e1.md", &exp("e1", "100"));
5445 fx.write("records/expenses/e2.md", &exp("e2", "100"));
5446 fx.write("records/expenses/e3.md", &exp("e3", "200")); let issues = fx.store_all();
5448 assert_eq!(
5451 count(&issues, codes::DUP_UNIQUE_KEY),
5452 1,
5453 "only e1+e2 collide, one issue: {issues:#?}"
5454 );
5455 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5456 assert_eq!(dup.file, PathBuf::from("records/expenses/e1.md"));
5457 assert_eq!(
5458 dup.line,
5459 Some(1),
5460 "compound-key collision anchors to line 1"
5461 );
5462 assert_eq!(dup.related, vec![PathBuf::from("records/expenses/e2.md")]);
5463 assert!(
5464 !issues.iter().any(|i| i.code == codes::DUP_UNIQUE_KEY
5465 && i.related.contains(&PathBuf::from("records/expenses/e3.md"))),
5466 "e3 differs on amount and must not collide: {issues:#?}"
5467 );
5468 }
5469
5470 #[test]
5471 fn dup_unique_key_list_field_is_order_independent() {
5472 let mut fx = Fixture::new();
5473 fx.config.schemas.insert(
5475 "meeting".into(),
5476 Schema {
5477 unique_keys: vec![vec!["date".into(), "attendees".into()]],
5478 ..Default::default()
5479 },
5480 );
5481 fx.write("records/contacts/a.md", &valid_contact("a"));
5482 fx.write("records/contacts/b.md", &valid_contact("b"));
5483 let m = |f: &str, order: &str| {
5484 let attendees = if order == "ab" {
5485 " - [[records/contacts/a]]\n - [[records/contacts/b]]"
5486 } else {
5487 " - [[records/contacts/b]]\n - [[records/contacts/a]]"
5488 };
5489 format!(
5490 "---\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"
5491 )
5492 };
5493 fx.write("records/meetings/m1.md", &m("m1", "ab"));
5494 fx.write("records/meetings/m2.md", &m("m2", "ba"));
5495 let issues = fx.store_all();
5496 assert_eq!(
5499 count(&issues, codes::DUP_UNIQUE_KEY),
5500 1,
5501 "same date + same attendee set (any order) collide as one issue: {issues:#?}"
5502 );
5503 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5504 assert_eq!(dup.file, PathBuf::from("records/meetings/m1.md"));
5505 assert_eq!(dup.related, vec![PathBuf::from("records/meetings/m2.md")]);
5506 }
5507
5508 #[test]
5511 fn missing_indexes_at_all_three_levels() {
5512 let fx = Fixture::new();
5513 fx.write("records/contacts/a.md", &valid_contact("a"));
5514 let issues = fx.store_all();
5515 let missing_files: BTreeSet<PathBuf> = issues
5519 .iter()
5520 .filter(|i| i.code == codes::INDEX_MISSING)
5521 .map(|i| i.file.clone())
5522 .collect();
5523 assert!(
5524 missing_files.contains(&PathBuf::from("index.md")),
5525 "{issues:#?}"
5526 );
5527 assert!(
5528 missing_files.contains(&PathBuf::from("records/index.md")),
5529 "{issues:#?}"
5530 );
5531 assert!(
5532 missing_files.contains(&PathBuf::from("records/contacts")),
5533 "{issues:#?}"
5534 );
5535 assert!(!has(&issues, codes::INDEX_JSONL_MISSING), "{issues:#?}");
5538 }
5539
5540 #[test]
5541 fn index_stale_entry_and_missing_entry() {
5542 let fx = Fixture::new();
5543 fx.write(
5544 "records/contacts/present.md",
5545 &valid_contact("present contact"),
5546 );
5547 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5549 fx.write(
5550 "records/index.md",
5551 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5552 );
5553 fx.write(
5555 "records/contacts/index.md",
5556 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/ghost]] — gone\n",
5557 );
5558 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/present.md\",\"type\":\"contact\",\"summary\":\"present contact\"}\n");
5559 let issues = fx.store_all();
5560 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5561 assert!(stale.message.contains("ghost"));
5562 assert!(stale.is_error());
5563 let missing = find(&issues, codes::INDEX_MISSING_ENTRY);
5564 assert!(
5565 missing.message.contains("present.md"),
5566 "{}",
5567 missing.message
5568 );
5569 }
5570
5571 #[test]
5572 fn index_md_entry_with_traversal_path_is_stale_not_probe() {
5573 let fx = Fixture::new();
5574 fx.write("records/contacts/a.md", &valid_contact("a"));
5575 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5576 fx.write(
5577 "records/index.md",
5578 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5579 );
5580 fx.write(
5581 "records/contacts/index.md",
5582 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/../../ghost]] — unsafe\n",
5583 );
5584 fx.write(
5585 "records/contacts/index.jsonl",
5586 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5587 );
5588 let issues = fx.store_all();
5589 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5590 assert!(stale.message.contains("not a safe store-relative path"));
5591 }
5592
5593 #[test]
5594 fn index_summary_mismatch() {
5595 let fx = Fixture::new();
5596 fx.write("records/contacts/a.md", &valid_contact("the real summary"));
5597 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5598 fx.write(
5599 "records/index.md",
5600 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5601 );
5602 fx.write(
5603 "records/contacts/index.md",
5604 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a STALE summary\n",
5605 );
5606 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"the real summary\"}\n");
5607 let issues = fx.store_all();
5608 let issue = find(&issues, codes::INDEX_SUMMARY_MISMATCH);
5609 assert!(issue.is_error());
5610 assert_eq!(issue.related, vec![PathBuf::from("records/contacts/a.md")]);
5611 }
5612
5613 #[test]
5614 fn index_summary_match_passes() {
5615 let fx = Fixture::new();
5616 fx.write("records/contacts/a.md", &valid_contact("matching summary"));
5617 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5618 fx.write(
5619 "records/index.md",
5620 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5621 );
5622 fx.write(
5623 "records/contacts/index.md",
5624 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — matching summary\n",
5625 );
5626 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"matching summary\"}\n");
5627 let issues = fx.store_all();
5628 assert!(!has(&issues, codes::INDEX_SUMMARY_MISMATCH), "{issues:#?}");
5629 }
5630
5631 #[test]
5632 fn index_entry_with_tag_suffix_matches_summary() {
5633 let fx = Fixture::new();
5634 fx.write("records/contacts/a.md", &valid_contact("clean 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(
5644 "records/contacts/index.md",
5645 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — clean summary · #customer\n",
5646 );
5647 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"clean summary\"}\n");
5648 let issues = fx.store_all();
5649 assert!(
5650 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5651 "tag suffix should be stripped: {issues:#?}"
5652 );
5653 }
5654
5655 #[test]
5656 fn index_entry_single_spaced_middot_tail_is_part_of_summary() {
5657 let fx = Fixture::new();
5664 fx.write(
5665 "records/contacts/a.md",
5666 &valid_contact("Standup notes · #standup"),
5667 );
5668 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5669 fx.write(
5670 "records/index.md",
5671 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5672 );
5673 fx.write(
5674 "records/contacts/index.md",
5675 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — Standup notes · #standup\n",
5676 );
5677 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"Standup notes · #standup\"}\n");
5678 let issues = fx.store_all();
5679 assert!(
5680 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5681 "a single-spaced middot tail is part of the summary, not a tag block: {issues:#?}"
5682 );
5683 }
5684
5685 #[test]
5686 fn index_jsonl_desync_missing_file_in_jsonl() {
5687 let fx = Fixture::new();
5688 fx.write("records/contacts/a.md", &valid_contact("a"));
5689 fx.write("records/contacts/b.md", &valid_contact("b"));
5690 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (2 files)\n");
5691 fx.write(
5692 "records/index.md",
5693 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5694 );
5695 fx.write(
5696 "records/contacts/index.md",
5697 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n- [[records/contacts/b]] — b\n",
5698 );
5699 fx.write(
5701 "records/contacts/index.jsonl",
5702 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5703 );
5704 let issues = fx.store_all();
5705 let desync = find(&issues, codes::INDEX_JSONL_DESYNC);
5706 assert!(desync.message.contains("b.md"), "{}", desync.message);
5707 }
5708
5709 #[test]
5710 fn index_jsonl_desync_record_points_at_missing_file() {
5711 let fx = Fixture::new();
5712 fx.write("records/contacts/a.md", &valid_contact("a"));
5713 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5714 fx.write(
5715 "records/index.md",
5716 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5717 );
5718 fx.write(
5719 "records/contacts/index.md",
5720 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5721 );
5722 fx.write(
5723 "records/contacts/index.jsonl",
5724 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5725 );
5726 let issues = fx.store_all();
5727 assert!(
5728 issues
5729 .iter()
5730 .any(|i| i.code == codes::INDEX_JSONL_DESYNC && i.message.contains("ghost.md")),
5731 "{issues:#?}"
5732 );
5733 }
5734
5735 #[test]
5736 fn index_jsonl_record_with_traversal_path_is_desync_not_probe() {
5737 let fx = Fixture::new();
5738 fx.write("records/contacts/a.md", &valid_contact("a"));
5739 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5740 fx.write(
5741 "records/index.md",
5742 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5743 );
5744 fx.write(
5745 "records/contacts/index.md",
5746 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5747 );
5748 fx.write(
5749 "records/contacts/index.jsonl",
5750 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/../../ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5751 );
5752 let issues = fx.store_all();
5753 assert!(
5754 issues.iter().any(|i| i.code == codes::INDEX_JSONL_DESYNC
5755 && i.message.contains("not a safe store-relative path")),
5756 "{issues:#?}"
5757 );
5758 }
5759
5760 #[test]
5761 fn index_jsonl_stale_summary() {
5762 let fx = Fixture::new();
5763 fx.write("records/contacts/a.md", &valid_contact("real summary"));
5764 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5765 fx.write(
5766 "records/index.md",
5767 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5768 );
5769 fx.write(
5770 "records/contacts/index.md",
5771 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — real summary\n",
5772 );
5773 fx.write(
5775 "records/contacts/index.jsonl",
5776 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"OUTDATED\"}\n",
5777 );
5778 let issues = fx.store_all();
5779 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5780 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5781 assert!(stale.key.as_deref().unwrap().contains("summary"));
5782 }
5783
5784 #[test]
5792 fn index_jsonl_stale_queryable_field_email() {
5793 let fx = Fixture::new();
5794 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";
5795 fx.write("records/contacts/a.md", contact);
5796 fx.rebuild_indexes();
5798 let jsonl_path = fx.dir.path().join("records/contacts/index.jsonl");
5799 let good = fs::read_to_string(&jsonl_path).unwrap();
5800 assert!(
5802 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5803 "freshly-rebuilt sidecar must not be stale"
5804 );
5805 assert!(
5807 good.contains("real@correct.com"),
5808 "sidecar projects email: {good}"
5809 );
5810 fx.write(
5811 "records/contacts/index.jsonl",
5812 &good.replace("real@correct.com", "STALE-WRONG@evil.com"),
5813 );
5814
5815 let issues = fx.store_all();
5816 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5817 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5818 let key = stale.key.as_deref().unwrap();
5821 assert!(
5822 key.contains("email"),
5823 "expected `email` in stale key, got {key:?}"
5824 );
5825 assert!(!key.contains("summary"), "summary still matches: {key:?}");
5826 assert!(!key.contains("type"), "type still matches: {key:?}");
5827 }
5828
5829 #[test]
5833 fn index_jsonl_stale_typed_and_list_fields() {
5834 let fx = Fixture::new();
5835 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";
5836 fx.write("records/expenses/e.md", expense);
5837 fx.rebuild_indexes();
5838 let jsonl_path = fx.dir.path().join("records/expenses/index.jsonl");
5839 let good = fs::read_to_string(&jsonl_path).unwrap();
5840 assert!(
5841 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5842 "freshly-rebuilt sidecar must not be stale"
5843 );
5844 let stale_line = good
5846 .replace("\"q2\"", "\"WRONG-TAG\"")
5847 .replace("2026-05-22T10:00:00-07:00", "2099-01-01T00:00:00-07:00")
5848 .replace("1299", "9999");
5849 fx.write("records/expenses/index.jsonl", &stale_line);
5850
5851 let issues = fx.store_all();
5852 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5853 let key = stale.key.as_deref().unwrap();
5854 for expected in ["amount", "tags", "updated"] {
5855 assert!(
5856 key.contains(expected),
5857 "expected `{expected}` in stale key, got {key:?}"
5858 );
5859 }
5860 }
5861
5862 #[test]
5863 fn index_orphan_in_noncanonical_folder() {
5864 let fx = Fixture::new();
5865 fx.write("records/contacts/a.md", &valid_contact("a"));
5866 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5868 fx.write(
5869 "records/index.md",
5870 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5871 );
5872 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5873 fx.write(
5874 "records/contacts/index.jsonl",
5875 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5876 );
5877 fx.write(
5879 "records/contacts/subfolder/index.md",
5880 "---\ntype: index\nscope: type-folder\n---\n\n# stray\n",
5881 );
5882 let issues = fx.store_all();
5883 let orphan = find(&issues, codes::INDEX_ORPHAN);
5884 assert_eq!(orphan.severity, Severity::Warning);
5885 assert_eq!(
5886 orphan.file,
5887 PathBuf::from("records/contacts/subfolder/index.md")
5888 );
5889 }
5890
5891 #[test]
5892 fn index_wrong_scope() {
5893 let fx = Fixture::new();
5894 fx.write("records/contacts/a.md", &valid_contact("a"));
5895 fx.write("index.md", "---\ntype: index\nscope: layer\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5897 fx.write(
5898 "records/index.md",
5899 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5900 );
5901 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5902 fx.write(
5903 "records/contacts/index.jsonl",
5904 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5905 );
5906 let issues = fx.store_all();
5907 let issue = find(&issues, codes::INDEX_WRONG_SCOPE);
5908 assert_eq!(issue.severity, Severity::Warning);
5909 assert_eq!(issue.file, PathBuf::from("index.md"));
5910 }
5911
5912 #[test]
5913 fn capped_type_folder_index_does_not_flag_missing_entries() {
5914 let fx = Fixture::new();
5916 for i in 0..501 {
5917 fx.write(
5918 &format!("records/contacts/c{i:04}.md"),
5919 &valid_contact(&format!("contact {i}")),
5920 );
5921 }
5922 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (501 files)\n");
5923 fx.write(
5924 "records/index.md",
5925 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5926 );
5927 fx.write(
5929 "records/contacts/index.md",
5930 "---\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",
5931 );
5932 let mut jsonl = String::new();
5934 for i in 0..501 {
5935 jsonl.push_str(&format!(
5936 "{{\"path\":\"records/contacts/c{i:04}.md\",\"type\":\"contact\",\"summary\":\"contact {i}\"}}\n"
5937 ));
5938 }
5939 fx.write("records/contacts/index.jsonl", &jsonl);
5940 let issues = fx.store_all();
5941 assert!(
5942 !has(&issues, codes::INDEX_MISSING_ENTRY),
5943 "over the cap, missing browse entries are expected: {issues:#?}"
5944 );
5945 assert!(
5947 !has(&issues, codes::INDEX_JSONL_DESYNC),
5948 "{:#?}",
5949 issues
5950 .iter()
5951 .filter(|i| i.code == codes::INDEX_JSONL_DESYNC)
5952 .collect::<Vec<_>>()
5953 );
5954 }
5955
5956 #[test]
5959 fn log_bad_timestamp_unknown_kind_out_of_order() {
5960 let fx = Fixture::new();
5961 fx.write(
5962 "log.md",
5963 concat!(
5964 "---\ntype: log\n---\n\n# Log\n\n",
5965 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5966 "## [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", ),
5970 );
5971 let issues = fx.store_all();
5972 assert!(has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
5973 assert_eq!(
5974 find(&issues, codes::LOG_OUT_OF_ORDER).severity,
5975 Severity::Warning
5976 );
5977 let unknown = find(&issues, codes::LOG_UNKNOWN_KIND);
5978 assert_eq!(unknown.severity, Severity::Warning);
5979 assert!(unknown.message.contains("frobnicate"));
5980 assert!(unknown
5981 .suggestion
5982 .as_deref()
5983 .is_some_and(|s| s.contains("create")));
5984 let bad = find(&issues, codes::LOG_BAD_TIMESTAMP);
5985 assert!(bad.is_error());
5986 }
5987
5988 #[test]
5989 fn log_validate_entry_without_object_is_well_formed() {
5990 let fx = Fixture::new();
5991 fx.write(
5992 "log.md",
5993 "---\ntype: log\n---\n\n## [2026-05-27 10:00] validate\nPASS\n",
5994 );
5995 let issues = fx.store_all();
5996 assert!(!has(&issues, codes::LOG_BAD_TIMESTAMP), "{issues:#?}");
5997 assert!(!has(&issues, codes::LOG_UNKNOWN_KIND), "{issues:#?}");
5998 }
5999
6000 #[test]
6001 fn log_in_order_is_clean() {
6002 let fx = Fixture::new();
6003 fx.write(
6004 "log.md",
6005 concat!(
6006 "---\ntype: log\n---\n\n",
6007 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6008 "## [2026-05-27 10:05] update | records/contacts/a\nx\n",
6009 ),
6010 );
6011 let issues = fx.store_all();
6012 assert!(!has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
6013 }
6014
6015 #[test]
6016 fn log_not_checked_in_working_set() {
6017 let fx = Fixture::new();
6019 fx.write(
6020 "log.md",
6021 concat!(
6022 "---\ntype: log\n---\n\n",
6023 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6024 "## [2026-05-27 09:00] update | records/contacts/a\nx\n",
6025 ),
6026 );
6027 let issues = validate_working_set(&fx.store(), None).unwrap();
6028 assert!(
6029 !has(&issues, codes::LOG_OUT_OF_ORDER),
6030 "log ordering is --all only: {issues:#?}"
6031 );
6032 }
6033
6034 #[test]
6037 fn working_set_validates_only_changed_files() {
6038 let fx = Fixture::new();
6039 fx.write(
6042 "records/contacts/dirty.md",
6043 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6044 );
6045 fx.write(
6046 "records/contacts/unlogged.md",
6047 "---\ntype: contact\ncreated: ALSO-BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6048 );
6049 fx.write(
6050 "log.md",
6051 "---\ntype: log\n---\n\n## [2026-05-22 10:00] update | records/contacts/dirty\nedited\n",
6052 );
6053 let issues = validate_working_set(&fx.store(), None).unwrap();
6054 assert!(
6055 issues.iter().any(|i| i.code == codes::FM_BAD_TIMESTAMP
6056 && i.file == Path::new("records/contacts/dirty.md")),
6057 "{issues:#?}"
6058 );
6059 assert!(
6060 !issues
6061 .iter()
6062 .any(|i| i.file == Path::new("records/contacts/unlogged.md")),
6063 "unlogged file must not be in the working set: {issues:#?}"
6064 );
6065 }
6066
6067 #[test]
6068 fn working_set_includes_incoming_linkers_to_changed_path() {
6069 let fx = Fixture::new();
6070 fx.write(
6073 "records/profiles/linker.md",
6074 "---\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",
6075 );
6076 fx.write(
6078 "log.md",
6079 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/changed\nremoved\n",
6080 );
6081 let issues = validate_working_set(&fx.store(), None).unwrap();
6082 assert!(
6083 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
6084 && i.file == Path::new("records/profiles/linker.md")),
6085 "incoming linker to a removed path must be validated: {issues:#?}"
6086 );
6087 }
6088
6089 #[test]
6090 fn working_set_respects_explicit_since_cutoff() {
6091 let fx = Fixture::new();
6092 fx.write(
6093 "records/contacts/old.md",
6094 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6095 );
6096 fx.write(
6097 "records/contacts/new.md",
6098 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6099 );
6100 fx.write(
6101 "log.md",
6102 concat!(
6103 "---\ntype: log\n---\n\n",
6104 "## [2026-05-20 10:00] update | records/contacts/old\nx\n\n",
6105 "## [2026-05-25 10:00] update | records/contacts/new\nx\n",
6106 ),
6107 );
6108 let since = DateTime::parse_from_rfc3339("2026-05-22T00:00:00+00:00").unwrap();
6110 let issues = validate_working_set(&fx.store(), Some(since)).unwrap();
6111 assert!(
6112 issues
6113 .iter()
6114 .any(|i| i.file == Path::new("records/contacts/new.md")),
6115 "{issues:#?}"
6116 );
6117 assert!(
6118 !issues
6119 .iter()
6120 .any(|i| i.file == Path::new("records/contacts/old.md")),
6121 "old change is before the cutoff: {issues:#?}"
6122 );
6123 }
6124
6125 #[test]
6126 fn working_set_default_since_is_last_validate_entry() {
6127 let fx = Fixture::new();
6128 fx.write(
6130 "records/contacts/before.md",
6131 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6132 );
6133 fx.write(
6134 "records/contacts/after.md",
6135 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6136 );
6137 fx.write(
6138 "log.md",
6139 concat!(
6140 "---\ntype: log\n---\n\n",
6141 "## [2026-05-20 10:00] update | records/contacts/before\nx\n\n",
6142 "## [2026-05-21 10:00] validate\nPASS\n\n",
6143 "## [2026-05-22 10:00] update | records/contacts/after\nx\n",
6144 ),
6145 );
6146 let issues = validate_working_set(&fx.store(), None).unwrap();
6147 assert!(
6148 issues
6149 .iter()
6150 .any(|i| i.file == Path::new("records/contacts/after.md")),
6151 "{issues:#?}"
6152 );
6153 assert!(
6154 !issues
6155 .iter()
6156 .any(|i| i.file == Path::new("records/contacts/before.md")),
6157 "change before the last validate entry is outside the default window: {issues:#?}"
6158 );
6159 }
6160
6161 #[test]
6164 fn issues_are_sorted_by_file_then_line() {
6165 let fx = Fixture::new();
6166 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");
6167 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");
6168 let issues = fx.store_all();
6169 let files: Vec<&PathBuf> = issues.iter().map(|i| &i.file).collect();
6170 let mut sorted = files.clone();
6171 sorted.sort();
6172 assert_eq!(
6173 files, sorted,
6174 "issues must be emitted in a stable file order"
6175 );
6176 }
6177
6178 #[test]
6181 fn frozen_page_is_not_a_validate_error() {
6182 let mut fx = Fixture::new();
6185 fx.config
6186 .frozen_pages
6187 .push(PathBuf::from("records/decisions/d.md"));
6188 fx.write(
6189 "records/decisions/d.md",
6190 "---\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",
6191 );
6192 let issues = fx.store_all();
6193 assert!(
6194 !has(&issues, codes::POLICY_FROZEN_PAGE),
6195 "frozen pages are enforced at write-time, not by validate: {issues:#?}"
6196 );
6197 }
6198
6199 #[test]
6200 fn wiki_link_ambiguous_is_never_emitted_under_full_path_doctrine() {
6201 let fx = Fixture::new();
6204 fx.write("records/contacts/sarah-chen.md", &valid_contact("sarah"));
6205 let mut body = valid_contact("links to sarah");
6206 body.push_str("\nSee [[records/contacts/sarah-chen]].\n");
6207 fx.write("records/contacts/p.md", &body);
6208 let issues = fx.store_all();
6209 assert!(!has(&issues, codes::WIKI_LINK_AMBIGUOUS), "{issues:#?}");
6210 }
6211
6212 #[test]
6215 fn unknown_type_passes_through() {
6216 let fx = Fixture::new();
6220 fx.write(
6221 "records/proposals/x.md",
6222 "---\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",
6223 );
6224 let issues = fx.store_all();
6225 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
6226 assert!(!has(&issues, codes::SCHEMA_MISSING_REQUIRED), "{issues:#?}");
6227 assert!(!has(&issues, codes::SCHEMA_SHAPE_MISMATCH), "{issues:#?}");
6228 assert!(
6230 !issues
6231 .iter()
6232 .any(|i| i.key.as_deref() == Some("custom_field")
6233 || i.key.as_deref() == Some("budget")),
6234 "unknown fields are ambient context: {issues:#?}"
6235 );
6236 }
6237
6238 #[test]
6241 fn incoming_linker_scan_does_not_prefix_match() {
6242 let fx = Fixture::new();
6245 fx.write(
6246 "records/profiles/only-sarah-chen.md",
6247 "---\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",
6248 );
6249 fx.write(
6251 "log.md",
6252 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah\nremoved\n",
6253 );
6254 let issues = validate_working_set(&fx.store(), None).unwrap();
6255 assert!(
6256 !issues
6257 .iter()
6258 .any(|i| i.file == Path::new("records/profiles/only-sarah-chen.md")),
6259 "a prefix-sharing link must not pull a file into the working set: {issues:#?}"
6260 );
6261 }
6262
6263 #[test]
6264 fn working_set_does_not_flag_stale_catalog_index_as_wiki_link_broken() {
6265 let fx = Fixture::new();
6279 fx.write(
6282 "records/contacts/index.md",
6283 "---\ntype: index\n---\n\n- [[records/contacts/sarah-chen]] — Sarah Chen\n",
6284 );
6285 fx.write(
6287 "log.md",
6288 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n",
6289 );
6290 let issues = validate_working_set(&fx.store(), None).unwrap();
6291 assert!(
6292 !issues
6293 .iter()
6294 .any(|i| i.file == Path::new("records/contacts/index.md")
6295 && i.code == codes::WIKI_LINK_BROKEN),
6296 "a stale catalog `index.md` entry must NOT be WIKI_LINK_BROKEN in the \
6297 working set (it is an INDEX_STALE_ENTRY under `--all`): {issues:#?}"
6298 );
6299 }
6300
6301 #[test]
6302 fn incoming_linker_scan_covers_the_whole_changed_set_in_one_pass() {
6303 let fx = Fixture::new();
6312 fx.write(
6314 "records/profiles/refers-sarah.md",
6315 "---\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",
6316 );
6317 fx.write(
6321 "records/meetings/2026/05/kickoff.md",
6322 "---\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",
6323 );
6324 fx.write(
6326 "log.md",
6327 "---\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",
6328 );
6329
6330 let issues = validate_working_set(&fx.store(), None).unwrap();
6331 assert!(
6332 issues
6333 .iter()
6334 .any(|i| i.file == Path::new("records/profiles/refers-sarah.md")
6335 && i.code == codes::WIKI_LINK_BROKEN),
6336 "linker to the FIRST deleted target must be pulled in and flagged: {issues:#?}"
6337 );
6338 assert!(
6339 issues.iter().any(
6340 |i| i.file == Path::new("records/meetings/2026/05/kickoff.md")
6341 && i.code == codes::WIKI_LINK_BROKEN
6342 ),
6343 "linker to the SECOND deleted target (typed-field edge) must also be \
6344 pulled in and flagged — proves the scan covers the whole changed set, \
6345 not just one object: {issues:#?}"
6346 );
6347 }
6348
6349 #[test]
6350 fn frontmatter_block_sequence_links_each_get_their_own_line() {
6351 let fx = Fixture::new();
6353 fx.write(
6355 "records/meetings/m.md",
6356 "---\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",
6357 );
6358 let issues = fx.store_all();
6359 let broken_lines: BTreeSet<Option<u32>> = issues
6360 .iter()
6361 .filter(|i| i.code == codes::WIKI_LINK_BROKEN)
6362 .map(|i| i.line)
6363 .collect();
6364 assert_eq!(
6365 broken_lines.len(),
6366 2,
6367 "two distinct broken-link lines: {issues:#?}"
6368 );
6369 }
6370
6371 #[test]
6374 fn null_created_is_missing_not_silently_passed() {
6375 let fx = Fixture::new();
6379 fx.write(
6380 "records/contacts/a.md",
6381 "---\ntype: contact\ncreated:\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6382 );
6383 let issues = fx.store_all();
6384 assert!(
6385 has(&issues, codes::FM_MISSING_CREATED),
6386 "null `created:` must read as missing: {issues:#?}"
6387 );
6388 }
6389
6390 #[test]
6391 fn sequence_created_is_bad_timestamp() {
6392 let fx = Fixture::new();
6394 fx.write(
6395 "records/contacts/a.md",
6396 "---\ntype: contact\ncreated: [2026]\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6397 );
6398 let issues = fx.store_all();
6399 assert!(
6400 issues
6401 .iter()
6402 .any(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created")),
6403 "a sequence `created:` must be FM_BAD_TIMESTAMP: {issues:#?}"
6404 );
6405 }
6406
6407 #[test]
6410 fn required_field_null_or_empty_collection_is_missing() {
6411 for value in ["", " []", " {}"] {
6416 let mut fx = Fixture::new();
6417 fx.config.schemas.insert(
6418 "contact".into(),
6419 Schema {
6420 fields: vec![FieldSpec {
6421 name: "name".into(),
6422 required: true,
6423 ..Default::default()
6424 }],
6425 ..Default::default()
6426 },
6427 );
6428 fx.write(
6429 "records/contacts/a.md",
6430 &format!(
6431 "---\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"
6432 ),
6433 );
6434 let issues = fx.store_all();
6435 assert!(
6436 issues
6437 .iter()
6438 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
6439 && i.key.as_deref() == Some("name")),
6440 "required `name:{value}` must be SCHEMA_MISSING_REQUIRED: {issues:#?}"
6441 );
6442 }
6443 }
6444
6445 #[test]
6448 fn wiki_link_to_raw_source_file_resolves() {
6449 let fx = Fixture::new();
6453 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6454 fx.write(
6455 "records/contacts/a.md",
6456 "---\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",
6457 );
6458 let issues = fx.store_all();
6459 assert!(
6460 !issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
6461 "a link to an existing raw source file must not be broken: {issues:#?}"
6462 );
6463 }
6464
6465 #[test]
6468 fn wrong_case_wiki_link_is_broken_exact_case() {
6469 let fx = Fixture::new();
6475 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6476 let mut body = valid_contact("links with the wrong case");
6477 body.push_str("\nKnows [[records/contacts/BOB]].\n");
6478 fx.write("records/contacts/alice.md", &body);
6479 let issues = fx.store_all();
6480 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6481 assert!(issue.is_error());
6482 assert!(
6483 issue.message.contains("records/contacts/BOB"),
6484 "the wrong-case target must be named in the issue: {issues:#?}"
6485 );
6486 }
6487
6488 #[test]
6489 fn correct_case_wiki_link_still_resolves() {
6490 let fx = Fixture::new();
6494 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6495 let mut body = valid_contact("links with the right case");
6496 body.push_str("\nKnows [[records/contacts/bob]].\n");
6497 fx.write("records/contacts/alice.md", &body);
6498 let issues = fx.store_all();
6499 assert!(
6500 !issues
6501 .iter()
6502 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("contacts/bob")),
6503 "a correct-case link must resolve clean: {issues:#?}"
6504 );
6505 }
6506
6507 #[test]
6508 fn wrong_case_raw_source_wiki_link_is_broken() {
6509 let fx = Fixture::new();
6514 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6515 fx.write(
6516 "records/contacts/a.md",
6517 "---\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",
6518 );
6519 let issues = fx.store_all();
6520 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6521 assert!(issue.is_error());
6522 assert!(
6523 issue.message.contains("2026-05-22-ELENA.eml"),
6524 "the wrong-case raw-source target must be flagged: {issues:#?}"
6525 );
6526 }
6527
6528 #[test]
6531 fn non_utf8_content_file_is_reported() {
6532 let fx = Fixture::new();
6536 let abs = fx.dir.path().join("records/notes/corrupt.md");
6537 fs::create_dir_all(abs.parent().unwrap()).unwrap();
6538 fs::write(&abs, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
6539 let issues = validate_working_set(&fx.store(), None).unwrap();
6540 assert!(
6541 has(&issues, codes::FM_UNREADABLE),
6542 "an unreadable content file must be reported, not silently skipped: {issues:#?}"
6543 );
6544 }
6545
6546 #[test]
6549 fn tilde_fence_containing_backtick_fence_does_not_invert() {
6550 let body = "~~~markdown\n```\n[[fake-link]]\n```\n~~~\n";
6555 let links = extract_wiki_links(body);
6556 assert!(
6557 links.is_empty(),
6558 "wiki-link inside a nested code fence must be skipped: {links:?}"
6559 );
6560 }
6561
6562 #[test]
6565 fn all_sweep_visits_in_layer_log_folder() {
6566 let fx = Fixture::new();
6571 fx.write("records/log/2026-06-01-pricing.md", "no frontmatter here\n");
6572 let issues = fx.store_all();
6573 assert!(
6574 has(&issues, codes::FM_MISSING_TYPE),
6575 "--all must validate files under an in-layer `log/` folder: {issues:#?}"
6576 );
6577 }
6578
6579 #[test]
6582 fn flow_form_link_list_with_spaces_is_flagged() {
6583 let keys = detect_flow_form_link_lists("attendees: [ [[records/contacts/elena]] ]\n");
6587 assert!(
6588 keys.iter().any(|k| k == "attendees"),
6589 "spaced flow-form list must be detected: {keys:?}"
6590 );
6591 }
6592
6593 #[test]
6596 fn middot_hashtag_summary_tail_round_trips() {
6597 assert_eq!(
6603 extract_index_entry_summary("— Standup notes · #standup").as_deref(),
6604 Some("Standup notes · #standup"),
6605 "a single-spaced middot tail is part of the summary, not a tag block"
6606 );
6607 assert_eq!(
6609 extract_index_entry_summary("— Renewal champion · #renewal #acme").as_deref(),
6610 Some("Renewal champion"),
6611 "the renderer's double-spaced ` · #tag` suffix is stripped"
6612 );
6613 }
6614
6615 #[test]
6618 fn url_shape_accepts_short_http_and_rejects_bare_scheme() {
6619 assert!(is_url("http://x"), "an 8-char http URL is valid");
6620 assert!(is_url("https://x"), "a 9-char https URL is valid");
6621 assert!(!is_url("http://"), "a bare scheme with no host is rejected");
6622 assert!(!is_url("https://"), "a bare https scheme is rejected");
6623 }
6624
6625 #[test]
6626 fn email_shape_rejects_double_at() {
6627 assert!(!is_email("sarah@@acme.com"), "double-@ domain is rejected");
6628 assert!(!is_email("a@b@c.com"), "two @ signs are rejected");
6629 assert!(is_email("sarah@acme.com"), "a normal address still passes");
6630 }
6631
6632 #[test]
6635 fn working_set_does_not_flag_log_md_body_links() {
6636 let fx = Fixture::new();
6642 fx.write("records/contacts/a.md", &valid_contact("A"));
6643 fx.write(
6644 "log.md",
6645 "---\ntype: log\n---\n\n## [2026-06-01 10:00] delete | records/contacts/ghost\n\nRemoved [[records/contacts/ghost]] per cleanup.\n",
6646 );
6647 let issues = validate_working_set(&fx.store(), None).unwrap();
6648 assert!(
6649 !issues
6650 .iter()
6651 .any(|i| i.code == codes::WIKI_LINK_BROKEN
6652 && i.file == std::path::Path::new("log.md")),
6653 "a broken wiki-link inside append-only log.md must not be flagged: {issues:#?}"
6654 );
6655 }
6656
6657 #[test]
6660 fn schema_duplicate_field_name_is_flagged() {
6661 let mut fx = Fixture::new();
6662 fx.config.schemas.insert(
6663 "contact".into(),
6664 Schema {
6665 fields: vec![
6666 FieldSpec {
6667 name: "name".into(),
6668 required: true,
6669 ..Default::default()
6670 },
6671 FieldSpec {
6672 name: "name".into(),
6673 ..Default::default()
6674 },
6675 ],
6676 ..Default::default()
6677 },
6678 );
6679 let issues = fx.store_all();
6680 assert!(
6681 issues
6682 .iter()
6683 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.key.as_deref() == Some("name")),
6684 "a duplicate schema field name must be flagged: {issues:#?}"
6685 );
6686 }
6687
6688 #[test]
6689 fn schema_unknown_modifier_is_info() {
6690 let mut fx = Fixture::new();
6691 fx.config.schemas.insert(
6692 "contact".into(),
6693 Schema {
6694 fields: vec![FieldSpec {
6695 name: "name".into(),
6696 unknown_modifiers: vec!["requierd".into()],
6697 ..Default::default()
6698 }],
6699 ..Default::default()
6700 },
6701 );
6702 let issues = fx.store_all();
6703 assert!(
6704 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6705 && i.severity == Severity::Info
6706 && i.key.as_deref() == Some("name")),
6707 "an unrecognized schema modifier must surface as Info: {issues:#?}"
6708 );
6709 }
6710
6711 #[test]
6717 fn schema_unique_key_optional_field_is_warning() {
6718 let mut fx = Fixture::new();
6719 fx.config.schemas.insert(
6720 "expense".into(),
6721 Schema {
6722 fields: vec![
6723 FieldSpec {
6724 name: "date".into(),
6725 required: true,
6726 ..Default::default()
6727 },
6728 FieldSpec {
6729 name: "amount".into(),
6730 required: true,
6731 ..Default::default()
6732 },
6733 FieldSpec {
6734 name: "vendor".into(),
6735 ..Default::default()
6736 },
6737 ],
6738 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
6739 ..Default::default()
6740 },
6741 );
6742 let issues = fx.store_all();
6743 assert!(
6744 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6745 && i.severity == Severity::Warning
6746 && i.key.as_deref() == Some("vendor")
6747 && i.message.contains("unique")),
6748 "a `unique:` key field not marked required must warn: {issues:#?}"
6749 );
6750 assert!(
6752 !issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6753 && matches!(i.key.as_deref(), Some("date") | Some("amount"))),
6754 "required key fields must not warn: {issues:#?}"
6755 );
6756 }
6757
6758 #[test]
6763 fn body_leading_frontmatter_block_is_warning() {
6764 let fx = Fixture::new();
6765 fx.write(
6766 "records/notes/imported.md",
6767 "---\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",
6768 );
6769 let issues = fx.store_all();
6770 assert!(
6771 issues
6772 .iter()
6773 .any(|i| i.code == codes::FM_IN_BODY && i.severity == Severity::Warning),
6774 "a body opening with a second frontmatter block must warn: {issues:#?}"
6775 );
6776 }
6777
6778 #[test]
6781 fn body_thematic_break_rules_do_not_warn() {
6782 let fx = Fixture::new();
6783 fx.write(
6784 "records/notes/rules.md",
6785 "---\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",
6786 );
6787 let issues = fx.store_all();
6788 assert!(
6789 !has(&issues, codes::FM_IN_BODY),
6790 "a `---` thematic rule around prose (not a YAML mapping) must NOT warn: {issues:#?}"
6791 );
6792 }
6793
6794 #[test]
6798 fn body_fenced_frontmatter_example_does_not_warn() {
6799 let fx = Fixture::new();
6800 fx.write(
6801 "records/notes/doc.md",
6802 "---\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",
6803 );
6804 let issues = fx.store_all();
6805 assert!(
6806 !has(&issues, codes::FM_IN_BODY),
6807 "a fenced example block (body opens with a code fence, not `---`) must NOT warn: {issues:#?}"
6808 );
6809 }
6810
6811 #[test]
6814 fn schema_unique_key_undeclared_field_is_warning() {
6815 let mut fx = Fixture::new();
6816 fx.config.schemas.insert(
6817 "expense".into(),
6818 Schema {
6819 fields: vec![FieldSpec {
6820 name: "date".into(),
6821 required: true,
6822 ..Default::default()
6823 }],
6824 unique_keys: vec![vec!["date".into(), "vendor".into()]],
6825 ..Default::default()
6826 },
6827 );
6828 let issues = fx.store_all();
6829 assert!(
6830 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6831 && i.severity == Severity::Warning
6832 && i.key.as_deref() == Some("vendor")
6833 && i.message.contains("not declared")),
6834 "a `unique:` key field absent from the schema must warn: {issues:#?}"
6835 );
6836 }
6837
6838 #[test]
6840 fn schema_unique_key_all_required_is_clean() {
6841 let mut fx = Fixture::new();
6842 fx.config.schemas.insert(
6843 "expense".into(),
6844 Schema {
6845 fields: vec![
6846 FieldSpec {
6847 name: "date".into(),
6848 required: true,
6849 ..Default::default()
6850 },
6851 FieldSpec {
6852 name: "amount".into(),
6853 required: true,
6854 ..Default::default()
6855 },
6856 ],
6857 unique_keys: vec![vec!["date".into(), "amount".into()]],
6858 ..Default::default()
6859 },
6860 );
6861 let issues = fx.store_all();
6862 assert!(
6863 !issues
6864 .iter()
6865 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.message.contains("unique")),
6866 "an all-required unique key must not warn: {issues:#?}"
6867 );
6868 }
6869
6870 #[test]
6876 fn every_code_constant_is_documented_in_spec() {
6877 let this_src = include_str!("validate.rs");
6881 let mut codes_in_module: Vec<String> = Vec::new();
6882 let mut in_codes_mod = false;
6883 for line in this_src.lines() {
6884 let t = line.trim();
6885 if t.starts_with("pub mod codes") {
6886 in_codes_mod = true;
6887 continue;
6888 }
6889 if in_codes_mod && line == "}" {
6891 break;
6892 }
6893 if in_codes_mod {
6894 if let Some(rest) = t.strip_prefix("pub const ") {
6895 let value = rest
6897 .split_once('=')
6898 .map(|(_, v)| v.trim())
6899 .and_then(|v| v.strip_prefix('"'))
6900 .and_then(|v| v.strip_suffix("\";"))
6901 .unwrap_or_else(|| panic!("unparseable code constant line: {line:?}"));
6902 codes_in_module.push(value.to_string());
6903 }
6904 }
6905 }
6906 assert!(
6907 codes_in_module.len() >= 36,
6908 "parsed only {} code constants from `mod codes`; the parser likely \
6909 broke against a source-format change",
6910 codes_in_module.len()
6911 );
6912
6913 let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SPEC.md");
6915 let spec = fs::read_to_string(&spec_path)
6916 .unwrap_or_else(|e| panic!("cannot read {}: {e}", spec_path.display()));
6917
6918 let missing: Vec<&String> = codes_in_module
6920 .iter()
6921 .filter(|code| !spec.contains(&format!("| `{code}` |")))
6922 .collect();
6923 assert!(
6924 missing.is_empty(),
6925 "validation codes emitted by the engine but absent from SPEC.md \
6926 § Validation (the declared complete vocabulary): {missing:?}"
6927 );
6928 }
6929
6930 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";
6933 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";
6934
6935 #[test]
6936 fn loose_file_catalogued_in_layer_jsonl_validates_clean() {
6937 let fx = Fixture::new();
6938 fx.write("records/contacts/alice.md", LOOSE_ALICE);
6939 fx.write("records/bob.md", LOOSE_BOB); fx.rebuild_indexes();
6941 let issues = fx.store_all();
6942 assert!(
6943 issues.is_empty(),
6944 "a rebuilt store with a catalogued loose file must validate clean, got: {issues:?}"
6945 );
6946 }
6947
6948 #[test]
6949 fn loose_file_with_missing_layer_jsonl_is_index_jsonl_missing() {
6950 let fx = Fixture::new();
6951 fx.write("records/contacts/alice.md", LOOSE_ALICE);
6952 fx.write("records/bob.md", LOOSE_BOB);
6953 fx.rebuild_indexes();
6954 fs::remove_file(fx.dir.path().join("records/index.jsonl")).unwrap();
6956 let issues = fx.store_all();
6957 assert!(
6958 has(&issues, codes::INDEX_JSONL_MISSING),
6959 "a loose file with no layer index.jsonl must raise INDEX_JSONL_MISSING, got: {issues:?}"
6960 );
6961 }
6962}