use crate::bundle::Bundle;
use crate::concept_id::ConceptId;
use crate::date::Date;
use crate::document::Document;
use crate::frontmatter::Frontmatter;
use crate::trust::Status;
use crate::validate::{Diagnostic, Report, Severity};
use std::collections::{BTreeSet, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
#[must_use]
pub fn lint_bundle(bundle: &Bundle) -> Report {
lint_bundle_at(bundle, None)
}
#[must_use]
pub fn lint_bundle_at(bundle: &Bundle, today: Option<Date>) -> Report {
let mut report = Report::default();
let indexed = indexed_concepts(bundle);
let title_counts = count_titles(bundle);
for concept in bundle.concepts() {
let mut cx = Cx {
report: &mut report,
path: concept.path.clone(),
id: concept.id.clone(),
};
let doc = &concept.document;
let fm = &doc.frontmatter;
check_missing_title(&mut cx, fm);
check_missing_description(&mut cx, fm);
check_missing_generated(&mut cx, fm);
check_unverified(&mut cx, fm);
check_legacy(&mut cx, doc);
check_empty_body(&mut cx, doc);
check_top_heading(&mut cx, doc);
check_verified_before_generated(&mut cx, fm);
check_links_to_deprecated(&mut cx, bundle);
check_staleness(&mut cx, fm, today);
check_draft_status(&mut cx, fm);
check_self_link(&mut cx, bundle);
check_duplicate_title(&mut cx, fm, &title_counts);
}
check_orphans(bundle, &indexed, &mut report);
check_stale_indexes(bundle, &mut report);
report
}
fn indexed_concepts(bundle: &Bundle) -> BTreeSet<ConceptId> {
let mut out = BTreeSet::new();
for index_path in bundle.index_files() {
for (raw, target) in index_listed_targets(bundle, index_path) {
if is_concept_link(&raw) && bundle.contains(&target) {
out.insert(target);
}
}
}
out
}
fn index_listed_targets(bundle: &Bundle, index_path: &Path) -> Vec<(String, ConceptId)> {
let mut out = Vec::new();
let Some(source) = index_source_id(bundle.root(), index_path) else {
return out;
};
let Ok(text) = fs::read_to_string(index_path) else {
return out;
};
let Ok(doc) = Document::parse(&text) else {
return out;
};
for link in doc.links() {
for target in link.resolve_all(&source) {
out.push((link.target.clone(), target));
}
}
out
}
fn is_concept_link(raw: &str) -> bool {
let t = raw.trim();
if t.starts_with('#') || t.is_empty() {
return false;
}
if crate::links::LinkKind::External == crate::links::Link::classify(t) {
return false;
}
let before_anchor = t.split('#').next().unwrap_or(t);
let basename = before_anchor.rsplit('/').next().unwrap_or(before_anchor);
#[allow(clippy::case_sensitive_file_extension_comparisons)]
{
basename.ends_with(".md") || !basename.contains('.')
}
}
fn index_source_id(bundle_root: &Path, index_path: &Path) -> Option<ConceptId> {
let rel = index_path.strip_prefix(bundle_root).ok()?;
let mut segments: Vec<String> = rel
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
_ => None,
})
.collect();
if let Some(last) = segments.last_mut() {
if let Some(stripped) = last.strip_suffix(".md") {
*last = stripped.to_string();
}
}
ConceptId::new(segments).ok()
}
fn count_titles(bundle: &Bundle) -> HashMap<String, usize> {
let mut counts: HashMap<String, usize> = HashMap::new();
for c in bundle.concepts() {
if let Some(title) = c.document.frontmatter.title() {
*counts.entry(title.into_owned()).or_default() += 1;
}
}
counts
}
struct Cx<'a> {
report: &'a mut Report,
path: PathBuf,
id: ConceptId,
}
impl Cx<'_> {
fn warn(&mut self, code: &'static str, message: impl Into<String>) {
self.push(Severity::Warning, code, message);
}
fn info(&mut self, code: &'static str, message: impl Into<String>) {
self.push(Severity::Info, code, message);
}
fn push(&mut self, severity: Severity, code: &'static str, message: impl Into<String>) {
self.report.diagnostics.push(Diagnostic {
severity,
path: Some(self.path.clone()),
concept: Some(self.id.clone()),
message: format!("[{code}] {}", message.into()),
});
}
}
fn check_missing_title(cx: &mut Cx, fm: &Frontmatter) {
if fm.title().is_none() {
cx.warn(
"L1",
"missing `title`; consumers fall back to the filename, but a human-readable \
title is recommended",
);
}
}
fn check_missing_description(cx: &mut Cx, fm: &Frontmatter) {
if fm.description().is_none() {
cx.warn(
"L2",
"missing `description`; a one-line summary is recommended and \
what `index.md` listings display",
);
}
}
fn check_missing_generated(cx: &mut Cx, fm: &Frontmatter) {
let has_generated_key = fm.get("generated").is_some();
let has_legacy_timestamp = fm.timestamp().is_some();
if !has_generated_key && !has_legacy_timestamp {
cx.warn(
"L3",
"missing `generated`; a continuously-authored corpus should record who \
produced the content and when",
);
}
}
fn check_unverified(cx: &mut Cx, fm: &Frontmatter) {
if fm.get("verified").is_none() {
cx.info("L4", "no `verified` events; trust tier is `unverified`");
}
}
fn check_legacy(cx: &mut Cx, doc: &Document) {
if doc.frontmatter.timestamp().is_some() {
cx.warn(
"L5",
"`timestamp` is a v0.1 key superseded by `generated: { by, at }`",
);
}
if doc.has_legacy_citations() {
cx.warn(
"L6",
"body `# Citations` list is superseded by `sources` + footnote attribution",
);
}
}
fn check_empty_body(cx: &mut Cx, doc: &Document) {
if doc.body.trim().is_empty() {
cx.warn(
"L7",
"body is empty; a concept should carry at least one line of prose or code",
);
}
}
fn check_top_heading(cx: &mut Cx, doc: &Document) {
if doc.body.trim().is_empty() {
return; }
let has_top_heading = doc.body.lines().any(|l| l.trim_start().starts_with("# "));
if !has_top_heading {
cx.warn(
"L8",
"body has no top-level `#` heading; OKF docs conventionally open with one",
);
}
}
fn check_verified_before_generated(cx: &mut Cx, fm: &Frontmatter) {
let Some(generated) = fm.generated() else {
return;
};
let Some(generated_at) = generated.at.as_ref().and_then(|a| a.datetime) else {
return;
};
let verified = fm.verified();
let Some(latest) = crate::trust::latest_verification(&verified) else {
return;
};
let Some(latest_at) = latest.at.as_ref().and_then(|a| a.datetime) else {
return;
};
if latest_at < generated_at {
cx.warn(
"L9",
format!(
"latest verification ({latest_at}) predates `generated.at` ({generated_at}); \
the current content was never re-verified"
),
);
}
}
fn check_links_to_deprecated(cx: &mut Cx, bundle: &Bundle) {
let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
for link in bundle.links_from(&cx.id) {
if !link.exists || !warned.insert(link.target.clone()) {
continue;
}
if let Some(target) = bundle.get(&link.target) {
if target.status().is_deprecated() {
cx.warn(
"L10",
format!("links to deprecated concept `{}`", link.target),
);
}
}
}
}
fn check_staleness(cx: &mut Cx, fm: &Frontmatter, today: Option<Date>) {
let Some(today) = today else {
return;
};
let Some(stale_after) = fm.stale_after().and_then(|d| d.effective_date()) else {
return;
};
if today >= stale_after {
cx.warn(
"L11",
format!("stale since {stale_after} (`stale_after` passed)"),
);
}
}
fn check_draft_status(cx: &mut Cx, fm: &Frontmatter) {
if matches!(fm.status(), Status::Draft) {
cx.info(
"L12",
"`status: draft`; a draft concept is not ready for production consumption",
);
}
}
fn check_self_link(cx: &mut Cx, bundle: &Bundle) {
for link in bundle.links_from(&cx.id) {
if link.exists && link.target == cx.id {
cx.info(
"L13",
"self-link; a concept that links to itself usually signals a stray reference",
);
return;
}
}
}
fn check_duplicate_title(cx: &mut Cx, fm: &Frontmatter, counts: &HashMap<String, usize>) {
let Some(title) = fm.title() else {
return;
};
if counts.get(title.as_ref()).copied().unwrap_or(0) > 1 {
cx.warn(
"L14",
format!("`title` {title:?} is shared with another concept; titles should disambiguate"),
);
}
}
fn check_orphans(bundle: &Bundle, indexed: &BTreeSet<ConceptId>, report: &mut Report) {
for c in bundle.concepts() {
let has_backlinks = !bundle.backlinks(&c.id).is_empty();
let is_indexed = indexed.contains(&c.id);
if !has_backlinks && !is_indexed {
report.diagnostics.push(Diagnostic {
severity: Severity::Warning,
path: Some(c.path.clone()),
concept: Some(c.id.clone()),
message: "[L15] orphan concept: no other concept links to it and no \
`index.md` lists it"
.to_string(),
});
}
}
}
fn check_stale_indexes(bundle: &Bundle, report: &mut Report) {
for index_path in bundle.index_files() {
let Some(dir) = index_path.parent() else {
continue;
};
let Some(index_id) = index_source_id(bundle.root(), index_path) else {
continue;
};
let index_dir = index_id.parent();
let actual: BTreeSet<ConceptId> = bundle
.concepts()
.iter()
.filter(|c| c.path.parent() == Some(dir))
.map(|c| c.id.clone())
.collect();
let listed: BTreeSet<ConceptId> = index_listed_targets(bundle, index_path)
.into_iter()
.filter(|(raw, _)| is_concept_link(raw))
.map(|(_, target)| target)
.filter(|t| t.parent() == index_dir)
.collect();
let missing_from_index: Vec<String> = actual
.iter()
.filter(|c| !listed.contains(*c))
.map(ConceptId::to_string)
.collect();
let listed_but_not_on_disk: Vec<String> = listed
.iter()
.filter(|c| !actual.contains(*c))
.map(ConceptId::to_string)
.collect();
if missing_from_index.is_empty() && listed_but_not_on_disk.is_empty() {
continue;
}
let mut parts = Vec::new();
if !missing_from_index.is_empty() {
parts.push(format!(
"missing from index: {}",
missing_from_index.join(", ")
));
}
if !listed_but_not_on_disk.is_empty() {
parts.push(format!(
"listed but not on disk: {}",
listed_but_not_on_disk.join(", ")
));
}
report.diagnostics.push(Diagnostic {
severity: Severity::Warning,
path: Some(index_path.clone()),
concept: None,
message: format!(
"[L16] index.md is out of sync with its directory ({})",
parts.join("; ")
),
});
}
}