1use crate::validate::{Diagnostic, Report, Severity, index_listed_targets, is_concept_link};
33use okf_core::bundle::Bundle;
34use okf_core::concept_id::ConceptId;
35use okf_core::date::Date;
36use okf_core::document::Document;
37use okf_core::frontmatter::Frontmatter;
38use okf_core::trust::Status;
39use std::collections::BTreeSet;
40use std::path::PathBuf;
41
42#[must_use]
47pub fn lint_bundle(bundle: &Bundle) -> Report {
48 lint_bundle_at(bundle, None)
49}
50
51#[must_use]
53pub fn lint_bundle_at(bundle: &Bundle, _today: Option<Date>) -> Report {
54 let mut report = Report::default();
55
56 let indexed = indexed_concepts(bundle);
57
58 for concept in bundle.concepts() {
59 let mut cx = Cx {
60 report: &mut report,
61 path: concept.path.clone(),
62 id: concept.id.clone(),
63 };
64 let doc = &concept.document;
65 let fm = &doc.frontmatter;
66
67 check_top_heading(&mut cx, doc);
68 check_key_order(&mut cx, fm);
69 check_heading_hierarchy(&mut cx, doc);
70 check_empty_headings(&mut cx, doc);
71 check_unused_sources(&mut cx, doc);
72 check_non_standard_actor(&mut cx, fm);
73 check_computation_block_formatting(&mut cx, doc);
74 check_whitespace(&mut cx, doc);
75 check_self_link(&mut cx, bundle);
76 check_unverified(&mut cx, fm);
77 check_draft_status(&mut cx, fm);
78 }
79
80 check_orphans(bundle, &indexed, &mut report);
81 check_root_index_version(bundle, &mut report);
82
83 report
84}
85
86fn indexed_concepts(bundle: &Bundle) -> BTreeSet<ConceptId> {
89 let mut out = BTreeSet::new();
90 for index_path in bundle.index_files() {
91 for (raw, target) in index_listed_targets(bundle, index_path) {
92 if is_concept_link(&raw) && bundle.contains(&target) {
93 out.insert(target);
94 }
95 }
96 }
97 out
98}
99
100struct Cx<'a> {
104 report: &'a mut Report,
105 path: PathBuf,
106 id: ConceptId,
107}
108
109impl Cx<'_> {
110 fn warn(&mut self, code: &'static str, message: impl Into<String>) {
111 self.push(Severity::Warning, code, message);
112 }
113
114 fn info(&mut self, code: &'static str, message: impl Into<String>) {
115 self.push(Severity::Info, code, message);
116 }
117
118 fn push(&mut self, severity: Severity, code: &'static str, message: impl Into<String>) {
119 let fixable = is_fixable_lint(code);
120 self.report.diagnostics.push(Diagnostic {
121 severity,
122 path: Some(self.path.clone()),
123 concept: Some(self.id.clone()),
124 message: format!("[{code}] {}", message.into()),
125 fixable,
126 });
127 }
128}
129
130const fn is_fixable_lint(code: &str) -> bool {
132 matches!(code.as_bytes(), b"L1" | b"L2" | b"L7" | b"L8" | b"L13")
133}
134
135fn check_unverified(cx: &mut Cx, fm: &Frontmatter) {
136 if fm.get("verified").is_none() {
137 cx.info("L11", "no `verified` events; trust tier is `unverified`");
138 }
139}
140
141fn check_top_heading(cx: &mut Cx, doc: &Document) {
142 if doc.body.trim().is_empty() {
143 return;
144 }
145 let has_top_heading = doc.body.lines().any(|l| l.trim_start().starts_with("# "));
146 if !has_top_heading {
147 cx.warn(
148 "L1",
149 "body has no top-level `#` heading; OKF docs conventionally open with one",
150 );
151 }
152}
153
154fn check_draft_status(cx: &mut Cx, fm: &Frontmatter) {
155 if matches!(fm.status(), Status::Draft) {
156 cx.info(
157 "L12",
158 "`status: draft`; a draft concept is not ready for production consumption",
159 );
160 }
161}
162
163fn check_self_link(cx: &mut Cx, bundle: &Bundle) {
164 for link in bundle.links_from(&cx.id) {
165 if link.exists && link.target == cx.id {
166 cx.info(
167 "L10",
168 "self-link; a concept that links to itself usually signals a stray reference",
169 );
170 return;
171 }
172 }
173}
174
175fn check_orphans(bundle: &Bundle, indexed: &BTreeSet<ConceptId>, report: &mut Report) {
176 for c in bundle.concepts() {
177 let has_backlinks = !bundle.backlinks(&c.id).is_empty();
178 let is_indexed = indexed.contains(&c.id);
179 if !has_backlinks && !is_indexed {
180 report.diagnostics.push(Diagnostic {
181 severity: Severity::Warning,
182 path: Some(c.path.clone()),
183 concept: Some(c.id.clone()),
184 message: "[L9] orphan concept: no other concept links to it and no \
185 `index.md` lists it"
186 .to_string(),
187 fixable: false,
188 });
189 }
190 }
191}
192
193fn check_key_order(cx: &mut Cx, fm: &Frontmatter) {
194 let keys: Vec<&str> = fm.as_mapping().keys().collect();
195 if keys.len() < 2 {
196 return;
197 }
198 let mut last_rank = None;
199 for key in keys {
200 if let Some(rank) = okf_core::frontmatter::PREFERRED_KEY_ORDER
201 .iter()
202 .position(|&k| k == key)
203 {
204 if let Some(prev) = last_rank
205 && rank < prev
206 {
207 cx.info(
208 "L2",
209 "frontmatter keys are not in canonical order (run `okf fmt` to normalize)",
210 );
211 return;
212 }
213 last_rank = Some(rank);
214 }
215 }
216}
217
218fn check_heading_hierarchy(cx: &mut Cx, doc: &Document) {
219 let headings = okf_core::extract_headings(&doc.body);
220 if headings.is_empty() {
221 return;
222 }
223
224 let is_attested = doc.frontmatter.is_attested_computation();
225 let mut h1_count = 0;
226 let mut prev_level = 0;
227
228 for h in &headings {
229 if h.level == 1 {
230 h1_count += 1;
231 if h1_count > 1 && !(is_attested && h1_count == 2 && h.text == "Computation") {
232 cx.warn(
233 "L3",
234 format!(
235 "multiple top-level `#` headings found (heading `{}` at line {})",
236 h.text, h.line_num
237 ),
238 );
239 }
240 }
241
242 if prev_level > 0 && h.level > prev_level + 1 {
243 cx.warn(
244 "L3",
245 format!(
246 "heading level skipped: `{}` jumps from h{prev_level} to h{}",
247 h.text, h.level
248 ),
249 );
250 }
251 prev_level = h.level;
252 }
253}
254
255fn check_empty_headings(cx: &mut Cx, doc: &Document) {
256 let lines: Vec<&str> = doc.body.lines().collect();
257 let headings = okf_core::extract_headings(&doc.body);
258
259 for (k, h) in headings.iter().enumerate() {
260 let content_end = match headings.get(k + 1) {
261 Some(next_h) => {
262 if next_h.level > h.level {
263 continue;
264 }
265 next_h.line_index
266 }
267 None => lines.len(),
268 };
269
270 let has_content = (h.line_index + 1..content_end).any(|idx| {
271 let l = lines[idx].trim();
272 !l.is_empty() && !l.starts_with("<!--")
273 });
274
275 if !has_content {
276 cx.warn("L4", format!("heading `{}` has no content", h.text));
277 }
278 }
279}
280
281fn check_unused_sources(cx: &mut Cx, doc: &Document) {
282 let sources = doc.frontmatter.sources();
283 if sources.is_empty() {
284 return;
285 }
286 let attributions = doc.attributions();
287 for source in sources {
288 if let Some(id) = &source.id {
289 let is_cited = attributions.iter().any(|a| a.label == *id)
290 || doc.body.contains(&format!("[^{id}]"));
291 if !is_cited {
292 cx.warn(
293 "L5",
294 format!(
295 "source `{id}` is declared in frontmatter but never cited with footnote `[^{id}]`",
296 ),
297 );
298 }
299 }
300 }
301}
302
303fn check_non_standard_actor(cx: &mut Cx, fm: &Frontmatter) {
304 if let Some(generated) = fm.generated()
305 && let Some(by) = &generated.by
306 && matches!(by.kind(), okf_core::ActorKind::Other)
307 {
308 cx.info(
309 "L6",
310 format!(
311 "actor `{by}` in `generated.by` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
312 ),
313 );
314 }
315 for verification in fm.verified() {
316 if let Some(by) = &verification.by
317 && matches!(by.kind(), okf_core::ActorKind::Other)
318 {
319 cx.info(
320 "L6",
321 format!(
322 "actor `{by}` in `verified.by` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
323 ),
324 );
325 }
326 }
327 for source in fm.sources() {
328 if let Some(author) = &source.author
329 && matches!(author.kind(), okf_core::ActorKind::Other)
330 {
331 cx.info(
332 "L6",
333 format!(
334 "author `{author}` in `sources.author` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
335 ),
336 );
337 }
338 }
339}
340
341fn check_computation_block_formatting(cx: &mut Cx, doc: &Document) {
342 let Some(contract) = doc.attested_computation() else {
343 return;
344 };
345 if let okf_core::computation::ComputationSource::Inline(inline) = &contract.computation
346 && inline.language.is_none()
347 {
348 cx.warn(
349 "L7",
350 "`# Computation` code block is missing a syntax language tag (e.g. ` ```python ` or ` ```sql `)",
351 );
352 }
353}
354
355fn check_whitespace(cx: &mut Cx, doc: &Document) {
356 let mut trailing_count = 0;
357 let mut first_trailing_line = 0;
358 let mut excess_blank = false;
359 let mut consecutive_blank = 0;
360
361 for (i, line) in doc.body.lines().enumerate() {
362 if line.ends_with(char::is_whitespace) {
363 trailing_count += 1;
364 if first_trailing_line == 0 {
365 first_trailing_line = i + 1;
366 }
367 }
368 if line.trim().is_empty() {
369 consecutive_blank += 1;
370 if consecutive_blank > 2 {
371 excess_blank = true;
372 }
373 } else {
374 consecutive_blank = 0;
375 }
376 }
377
378 let body_trimmed = doc.body.trim_end_matches(['\n', '\r']);
379 let has_trailing_blank_lines = doc.body.len() > body_trimmed.len() + 1
380 && doc.body[body_trimmed.len()..]
381 .chars()
382 .filter(|&c| c == '\n')
383 .count()
384 > 2;
385
386 if trailing_count > 0 {
387 cx.info(
388 "L8",
389 format!(
390 "trailing whitespace found on {trailing_count} line(s) in markdown body (first at line {first_trailing_line})"
391 ),
392 );
393 } else if excess_blank || has_trailing_blank_lines {
394 cx.info(
395 "L8",
396 "excess consecutive blank lines found in markdown body",
397 );
398 }
399}
400
401fn check_root_index_version(bundle: &Bundle, report: &mut Report) {
402 let root_index = bundle.root().join("index.md");
403 if !root_index.exists() {
404 return;
405 }
406 let Ok(text) = std::fs::read_to_string(&root_index) else {
407 return;
408 };
409 let Ok(doc) = Document::parse(&text) else {
410 return;
411 };
412 if let Some(val) = doc.frontmatter.get("okf_version")
413 && !matches!(val, okf_core::yaml::Value::String(_))
414 {
415 report.diagnostics.push(Diagnostic {
416 severity: Severity::Warning,
417 path: Some(root_index),
418 concept: None,
419 message: "[L13] `okf_version` in root index.md is unquoted; YAML parses unquoted decimal numbers as floats".to_string(),
420 fixable: true,
421 });
422 }
423}