1use crate::validate::{Diagnostic, Report, Severity};
48use okf_core::bundle::Bundle;
49use okf_core::concept_id::ConceptId;
50use okf_core::date::Date;
51use okf_core::document::Document;
52use okf_core::frontmatter::Frontmatter;
53use okf_core::trust::Status;
54use std::collections::{BTreeSet, HashMap};
55use std::fs;
56use std::path::{Path, PathBuf};
57
58#[must_use]
63pub fn lint_bundle(bundle: &Bundle) -> Report {
64 lint_bundle_at(bundle, None)
65}
66
67#[must_use]
69pub fn lint_bundle_at(bundle: &Bundle, today: Option<Date>) -> Report {
70 let mut report = Report::default();
71
72 let indexed = indexed_concepts(bundle);
73 let title_counts = count_titles(bundle);
74
75 for concept in bundle.concepts() {
76 let mut cx = Cx {
77 report: &mut report,
78 path: concept.path.clone(),
79 id: concept.id.clone(),
80 };
81 let doc = &concept.document;
82 let fm = &doc.frontmatter;
83
84 check_missing_title(&mut cx, fm);
85 check_missing_description(&mut cx, fm);
86 check_missing_generated(&mut cx, fm);
87 check_unverified(&mut cx, fm);
88 check_legacy(&mut cx, doc);
89 check_empty_body(&mut cx, doc);
90 check_top_heading(&mut cx, doc);
91 check_verified_before_generated(&mut cx, fm);
92 check_links_to_deprecated(&mut cx, bundle);
93 check_staleness(&mut cx, fm, today);
94 check_draft_status(&mut cx, fm);
95 check_self_link(&mut cx, bundle);
96 check_broken_links(&mut cx, bundle);
97 check_duplicate_title(&mut cx, fm, &title_counts);
98 check_key_order(&mut cx, fm);
99 check_heading_hierarchy(&mut cx, doc);
100 check_empty_headings(&mut cx, doc);
101 check_unused_sources(&mut cx, doc);
102 check_non_standard_actor(&mut cx, fm);
103 check_future_timestamps(&mut cx, fm, today);
104 check_attestation_resources(&mut cx, bundle, doc);
105 check_computation_block_formatting(&mut cx, doc);
106 check_whitespace(&mut cx, doc);
107 }
108
109 check_orphans(bundle, &indexed, &mut report);
110 check_stale_indexes(bundle, &mut report);
111 check_circular_derivation(bundle, &mut report);
112 check_duplicate_log_dates(bundle, &mut report);
113
114 report
115}
116
117fn indexed_concepts(bundle: &Bundle) -> BTreeSet<ConceptId> {
120 let mut out = BTreeSet::new();
121 for index_path in bundle.index_files() {
122 for (raw, target) in index_listed_targets(bundle, index_path) {
123 if is_concept_link(&raw) && bundle.contains(&target) {
124 out.insert(target);
125 }
126 }
127 }
128 out
129}
130
131fn index_listed_targets(bundle: &Bundle, index_path: &Path) -> Vec<(String, ConceptId)> {
140 let mut out = Vec::new();
141 let Some(source) = index_source_id(bundle.root(), index_path) else {
142 return out;
143 };
144 let Ok(text) = fs::read_to_string(index_path) else {
145 return out;
146 };
147 let Ok(doc) = Document::parse(&text) else {
148 return out;
149 };
150 for link in doc.links() {
151 for target in link.resolve_all(&source) {
152 out.push((link.target.clone(), target));
153 }
154 }
155 out
156}
157
158fn is_concept_link(raw: &str) -> bool {
161 let t = raw.trim();
162 if t.starts_with('#') || t.is_empty() {
163 return false;
164 }
165 if okf_core::links::LinkKind::External == okf_core::links::Link::classify(t) {
166 return false;
167 }
168 let before_anchor = t.split('#').next().unwrap_or(t);
169 let basename = before_anchor.rsplit('/').next().unwrap_or(before_anchor);
170 #[allow(clippy::case_sensitive_file_extension_comparisons)]
173 {
174 basename.ends_with(".md") || !basename.contains('.')
175 }
176}
177
178fn index_source_id(bundle_root: &Path, index_path: &Path) -> Option<ConceptId> {
187 let rel = index_path.strip_prefix(bundle_root).ok()?;
188 let mut segments: Vec<String> = rel
189 .components()
190 .filter_map(|c| match c {
191 std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
192 _ => None,
193 })
194 .collect();
195 if let Some(last) = segments.last_mut()
196 && let Some(stripped) = last.strip_suffix(".md")
197 {
198 *last = stripped.to_string();
199 }
200 ConceptId::new(segments).ok()
201}
202
203fn count_titles(bundle: &Bundle) -> HashMap<String, usize> {
205 let mut counts: HashMap<String, usize> = HashMap::new();
206 for c in bundle.concepts() {
207 if let Some(title) = c.document.frontmatter.title() {
208 *counts.entry(title.into_owned()).or_default() += 1;
209 }
210 }
211 counts
212}
213
214struct Cx<'a> {
218 report: &'a mut Report,
219 path: PathBuf,
220 id: ConceptId,
221}
222
223impl Cx<'_> {
224 fn warn(&mut self, code: &'static str, message: impl Into<String>) {
225 self.push(Severity::Warning, code, message);
226 }
227
228 fn info(&mut self, code: &'static str, message: impl Into<String>) {
229 self.push(Severity::Info, code, message);
230 }
231
232 fn push(&mut self, severity: Severity, code: &'static str, message: impl Into<String>) {
233 let fixable = is_fixable_lint(code);
234 self.report.diagnostics.push(Diagnostic {
235 severity,
236 path: Some(self.path.clone()),
237 concept: Some(self.id.clone()),
238 message: format!("[{code}] {}", message.into()),
239 fixable,
240 });
241 }
242}
243
244const fn is_fixable_lint(code: &str) -> bool {
246 matches!(
247 code.as_bytes(),
248 b"L1" | b"L3" | b"L5" | b"L6" | b"L8" | b"L16" | b"L18" | b"L26" | b"L27" | b"L28"
249 )
250}
251
252fn check_missing_title(cx: &mut Cx, fm: &Frontmatter) {
253 if fm.title().is_none() {
254 cx.warn(
255 "L1",
256 "missing `title`; consumers fall back to the filename, but a human-readable \
257 title is recommended",
258 );
259 }
260}
261
262fn check_missing_description(cx: &mut Cx, fm: &Frontmatter) {
263 if fm.description().is_none() {
264 cx.warn(
265 "L2",
266 "missing `description`; a one-line summary is recommended and \
267 what `index.md` listings display",
268 );
269 }
270}
271
272fn check_missing_generated(cx: &mut Cx, fm: &Frontmatter) {
273 let has_generated_key = fm.get("generated").is_some();
274 let has_legacy_timestamp = fm.timestamp().is_some();
275 if !has_generated_key && !has_legacy_timestamp {
276 cx.warn(
277 "L3",
278 "missing `generated`; a continuously-authored corpus should record who \
279 produced the content and when",
280 );
281 }
282}
283
284fn check_unverified(cx: &mut Cx, fm: &Frontmatter) {
285 if fm.get("verified").is_none() {
286 cx.info("L4", "no `verified` events; trust tier is `unverified`");
287 }
288}
289
290fn check_legacy(cx: &mut Cx, doc: &Document) {
291 if doc.frontmatter.timestamp().is_some() {
292 cx.warn(
293 "L5",
294 "`timestamp` is a v0.1 key superseded by `generated: { by, at }`",
295 );
296 }
297 if doc.has_legacy_citations() {
298 cx.warn(
299 "L6",
300 "body `# Citations` list is superseded by `sources` + footnote attribution",
301 );
302 }
303}
304
305fn check_empty_body(cx: &mut Cx, doc: &Document) {
306 if doc.body.trim().is_empty() {
307 cx.warn(
308 "L7",
309 "body is empty; a concept should carry at least one line of prose or code",
310 );
311 }
312}
313
314fn check_top_heading(cx: &mut Cx, doc: &Document) {
315 if doc.body.trim().is_empty() {
316 return; }
318 let has_top_heading = doc.body.lines().any(|l| l.trim_start().starts_with("# "));
319 if !has_top_heading {
320 cx.warn(
321 "L8",
322 "body has no top-level `#` heading; OKF docs conventionally open with one",
323 );
324 }
325}
326
327fn check_verified_before_generated(cx: &mut Cx, fm: &Frontmatter) {
328 let Some(generated) = fm.generated() else {
329 return;
330 };
331 let Some(generated_at) = generated.at.as_ref().and_then(|a| a.datetime) else {
332 return;
333 };
334 let verified = fm.verified();
335 let Some(latest) = okf_core::trust::latest_verification(&verified) else {
336 return;
337 };
338 let Some(latest_at) = latest.at.as_ref().and_then(|a| a.datetime) else {
339 return;
340 };
341 if latest_at < generated_at {
342 cx.warn(
343 "L9",
344 format!(
345 "latest verification ({latest_at}) predates `generated.at` ({generated_at}); \
346 the current content was never re-verified"
347 ),
348 );
349 }
350}
351
352fn check_links_to_deprecated(cx: &mut Cx, bundle: &Bundle) {
353 let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
354 for link in bundle.links_from(&cx.id) {
355 if !link.exists || !warned.insert(link.target.clone()) {
356 continue;
357 }
358 if let Some(target) = bundle.get(&link.target)
359 && target.status().is_deprecated()
360 {
361 cx.warn(
362 "L10",
363 format!("links to deprecated concept `{}`", link.target),
364 );
365 }
366 }
367}
368
369fn check_staleness(cx: &mut Cx, fm: &Frontmatter, today: Option<Date>) {
370 let Some(today) = today else {
371 return;
372 };
373 let Some(stale_after) = fm.stale_after() else {
374 return;
375 };
376 if fm.is_stale_on(today) {
377 cx.warn(
378 "L11",
379 format!("stale since {stale_after} (`stale_after` passed)"),
380 );
381 }
382}
383
384fn check_draft_status(cx: &mut Cx, fm: &Frontmatter) {
385 if matches!(fm.status(), Status::Draft) {
386 cx.info(
387 "L12",
388 "`status: draft`; a draft concept is not ready for production consumption",
389 );
390 }
391}
392
393fn check_self_link(cx: &mut Cx, bundle: &Bundle) {
394 for link in bundle.links_from(&cx.id) {
395 if link.exists && link.target == cx.id {
396 cx.info(
397 "L13",
398 "self-link; a concept that links to itself usually signals a stray reference",
399 );
400 return;
401 }
402 }
403}
404
405fn check_broken_links(cx: &mut Cx, bundle: &Bundle) {
406 for link in bundle.links_from(&cx.id) {
407 if !link.exists {
408 cx.warn(
409 "L17",
410 format!(
411 "broken link to `{}` (target concept does not exist)",
412 link.raw
413 ),
414 );
415 }
416 }
417}
418
419fn check_duplicate_title(cx: &mut Cx, fm: &Frontmatter, counts: &HashMap<String, usize>) {
420 let Some(title) = fm.title() else {
421 return;
422 };
423 if counts.get(title.as_ref()).copied().unwrap_or(0) > 1 {
424 cx.warn(
425 "L14",
426 format!("`title` {title:?} is shared with another concept; titles should disambiguate"),
427 );
428 }
429}
430
431fn check_orphans(bundle: &Bundle, indexed: &BTreeSet<ConceptId>, report: &mut Report) {
432 for c in bundle.concepts() {
433 let has_backlinks = !bundle.backlinks(&c.id).is_empty();
434 let is_indexed = indexed.contains(&c.id);
435 if !has_backlinks && !is_indexed {
436 report.diagnostics.push(Diagnostic {
437 severity: Severity::Warning,
438 path: Some(c.path.clone()),
439 concept: Some(c.id.clone()),
440 message: "[L15] orphan concept: no other concept links to it and no \
441 `index.md` lists it"
442 .to_string(),
443 fixable: false,
444 });
445 }
446 }
447}
448
449fn check_stale_indexes(bundle: &Bundle, report: &mut Report) {
450 for index_path in bundle.index_files() {
451 let Some(dir) = index_path.parent() else {
452 continue;
453 };
454 let Some(index_id) = index_source_id(bundle.root(), index_path) else {
455 continue;
456 };
457 let index_dir = index_id.parent();
460
461 let actual: BTreeSet<ConceptId> = bundle
462 .concepts()
463 .iter()
464 .filter(|c| c.path.parent() == Some(dir))
465 .map(|c| c.id.clone())
466 .collect();
467
468 let listed: BTreeSet<ConceptId> = index_listed_targets(bundle, index_path)
475 .into_iter()
476 .filter(|(raw, _)| is_concept_link(raw))
477 .map(|(_, target)| target)
478 .filter(|t| t.parent() == index_dir)
479 .collect();
480
481 let missing_from_index: Vec<String> = actual
482 .iter()
483 .filter(|c| !listed.contains(*c))
484 .map(ConceptId::to_string)
485 .collect();
486 let listed_but_not_on_disk: Vec<String> = listed
487 .iter()
488 .filter(|c| !actual.contains(*c))
489 .map(ConceptId::to_string)
490 .collect();
491
492 if missing_from_index.is_empty() && listed_but_not_on_disk.is_empty() {
493 continue;
494 }
495
496 let mut parts = Vec::new();
497 if !missing_from_index.is_empty() {
498 parts.push(format!(
499 "missing from index: {}",
500 missing_from_index.join(", ")
501 ));
502 }
503 if !listed_but_not_on_disk.is_empty() {
504 parts.push(format!(
505 "listed but not on disk: {}",
506 listed_but_not_on_disk.join(", ")
507 ));
508 }
509
510 report.diagnostics.push(Diagnostic {
511 severity: Severity::Warning,
512 path: Some(index_path.clone()),
513 concept: None,
514 message: format!(
515 "[L16] index.md is out of sync with its directory ({})",
516 parts.join("; ")
517 ),
518 fixable: true,
519 });
520 }
521}
522
523fn check_key_order(cx: &mut Cx, fm: &Frontmatter) {
524 let keys: Vec<&str> = fm.as_mapping().keys().collect();
525 if keys.len() < 2 {
526 return;
527 }
528 let mut last_rank = None;
529 for key in keys {
530 if let Some(rank) = okf_core::frontmatter::PREFERRED_KEY_ORDER
531 .iter()
532 .position(|&k| k == key)
533 {
534 if let Some(prev) = last_rank
535 && rank < prev
536 {
537 cx.info(
538 "L18",
539 "frontmatter keys are not in canonical order (run `okf fmt` to normalize)",
540 );
541 return;
542 }
543 last_rank = Some(rank);
544 }
545 }
546}
547
548struct MarkdownHeading<'a> {
549 level: usize,
550 text: &'a str,
551 line_num: usize,
552}
553
554fn parse_heading_line(line: &str) -> Option<(usize, &str)> {
555 let t = line.trim_start();
556 if !t.starts_with('#') {
557 return None;
558 }
559 let count = t.chars().take_while(|&c| c == '#').count();
560 if (1..=6).contains(&count) && t[count..].starts_with(' ') {
561 Some((count, t[count..].trim()))
562 } else {
563 None
564 }
565}
566
567fn parse_markdown_headings(body: &str) -> Vec<MarkdownHeading<'_>> {
568 let mut headings = Vec::new();
569 let mut in_code_block = false;
570
571 for (i, line) in body.lines().enumerate() {
572 let trimmed = line.trim();
573 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
574 in_code_block = !in_code_block;
575 continue;
576 }
577 if in_code_block {
578 continue;
579 }
580 if let Some((level, text)) = parse_heading_line(trimmed) {
581 headings.push(MarkdownHeading {
582 level,
583 text,
584 line_num: i + 1,
585 });
586 }
587 }
588 headings
589}
590
591fn check_heading_hierarchy(cx: &mut Cx, doc: &Document) {
592 let headings = parse_markdown_headings(&doc.body);
593 if headings.is_empty() {
594 return;
595 }
596
597 let is_attested = doc.frontmatter.is_attested_computation();
598 let mut h1_count = 0;
599 let mut prev_level = 0;
600
601 for h in &headings {
602 if h.level == 1 {
603 h1_count += 1;
604 if h1_count > 1 && !(is_attested && h1_count == 2 && h.text == "Computation") {
605 cx.warn(
606 "L19",
607 format!(
608 "multiple top-level `#` headings found (heading `{}` at line {})",
609 h.text, h.line_num
610 ),
611 );
612 }
613 }
614
615 if prev_level > 0 && h.level > prev_level + 1 {
616 cx.warn(
617 "L19",
618 format!(
619 "heading level skipped: `{}` jumps from h{prev_level} to h{}",
620 h.text, h.level
621 ),
622 );
623 }
624 prev_level = h.level;
625 }
626}
627
628fn check_empty_headings(cx: &mut Cx, doc: &Document) {
629 let lines: Vec<&str> = doc.body.lines().collect();
630 let mut in_code_block = false;
631
632 let mut headings_with_line: Vec<(usize, usize, &str)> = Vec::new();
633 for (i, line) in lines.iter().enumerate() {
634 let trimmed = line.trim();
635 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
636 in_code_block = !in_code_block;
637 continue;
638 }
639 if in_code_block {
640 continue;
641 }
642 if let Some((level, text)) = parse_heading_line(trimmed) {
643 headings_with_line.push((i, level, text));
644 }
645 }
646
647 for (k, &(line_idx, level, text)) in headings_with_line.iter().enumerate() {
648 let next_heading = headings_with_line
649 .get(k + 1)
650 .map(|&(next_idx, next_level, _)| (next_idx, next_level));
651
652 let content_end = match next_heading {
653 Some((next_idx, next_level)) => {
654 if next_level > level {
655 continue;
656 }
657 next_idx
658 }
659 None => lines.len(),
660 };
661
662 let has_content = (line_idx + 1..content_end).any(|idx| {
663 let l = lines[idx].trim();
664 !l.is_empty() && !l.starts_with("<!--")
665 });
666
667 if !has_content {
668 cx.warn("L20", format!("heading `{text}` has no content"));
669 }
670 }
671}
672
673fn check_unused_sources(cx: &mut Cx, doc: &Document) {
674 let sources = doc.frontmatter.sources();
675 if sources.is_empty() {
676 return;
677 }
678 let attributions = doc.attributions();
679 for source in sources {
680 if let Some(id) = &source.id {
681 let is_cited = attributions.iter().any(|a| a.label == *id)
682 || doc.body.contains(&format!("[^{id}]"));
683 if !is_cited {
684 cx.info(
685 "L21",
686 format!(
687 "source `{id}` is declared in frontmatter but never cited with footnote `[^{id}]`",
688 ),
689 );
690 }
691 }
692 }
693}
694
695fn check_circular_derivation(bundle: &Bundle, report: &mut Report) {
696 let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
697
698 for concept in bundle.concepts() {
699 if warned.contains(&concept.id) {
700 continue;
701 }
702 let mut path = Vec::new();
703 let mut visited = BTreeSet::new();
704
705 if find_derivation_cycle(bundle, &concept.id, &mut path, &mut visited) {
706 for id in &path {
707 warned.insert(id.clone());
708 }
709 let cycle_str: Vec<String> = path.iter().map(ToString::to_string).collect();
710 report.diagnostics.push(Diagnostic {
711 severity: Severity::Warning,
712 path: Some(concept.path.clone()),
713 concept: Some(concept.id.clone()),
714 message: format!(
715 "[L22] circular concept derivation: {}",
716 cycle_str.join(" ~> ")
717 ),
718 fixable: false,
719 });
720 }
721 }
722}
723
724fn find_derivation_cycle(
725 bundle: &Bundle,
726 current: &ConceptId,
727 path: &mut Vec<ConceptId>,
728 visited: &mut BTreeSet<ConceptId>,
729) -> bool {
730 path.push(current.clone());
731 visited.insert(current.clone());
732
733 for next in bundle.derived_from(current) {
734 if path.first() == Some(next) || path.contains(next) {
735 path.push((*next).clone());
736 return true;
737 }
738 if !visited.contains(next) && find_derivation_cycle(bundle, next, path, visited) {
739 return true;
740 }
741 }
742
743 path.pop();
744 false
745}
746
747fn check_non_standard_actor(cx: &mut Cx, fm: &Frontmatter) {
748 if let Some(generated) = fm.generated()
749 && let Some(by) = &generated.by
750 && matches!(by.kind(), okf_core::ActorKind::Other)
751 {
752 cx.info(
753 "L23",
754 format!(
755 "actor `{by}` in `generated.by` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
756 ),
757 );
758 }
759 for verification in fm.verified() {
760 if let Some(by) = &verification.by
761 && matches!(by.kind(), okf_core::ActorKind::Other)
762 {
763 cx.info(
764 "L23",
765 format!(
766 "actor `{by}` in `verified.by` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
767 ),
768 );
769 }
770 }
771 for source in fm.sources() {
772 if let Some(author) = &source.author
773 && matches!(author.kind(), okf_core::ActorKind::Other)
774 {
775 cx.info(
776 "L23",
777 format!(
778 "author `{author}` in `sources.author` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
779 ),
780 );
781 }
782 }
783}
784
785fn check_future_timestamps(cx: &mut Cx, fm: &Frontmatter, today: Option<Date>) {
786 let check_date = today.or_else(Date::today_utc);
787 let Some(check_date) = check_date else {
788 return;
789 };
790 let threshold_seconds = (check_date.days_since_epoch() + 1) * 86_400;
791
792 if let Some(generated) = fm.generated()
793 && let Some(dt) = generated.at.as_ref().and_then(|a| a.datetime)
794 && dt.to_utc_seconds() > threshold_seconds
795 {
796 cx.warn(
797 "L24",
798 format!("`generated.at` timestamp `{dt}` is in the future"),
799 );
800 }
801 for verification in fm.verified() {
802 if let Some(dt) = verification.at.as_ref().and_then(|a| a.datetime)
803 && dt.to_utc_seconds() > threshold_seconds
804 {
805 cx.warn(
806 "L24",
807 format!("`verified.at` timestamp `{dt}` is in the future"),
808 );
809 }
810 }
811 for source in fm.sources() {
812 if let Some(dt) = source.last_modified.as_ref().and_then(|a| a.datetime)
813 && dt.to_utc_seconds() > threshold_seconds
814 {
815 cx.warn(
816 "L24",
817 format!("`sources.last_modified` timestamp `{dt}` is in the future"),
818 );
819 }
820 }
821}
822
823fn check_attestation_resources(cx: &mut Cx, bundle: &Bundle, doc: &Document) {
824 let Some(contract) = doc.attested_computation() else {
825 return;
826 };
827 if let Some(executor) = &contract.executor
828 && let Some(res) = &executor.resource
829 && !res.starts_with("http://")
830 && !res.starts_with("https://")
831 && bundle.resolve_path_field(&cx.id, res).is_none()
832 {
833 cx.warn(
834 "L25",
835 format!("`executor.resource` points to `{res}` which does not exist on disk"),
836 );
837 }
838 if let Some(attester) = &contract.attester
839 && let Some(res) = &attester.resource
840 && !res.starts_with("http://")
841 && !res.starts_with("https://")
842 && bundle.resolve_path_field(&cx.id, res).is_none()
843 {
844 cx.warn(
845 "L25",
846 format!("`attester.resource` points to `{res}` which does not exist on disk"),
847 );
848 }
849 if let okf_core::computation::ComputationSource::File(path) = &contract.computation
850 && !path.starts_with("http://")
851 && !path.starts_with("https://")
852 && bundle.resolve_path_field(&cx.id, path).is_none()
853 {
854 cx.warn(
855 "L25",
856 format!("`computation` file `{path}` does not exist on disk"),
857 );
858 }
859}
860
861fn check_computation_block_formatting(cx: &mut Cx, doc: &Document) {
862 let Some(contract) = doc.attested_computation() else {
863 return;
864 };
865 if let okf_core::computation::ComputationSource::Inline(inline) = &contract.computation
866 && inline.language.is_none()
867 {
868 cx.warn(
869 "L26",
870 "`# Computation` code block is missing a syntax language tag (e.g. ` ```python ` or ` ```sql `)",
871 );
872 }
873}
874
875fn check_duplicate_log_dates(bundle: &Bundle, report: &mut Report) {
876 for log_path in bundle.log_files() {
877 let Ok(text) = fs::read_to_string(log_path) else {
878 continue;
879 };
880 let log = okf_core::log::Log::parse(&text);
881 let mut seen = HashMap::new();
882 for day in &log.days {
883 *seen.entry(day.date.clone()).or_insert(0) += 1;
884 }
885 for (date, count) in seen {
886 if count > 1 {
887 report.diagnostics.push(Diagnostic {
888 severity: Severity::Warning,
889 path: Some(log_path.clone()),
890 concept: None,
891 message: format!(
892 "[L27] log.md contains duplicate date heading `## {date}` (entries should be grouped under a single heading)"
893 ),
894 fixable: true,
895 });
896 }
897 }
898 }
899}
900
901fn check_whitespace(cx: &mut Cx, doc: &Document) {
902 let mut trailing_count = 0;
903 let mut first_trailing_line = 0;
904 let mut excess_blank = false;
905 let mut consecutive_blank = 0;
906
907 for (i, line) in doc.body.lines().enumerate() {
908 if line.ends_with(char::is_whitespace) {
909 trailing_count += 1;
910 if first_trailing_line == 0 {
911 first_trailing_line = i + 1;
912 }
913 }
914 if line.trim().is_empty() {
915 consecutive_blank += 1;
916 if consecutive_blank > 2 {
917 excess_blank = true;
918 }
919 } else {
920 consecutive_blank = 0;
921 }
922 }
923
924 let body_trimmed = doc.body.trim_end_matches(['\n', '\r']);
925 let has_trailing_blank_lines = doc.body.len() > body_trimmed.len() + 1
926 && doc.body[body_trimmed.len()..]
927 .chars()
928 .filter(|&c| c == '\n')
929 .count()
930 > 2;
931
932 if trailing_count > 0 {
933 cx.info(
934 "L28",
935 format!(
936 "trailing whitespace found on {trailing_count} line(s) in markdown body (first at line {first_trailing_line})"
937 ),
938 );
939 } else if excess_blank || has_trailing_blank_lines {
940 cx.info(
941 "L28",
942 "excess consecutive blank lines found in markdown body",
943 );
944 }
945}