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