use crate::descmd::{find_section, find_step, find_task};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
impl Severity {
pub fn as_str(&self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
}
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub severity: Severity,
pub message: String,
}
fn err(findings: &mut Vec<Finding>, message: String) {
findings.push(Finding {
severity: Severity::Error,
message,
});
}
fn warn(findings: &mut Vec<Finding>, message: String) {
findings.push(Finding {
severity: Severity::Warning,
message,
});
}
pub fn lint_description(desc: &str) -> Vec<Finding> {
let mut findings = Vec::new();
if !desc.trim().is_empty() && !find_section(desc, "Spec").2 {
warn(&mut findings, "no ## Spec section".to_string());
}
for line in cliban_core::sections::file_lines(desc) {
if let cliban_core::sections::FileLine::Invalid(text) = line {
err(
&mut findings,
format!(
"## Files entry \"{text}\" is not \"<A|M|D> <path>\" — \
collision detection will not see it"
),
);
}
}
let mut seen: Vec<String> = Vec::new();
for anchor in cliban_core::sections::h2_anchors(desc) {
if seen.contains(&anchor) {
err(
&mut findings,
format!(
"duplicate \"## {anchor}\" section — only the first is addressable; \
repair by rewriting the whole description with \
issue edit --description-file"
),
);
} else {
seen.push(anchor);
}
}
let (a_start, a_end, a_ok) = find_section(desc, "Activity Log");
let mut stranded = 0usize;
for item in cliban_core::sections::top_level_list_items(desc) {
if a_ok && item.start >= a_start && item.end <= a_end {
continue;
}
let body = cliban_core::sections::list_item_body(&desc[item]);
let head = body.split_once('\n').map_or(body.as_str(), |(h, _)| h);
if crate::descmd::parse_entry_head(head).is_some() {
stranded += 1;
}
}
if stranded > 0 {
err(
&mut findings,
format!(
"{stranded} timestamped activity entr{} outside ## Activity Log — the \
residue of a split section; repair by rewriting the whole description \
with issue edit --description-file",
if stranded == 1 { "y" } else { "ies" }
),
);
}
let (plan_start, plan_end, has_plan) = find_section(desc, "Plan");
if has_plan {
lint_plan(&desc[plan_start..plan_end], &mut findings);
}
if find_section(desc, "Activity Log").2 {
lint_activity(desc, &mut findings);
}
findings
}
fn lint_plan(plan: &str, findings: &mut Vec<Finding>) {
let mut numbers: Vec<i32> = Vec::new();
let mut first_task_at: Option<usize> = None;
let mut unreachable_steps = 0usize;
for h in cliban_core::sections::h3_headings(plan) {
match cliban_core::sections::task_number(&h.text) {
Some(n) => {
numbers.push(n);
first_task_at.get_or_insert(h.range.start);
}
None => err(
findings,
format!(
"plan heading {:?} is not \"### Task N: <title>\"",
plan[h.range.clone()].trim_end()
),
),
}
}
for item in cliban_core::sections::list_items(plan) {
let src = &plan[item.range.clone()];
let first_line = src.split_once('\n').map_or(src, |(l, _)| l).trim_end();
if !first_line.starts_with("- [") {
continue;
}
let well_formed = first_line.starts_with("- [ ] ") || first_line.starts_with("- [x] ");
if item.depth > 0 {
warn(
findings,
format!(
"indented checkbox {first_line:?} is a child bullet — tick cannot reach it"
),
);
} else if !well_formed {
err(
findings,
format!("malformed checkbox {first_line:?} (want \"- [ ] \" or \"- [x] \")"),
);
} else if first_task_at.is_none_or(|at| item.range.start < at) {
unreachable_steps += 1;
}
}
if unreachable_steps > 0 {
warn(
findings,
format!(
"{unreachable_steps} step(s) outside any \"### Task N:\" heading — \
tick cannot reach them"
),
);
}
for (i, n) in numbers.iter().enumerate() {
let expected = (i + 1) as i32;
if *n != expected {
warn(
findings,
format!(
"plan tasks are numbered {numbers:?}; expected 1..{}",
numbers.len()
),
);
break;
}
}
for n in &numbers {
let (task_start, task_end, ok) = find_task(plan, *n);
if !ok {
continue; }
if find_step(&plan[task_start..task_end], 1).is_none() {
warn(findings, format!("Task {n} has no steps"));
}
}
}
fn lint_activity(desc: &str, findings: &mut Vec<Finding>) {
for entry in crate::descmd::activity_entries(desc) {
if entry.parsed.is_none() {
let head = &entry.head;
warn(
findings,
format!("activity entry \"- {head}\" does not parse as \"- <ts> — <msg>\""),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn errors(desc: &str) -> usize {
lint_description(desc)
.iter()
.filter(|f| f.severity == Severity::Error)
.count()
}
#[test]
fn duplicate_section_anchors_are_an_error() {
let d = "## Spec\n\ns\n\n## Plan\n\nplan\n\n## Spec\n\nagain\n";
let f = lint_description(d);
assert!(
f.iter().any(|f| f.severity == Severity::Error
&& f.message.contains("Spec")
&& f.message.contains("--description-file")),
"duplicate anchor must be an error naming the repair: {f:?}"
);
}
#[test]
fn a_custom_h2_is_not_an_error() {
let d = "## Spec\n\ns\n\n## Rollout\n\ncustom section\n";
assert_eq!(errors(d), 0);
}
#[test]
fn a_stranded_timestamped_entry_is_an_error() {
let d = "## Spec\n\ns\n\n## Activity Log\n\n- 2026-08-08T10:00Z — ok\n\n\
## Phantom\n\n- 2026-08-08T10:01Z — stranded tail\n";
let f = lint_description(d);
assert!(
f.iter().any(|f| f.severity == Severity::Error
&& f.message.contains("--description-file")),
"stranded entry must be an error naming the repair: {f:?}"
);
}
#[test]
fn plain_bullets_in_a_custom_section_are_clean() {
let d = "## Spec\n\ns\n\n## Rollout\n\n- step one\n- step two\n";
assert_eq!(errors(d), 0);
}
#[test]
fn a_conforming_description_is_clean() {
let d = "## Spec\n\ns\n\n## Plan\n\n### Task 1: a\n\n- [ ] **Step 1: x**\n\n\
## Activity Log\n\n- 2026-08-02T10:00Z — started\n";
assert!(lint_description(d).is_empty(), "{:?}", lint_description(d));
}
#[test]
fn empty_description_is_clean() {
assert!(lint_description("").is_empty());
}
#[test]
fn bad_task_heading_is_an_error() {
let d = "## Plan\n\n### Step one\n\n- [ ] x\n";
assert_eq!(errors(d), 1);
}
#[test]
fn malformed_checkbox_is_an_error() {
let d = "## Plan\n\n### Task 1: a\n\n- [X] step\n";
assert_eq!(errors(d), 1);
}
#[test]
fn indented_checkbox_is_a_warning_not_an_error() {
let d = "## Plan\n\n### Task 1: a\n\n- [ ] real step\n - [ ] hidden\n";
assert_eq!(errors(d), 0);
assert!(lint_description(d)
.iter()
.any(|f| f.severity == Severity::Warning && f.message.contains("indented")));
}
#[test]
fn skipped_task_number_is_a_warning() {
let d = "## Plan\n\n### Task 1: a\n\n- [ ] x\n\n### Task 3: b\n\n- [ ] y\n";
let f = lint_description(d);
assert!(f.iter().any(|f| f.severity == Severity::Warning));
assert_eq!(errors(d), 0);
}
#[test]
fn flat_checklist_without_tasks_is_flagged() {
let d = "## Plan\n\n- [ ] first\n- [ ] second\n";
let f = lint_description(d);
assert!(
f.iter().any(|f| f.message.contains("tick cannot reach")),
"{f:?}"
);
}
#[test]
fn steps_inside_tasks_are_not_flagged_as_unreachable() {
let d = "## Plan\n\n### Task 1: a\n\n- [ ] in task\n";
assert!(!lint_description(d)
.iter()
.any(|f| f.message.contains("tick cannot reach")));
}
#[test]
fn stepless_task_is_a_warning() {
let d = "## Plan\n\n### Task 1: a\n\nprose only\n";
let f = lint_description(d);
assert!(f
.iter()
.any(|f| f.severity == Severity::Warning && f.message.contains("no steps")));
}
#[test]
fn unparseable_activity_line_is_a_warning() {
let d = "## Activity Log\n\n- yesterday: did stuff\n";
let f = lint_description(d);
assert_eq!(f.len(), 2, "{f:?}"); assert!(f.iter().all(|f| f.severity == Severity::Warning));
}
#[test]
fn a_sublist_under_a_log_entry_is_not_a_malformed_entry() {
let d = "## Spec\n\ns\n\n## Activity Log\n\n- 2026-08-08T10:00Z — did a thing\n \
- detail one\n - detail two\n";
let f = lint_description(d);
assert!(f.is_empty(), "sublists are entry body, not entries: {f:?}");
}
#[test]
fn a_wrapped_log_entry_is_not_flagged() {
let d = "## Spec\n\ns\n\n## Activity Log\n\n- 2026-08-08T10:00Z — a long entry that\n \
wraps onto another line\n";
assert!(lint_description(d).is_empty(), "{:?}", lint_description(d));
}
#[test]
fn a_fenced_plan_is_content_not_structure() {
let d = "## Spec\n\ns\n\n## Plan\n\n### Task 1: a\n\n- [ ] **Step 1: x**\n\n\
The format is:\n\n```markdown\n### Step one\n\n- [X] not a real checkbox\n```\n";
let f = lint_description(d);
assert!(f.is_empty(), "fenced content must not lint: {f:?}");
}
#[test]
fn steps_after_a_fenced_block_still_count_as_inside_the_task() {
let d = "## Spec\n\ns\n\n## Plan\n\n### Task 1: a\n\n```\n### Task 9: fake\n```\n\n\
- [ ] **Step 1: x**\n";
let f = lint_description(d);
assert!(
!f.iter().any(|f| f.message.contains("tick cannot reach")),
"{f:?}"
);
}
#[test]
fn a_malformed_files_entry_is_an_error_not_a_warning() {
let d = "## Spec\n\ns\n\n## Files\n\n- M good/path.rs\n- X bad/status.rs\n";
let f = lint_description(d);
let bad: Vec<_> = f.iter().filter(|f| f.message.contains("## Files")).collect();
assert_eq!(bad.len(), 1, "{f:?}");
assert_eq!(bad[0].severity, Severity::Error, "{f:?}");
assert!(bad[0].message.contains("X bad/status.rs"), "{f:?}");
}
#[test]
fn a_well_formed_files_section_with_prose_lints_clean() {
let d = "## Spec\n\ns\n\n## Files\n\nPredicted, amended when wrong.\n\n\
- A new/file.rs\n- M old/file.rs\n- D gone/file.rs\n";
assert!(lint_description(d).is_empty(), "{:?}", lint_description(d));
}
}