use anyhow::Result;
use serde_json::json;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::cli::LintArgs;
use crate::model::{Project, Status};
use crate::storage::load_resolved;
use crate::validate;
const SHORT_RATIONALE_WORDS: usize = 10;
const SINGLE_ACCEPTANCE: usize = 1;
pub fn run(args: LintArgs, file: &Option<PathBuf>) -> Result<()> {
let (_, project) = load_resolved(file)?;
let report = build_report(&project, &args.path);
if args.json {
println!("{}", serde_json::to_string_pretty(&report.to_json())?);
} else {
print!("{}", report.to_markdown(&project));
}
if report.validator_errors > 0 {
std::process::exit(1);
}
Ok(())
}
struct LintReport {
project_name: String,
total: usize,
by_status: [usize; 6],
validator_errors: usize,
validator_warnings: usize,
validator_findings: Vec<(String, Vec<validate::Finding>)>,
markerless_active: Vec<String>,
short_rationale: Vec<(String, usize)>,
single_acceptance_functional: Vec<String>,
no_test_record: Vec<String>,
verification_kinds: [usize; 3], }
fn build_report(project: &Project, src_path: &Path) -> LintReport {
let total = project.requirements.len();
let mut by_status = [0usize; 6];
for r in project.requirements.values() {
let i = match r.status {
Status::Draft => 0,
Status::Proposed => 1,
Status::Approved => 2,
Status::Implemented => 3,
Status::Verified => 4,
Status::Obsolete => 5,
};
by_status[i] += 1;
}
let validator_findings = validate::validate_project(project);
let validator_errors: usize = validator_findings
.iter()
.flat_map(|(_, fs)| fs.iter())
.filter(|f| f.error)
.count();
let validator_warnings: usize = validator_findings
.iter()
.flat_map(|(_, fs)| fs.iter())
.filter(|f| !f.error)
.count();
let referenced = scan_markers(src_path);
let markerless_active: Vec<String> = project
.requirements
.iter()
.filter(|(_, r)| !matches!(r.status, Status::Obsolete | Status::Draft))
.filter(|(id, _)| !referenced.contains(*id))
.map(|(id, _)| id.clone())
.collect();
let mut markerless_active = markerless_active;
markerless_active.sort();
let validator_rationale_ids: std::collections::BTreeSet<String> = validator_findings
.iter()
.filter(|(_, fs)| fs.iter().any(|f| f.rule_code == "REQ-V-0013"))
.map(|(id, _)| id.clone())
.collect();
let mut short_rationale: Vec<(String, usize)> = project
.requirements
.iter()
.filter(|(_, r)| !matches!(r.status, Status::Obsolete))
.filter_map(|(id, r)| {
if validator_rationale_ids.contains(id) {
return None;
}
let words = r.rationale.split_whitespace().count();
if words < SHORT_RATIONALE_WORDS {
Some((id.clone(), words))
} else {
None
}
})
.collect();
short_rationale.sort();
let mut single_acceptance_functional: Vec<String> = project
.requirements
.iter()
.filter(|(_, r)| {
!matches!(r.status, Status::Obsolete)
&& matches!(r.kind, crate::model::Kind::Functional)
&& r.acceptance.len() <= SINGLE_ACCEPTANCE
})
.map(|(id, _)| id.clone())
.collect();
single_acceptance_functional.sort();
let mut no_test_record: Vec<String> = project
.requirements
.iter()
.filter(|(_, r)| {
!matches!(r.status, Status::Obsolete | Status::Draft) && r.tests.is_empty()
})
.map(|(id, _)| id.clone())
.collect();
no_test_record.sort();
let mut verification_kinds = [0usize; 3];
for r in project.requirements.values() {
if let Some(latest) = r.tests.last() {
let i = match latest.kind {
crate::model::EvidenceKind::Automated => 0,
crate::model::EvidenceKind::Composition => 1,
crate::model::EvidenceKind::Inspection => 2,
};
verification_kinds[i] += 1;
}
}
LintReport {
project_name: project.name.clone(),
total,
by_status,
validator_errors,
validator_warnings,
validator_findings,
markerless_active,
short_rationale,
single_acceptance_functional,
no_test_record,
verification_kinds,
}
}
fn scan_markers(root: &Path) -> BTreeSet<String> {
use regex::Regex;
let re = Regex::new(r"REQ-\d{4}").unwrap();
let mut found: BTreeSet<String> = BTreeSet::new();
let skip_dirs = [
".git",
"target",
"node_modules",
".agent-sandbox",
".venv",
"dist",
"build",
];
let exts = [
"rs", "py", "js", "ts", "tsx", "go", "java", "kt", "scala", "swift", "cs", "rb", "php",
"lua", "c", "cpp", "h", "hh", "hpp", "hxx", "m", "mm",
];
fn walk(
dir: &Path,
skip: &[&str],
exts: &[&str],
re: ®ex::Regex,
out: &mut BTreeSet<String>,
) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for ent in entries.flatten() {
let path = ent.path();
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if path.is_dir() {
if skip.contains(&name.as_str()) {
continue;
}
walk(&path, skip, exts, re, out);
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if exts.contains(&ext) {
if let Ok(text) = std::fs::read_to_string(&path) {
for cap in re.find_iter(&text) {
out.insert(cap.as_str().to_string());
}
}
}
}
}
}
walk(root, &skip_dirs, &exts, &re, &mut found);
found
}
impl LintReport {
fn to_json(&self) -> serde_json::Value {
json!({
"project": self.project_name,
"total": self.total,
"by_status": {
"draft": self.by_status[0],
"proposed": self.by_status[1],
"approved": self.by_status[2],
"implemented": self.by_status[3],
"verified": self.by_status[4],
"obsolete": self.by_status[5],
},
"validator": {
"errors": self.validator_errors,
"warnings": self.validator_warnings,
"findings": self.validator_findings.iter().map(|(id, fs)| {
json!({
"id": id,
"findings": fs.iter().map(|f| json!({
"rule_code": f.rule_code,
"severity": if f.error { "error" } else { "warning" },
"field": f.field,
"message": f.message,
})).collect::<Vec<_>>(),
})
}).collect::<Vec<_>>(),
},
"quality": {
"markerless_active": self.markerless_active,
"short_rationale": self.short_rationale.iter().map(|(id, words)| {
json!({ "id": id, "words": words })
}).collect::<Vec<_>>(),
"single_acceptance_functional": self.single_acceptance_functional,
"no_test_record": self.no_test_record,
"verification_kinds": {
"automated": self.verification_kinds[0],
"composition": self.verification_kinds[1],
"inspection": self.verification_kinds[2],
},
},
})
}
fn to_markdown(&self, project: &Project) -> String {
let mut out = String::new();
out.push_str(&format!("# req lint — {}\n\n", self.project_name));
let headline_emoji = if self.validator_errors > 0 {
"FAIL"
} else if self.validator_warnings > 0 {
"WARN"
} else {
"OK"
};
let quality_count = self.markerless_active.len()
+ self.short_rationale.len()
+ self.single_acceptance_functional.len()
+ self.no_test_record.len();
out.push_str(&format!(
"**Status:** {} — {} requirement(s); validate {} error(s), {} warning(s); {} quality observation(s).\n\n",
headline_emoji, self.total, self.validator_errors, self.validator_warnings, quality_count
));
out.push_str("## Status distribution\n\n");
let labels = [
"draft",
"proposed",
"approved",
"implemented",
"verified",
"obsolete",
];
for (i, lbl) in labels.iter().enumerate() {
if self.by_status[i] > 0 {
out.push_str(&format!("- **{}**: {}\n", lbl, self.by_status[i]));
}
}
out.push('\n');
if !self.validator_findings.is_empty() {
out.push_str("## Validator findings\n\n");
for (id, fs) in &self.validator_findings {
for f in fs {
let sev = if f.error { "ERR " } else { "WARN" };
out.push_str(&format!(
"- {} **{}** `{}` [{}] {}\n",
sev, id, f.rule_code, f.field, f.message
));
}
}
out.push('\n');
}
out.push_str("## Quality observations\n\n");
if quality_count == 0 {
out.push_str("None. All active requirements have marker coverage, meaningful rationale, multiple acceptance criteria, and at least one test record.\n\n");
} else {
if !self.markerless_active.is_empty() {
out.push_str(&format!(
"### Active requirements with no source marker ({})\n\nThese have not been linked from any `// REQ-NNNN:` comment. Add a marker in the file that implements the requirement, or document why no code marker is appropriate (verification-only, policy meta-req).\n\n",
self.markerless_active.len()
));
for id in &self.markerless_active {
if let Some(r) = project.requirements.get(id) {
out.push_str(&format!(
"- **{}** — {} ({})\n",
id,
r.title,
r.status.as_str()
));
}
}
out.push('\n');
}
if !self.short_rationale.is_empty() {
out.push_str(&format!(
"### Rationales under {} words ({})\n\nA useful rationale names a cause or constraint, not just a consequence. Expand with `req update <id> -r \"...\" --reason \"...\"`.\n\n",
SHORT_RATIONALE_WORDS, self.short_rationale.len()
));
for (id, w) in &self.short_rationale {
out.push_str(&format!("- **{}** — {} word(s)\n", id, w));
}
out.push('\n');
}
if !self.single_acceptance_functional.is_empty() {
out.push_str(&format!(
"### Functional requirements with ≤1 acceptance criterion ({})\n\nOne acceptance criterion rarely covers a real obligation. Add more with `req update <id> --add-acceptance \"...\"`.\n\n",
self.single_acceptance_functional.len()
));
for id in &self.single_acceptance_functional {
out.push_str(&format!("- **{}**\n", id));
}
out.push('\n');
}
if !self.no_test_record.is_empty() {
out.push_str(&format!(
"### Active requirements with no test record ({})\n\nAdvanced-state requirements (Proposed onwards) without any evidence record. Attach one with `req test record`, `req verify --by inspection`, or `req test run --promote`.\n\n",
self.no_test_record.len()
));
for id in &self.no_test_record {
out.push_str(&format!("- **{}**\n", id));
}
out.push('\n');
}
}
out.push_str("## Verification kind distribution\n\n");
out.push_str(&format!(
"- **automated**: {}\n- **composition**: {}\n- **inspection**: {}\n\n",
self.verification_kinds[0], self.verification_kinds[1], self.verification_kinds[2],
));
out
}
}