1use crate::validate::{Diagnostic, Report, Severity};
36use okf_core::bundle::Bundle;
37use okf_core::concept_id::ConceptId;
38use okf_core::date::Date;
39use okf_core::document::Document;
40use okf_core::frontmatter::Frontmatter;
41use okf_core::trust::Status;
42use std::collections::{BTreeSet, HashMap};
43use std::fs;
44use std::path::{Path, PathBuf};
45
46#[must_use]
51pub fn lint_bundle(bundle: &Bundle) -> Report {
52 lint_bundle_at(bundle, None)
53}
54
55#[must_use]
57pub fn lint_bundle_at(bundle: &Bundle, today: Option<Date>) -> Report {
58 let mut report = Report::default();
59
60 let indexed = indexed_concepts(bundle);
61 let title_counts = count_titles(bundle);
62
63 for concept in bundle.concepts() {
64 let mut cx = Cx {
65 report: &mut report,
66 path: concept.path.clone(),
67 id: concept.id.clone(),
68 };
69 let doc = &concept.document;
70 let fm = &doc.frontmatter;
71
72 check_missing_title(&mut cx, fm);
73 check_missing_description(&mut cx, fm);
74 check_missing_generated(&mut cx, fm);
75 check_unverified(&mut cx, fm);
76 check_legacy(&mut cx, doc);
77 check_empty_body(&mut cx, doc);
78 check_top_heading(&mut cx, doc);
79 check_verified_before_generated(&mut cx, fm);
80 check_links_to_deprecated(&mut cx, bundle);
81 check_staleness(&mut cx, fm, today);
82 check_draft_status(&mut cx, fm);
83 check_self_link(&mut cx, bundle);
84 check_duplicate_title(&mut cx, fm, &title_counts);
85 }
86
87 check_orphans(bundle, &indexed, &mut report);
88 check_stale_indexes(bundle, &mut report);
89
90 report
91}
92
93fn indexed_concepts(bundle: &Bundle) -> BTreeSet<ConceptId> {
96 let mut out = BTreeSet::new();
97 for index_path in bundle.index_files() {
98 for (raw, target) in index_listed_targets(bundle, index_path) {
99 if is_concept_link(&raw) && bundle.contains(&target) {
100 out.insert(target);
101 }
102 }
103 }
104 out
105}
106
107fn index_listed_targets(bundle: &Bundle, index_path: &Path) -> Vec<(String, ConceptId)> {
116 let mut out = Vec::new();
117 let Some(source) = index_source_id(bundle.root(), index_path) else {
118 return out;
119 };
120 let Ok(text) = fs::read_to_string(index_path) else {
121 return out;
122 };
123 let Ok(doc) = Document::parse(&text) else {
124 return out;
125 };
126 for link in doc.links() {
127 for target in link.resolve_all(&source) {
128 out.push((link.target.clone(), target));
129 }
130 }
131 out
132}
133
134fn is_concept_link(raw: &str) -> bool {
137 let t = raw.trim();
138 if t.starts_with('#') || t.is_empty() {
139 return false;
140 }
141 if okf_core::links::LinkKind::External == okf_core::links::Link::classify(t) {
142 return false;
143 }
144 let before_anchor = t.split('#').next().unwrap_or(t);
145 let basename = before_anchor.rsplit('/').next().unwrap_or(before_anchor);
146 #[allow(clippy::case_sensitive_file_extension_comparisons)]
149 {
150 basename.ends_with(".md") || !basename.contains('.')
151 }
152}
153
154fn index_source_id(bundle_root: &Path, index_path: &Path) -> Option<ConceptId> {
163 let rel = index_path.strip_prefix(bundle_root).ok()?;
164 let mut segments: Vec<String> = rel
165 .components()
166 .filter_map(|c| match c {
167 std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
168 _ => None,
169 })
170 .collect();
171 if let Some(last) = segments.last_mut()
172 && let Some(stripped) = last.strip_suffix(".md")
173 {
174 *last = stripped.to_string();
175 }
176 ConceptId::new(segments).ok()
177}
178
179fn count_titles(bundle: &Bundle) -> HashMap<String, usize> {
181 let mut counts: HashMap<String, usize> = HashMap::new();
182 for c in bundle.concepts() {
183 if let Some(title) = c.document.frontmatter.title() {
184 *counts.entry(title.into_owned()).or_default() += 1;
185 }
186 }
187 counts
188}
189
190struct Cx<'a> {
194 report: &'a mut Report,
195 path: PathBuf,
196 id: ConceptId,
197}
198
199impl Cx<'_> {
200 fn warn(&mut self, code: &'static str, message: impl Into<String>) {
201 self.push(Severity::Warning, code, message);
202 }
203
204 fn info(&mut self, code: &'static str, message: impl Into<String>) {
205 self.push(Severity::Info, code, message);
206 }
207
208 fn push(&mut self, severity: Severity, code: &'static str, message: impl Into<String>) {
209 self.report.diagnostics.push(Diagnostic {
210 severity,
211 path: Some(self.path.clone()),
212 concept: Some(self.id.clone()),
213 message: format!("[{code}] {}", message.into()),
214 });
215 }
216}
217
218fn check_missing_title(cx: &mut Cx, fm: &Frontmatter) {
219 if fm.title().is_none() {
220 cx.warn(
221 "L1",
222 "missing `title`; consumers fall back to the filename, but a human-readable \
223 title is recommended",
224 );
225 }
226}
227
228fn check_missing_description(cx: &mut Cx, fm: &Frontmatter) {
229 if fm.description().is_none() {
230 cx.warn(
231 "L2",
232 "missing `description`; a one-line summary is recommended and \
233 what `index.md` listings display",
234 );
235 }
236}
237
238fn check_missing_generated(cx: &mut Cx, fm: &Frontmatter) {
239 let has_generated_key = fm.get("generated").is_some();
240 let has_legacy_timestamp = fm.timestamp().is_some();
241 if !has_generated_key && !has_legacy_timestamp {
242 cx.warn(
243 "L3",
244 "missing `generated`; a continuously-authored corpus should record who \
245 produced the content and when",
246 );
247 }
248}
249
250fn check_unverified(cx: &mut Cx, fm: &Frontmatter) {
251 if fm.get("verified").is_none() {
252 cx.info("L4", "no `verified` events; trust tier is `unverified`");
253 }
254}
255
256fn check_legacy(cx: &mut Cx, doc: &Document) {
257 if doc.frontmatter.timestamp().is_some() {
258 cx.warn(
259 "L5",
260 "`timestamp` is a v0.1 key superseded by `generated: { by, at }`",
261 );
262 }
263 if doc.has_legacy_citations() {
264 cx.warn(
265 "L6",
266 "body `# Citations` list is superseded by `sources` + footnote attribution",
267 );
268 }
269}
270
271fn check_empty_body(cx: &mut Cx, doc: &Document) {
272 if doc.body.trim().is_empty() {
273 cx.warn(
274 "L7",
275 "body is empty; a concept should carry at least one line of prose or code",
276 );
277 }
278}
279
280fn check_top_heading(cx: &mut Cx, doc: &Document) {
281 if doc.body.trim().is_empty() {
282 return; }
284 let has_top_heading = doc.body.lines().any(|l| l.trim_start().starts_with("# "));
285 if !has_top_heading {
286 cx.warn(
287 "L8",
288 "body has no top-level `#` heading; OKF docs conventionally open with one",
289 );
290 }
291}
292
293fn check_verified_before_generated(cx: &mut Cx, fm: &Frontmatter) {
294 let Some(generated) = fm.generated() else {
295 return;
296 };
297 let Some(generated_at) = generated.at.as_ref().and_then(|a| a.datetime) else {
298 return;
299 };
300 let verified = fm.verified();
301 let Some(latest) = okf_core::trust::latest_verification(&verified) else {
302 return;
303 };
304 let Some(latest_at) = latest.at.as_ref().and_then(|a| a.datetime) else {
305 return;
306 };
307 if latest_at < generated_at {
308 cx.warn(
309 "L9",
310 format!(
311 "latest verification ({latest_at}) predates `generated.at` ({generated_at}); \
312 the current content was never re-verified"
313 ),
314 );
315 }
316}
317
318fn check_links_to_deprecated(cx: &mut Cx, bundle: &Bundle) {
319 let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
320 for link in bundle.links_from(&cx.id) {
321 if !link.exists || !warned.insert(link.target.clone()) {
322 continue;
323 }
324 if let Some(target) = bundle.get(&link.target)
325 && target.status().is_deprecated()
326 {
327 cx.warn(
328 "L10",
329 format!("links to deprecated concept `{}`", link.target),
330 );
331 }
332 }
333}
334
335fn check_staleness(cx: &mut Cx, fm: &Frontmatter, today: Option<Date>) {
336 let Some(today) = today else {
337 return;
338 };
339 let Some(stale_after) = fm.stale_after() else {
340 return;
341 };
342 if fm.is_stale_on(today) {
343 cx.warn(
344 "L11",
345 format!("stale since {stale_after} (`stale_after` passed)"),
346 );
347 }
348}
349
350fn check_draft_status(cx: &mut Cx, fm: &Frontmatter) {
351 if matches!(fm.status(), Status::Draft) {
352 cx.info(
353 "L12",
354 "`status: draft`; a draft concept is not ready for production consumption",
355 );
356 }
357}
358
359fn check_self_link(cx: &mut Cx, bundle: &Bundle) {
360 for link in bundle.links_from(&cx.id) {
361 if link.exists && link.target == cx.id {
362 cx.info(
363 "L13",
364 "self-link; a concept that links to itself usually signals a stray reference",
365 );
366 return;
367 }
368 }
369}
370
371fn check_duplicate_title(cx: &mut Cx, fm: &Frontmatter, counts: &HashMap<String, usize>) {
372 let Some(title) = fm.title() else {
373 return;
374 };
375 if counts.get(title.as_ref()).copied().unwrap_or(0) > 1 {
376 cx.warn(
377 "L14",
378 format!("`title` {title:?} is shared with another concept; titles should disambiguate"),
379 );
380 }
381}
382
383fn check_orphans(bundle: &Bundle, indexed: &BTreeSet<ConceptId>, report: &mut Report) {
384 for c in bundle.concepts() {
385 let has_backlinks = !bundle.backlinks(&c.id).is_empty();
386 let is_indexed = indexed.contains(&c.id);
387 if !has_backlinks && !is_indexed {
388 report.diagnostics.push(Diagnostic {
389 severity: Severity::Warning,
390 path: Some(c.path.clone()),
391 concept: Some(c.id.clone()),
392 message: "[L15] orphan concept: no other concept links to it and no \
393 `index.md` lists it"
394 .to_string(),
395 });
396 }
397 }
398}
399
400fn check_stale_indexes(bundle: &Bundle, report: &mut Report) {
401 for index_path in bundle.index_files() {
402 let Some(dir) = index_path.parent() else {
403 continue;
404 };
405 let Some(index_id) = index_source_id(bundle.root(), index_path) else {
406 continue;
407 };
408 let index_dir = index_id.parent();
411
412 let actual: BTreeSet<ConceptId> = bundle
413 .concepts()
414 .iter()
415 .filter(|c| c.path.parent() == Some(dir))
416 .map(|c| c.id.clone())
417 .collect();
418
419 let listed: BTreeSet<ConceptId> = index_listed_targets(bundle, index_path)
426 .into_iter()
427 .filter(|(raw, _)| is_concept_link(raw))
428 .map(|(_, target)| target)
429 .filter(|t| t.parent() == index_dir)
430 .collect();
431
432 let missing_from_index: Vec<String> = actual
433 .iter()
434 .filter(|c| !listed.contains(*c))
435 .map(ConceptId::to_string)
436 .collect();
437 let listed_but_not_on_disk: Vec<String> = listed
438 .iter()
439 .filter(|c| !actual.contains(*c))
440 .map(ConceptId::to_string)
441 .collect();
442
443 if missing_from_index.is_empty() && listed_but_not_on_disk.is_empty() {
444 continue;
445 }
446
447 let mut parts = Vec::new();
448 if !missing_from_index.is_empty() {
449 parts.push(format!(
450 "missing from index: {}",
451 missing_from_index.join(", ")
452 ));
453 }
454 if !listed_but_not_on_disk.is_empty() {
455 parts.push(format!(
456 "listed but not on disk: {}",
457 listed_but_not_on_disk.join(", ")
458 ));
459 }
460
461 report.diagnostics.push(Diagnostic {
462 severity: Severity::Warning,
463 path: Some(index_path.clone()),
464 concept: None,
465 message: format!(
466 "[L16] index.md is out of sync with its directory ({})",
467 parts.join("; ")
468 ),
469 });
470 }
471}