fn print_task_status(task_id: &str, task_dir: &Path) -> Result<()> {
use crate::cli::colors as c;
let checklist_path = task_dir.join("checklist.yaml");
if checklist_path.exists() {
let content = fs::read_to_string(&checklist_path)?;
let checklist: QaChecklist = serde_yaml_ng::from_str(&content)?;
let categories = &checklist.categories;
let all_items: Vec<&ChecklistItem> = categories
.safety_ethics
.iter()
.chain(categories.code_quality.iter())
.chain(categories.testing.iter())
.chain(categories.documentation.iter())
.chain(categories.process.iter())
.collect();
let checked = all_items.iter().filter(|i| i.checked).count();
let total = all_items.len();
let score = (checked as f64 / total as f64) * 100.0;
let status = if checked == total {
format!("{}Complete{}", c::GREEN, c::RESET)
} else if checked > 0 {
format!("{}In Progress{}", c::YELLOW, c::RESET)
} else {
format!("{}Pending{}", c::DIM, c::RESET)
};
println!(
"{:<15} {:<20} {:.0}% ({}/{})",
task_id, status, score, checked, total
);
} else {
println!("{:<15} {}", task_id, c::dim("No checklist"));
}
Ok(())
}
async fn handle_spec(
target: &str,
project_path: &Path,
full: bool,
format: QaOutputFormat,
output: Option<&Path>,
threshold: u32,
gateway_threshold: u32,
) -> Result<()> {
use crate::cli::colors as c;
use crate::services::spec_parser::{
ClaimCategory, SpecParser, ValidationStatus as SpecValidationStatus,
};
println!("{}", c::header("Popperian Specification Validation"));
println!("{}", c::rule());
println!("{}: {}", c::dim("Target"), target);
println!(
"{}: {}",
c::dim("Mode"),
if full {
"Full (with mutation testing)"
} else {
"Standard"
}
);
println!();
let spec_path = resolve_spec_path(target, project_path)?;
println!("{}: {}", c::dim("Specification"), c::path(&spec_path.display().to_string()));
println!();
let parser = SpecParser::new();
let spec = parser.parse_file(&spec_path)?;
println!("{}: {}", c::dim("Title"), spec.title);
println!("{}: {:?}", c::dim("Issue refs"), spec.issue_refs);
println!("{}: {}", c::dim("Claims"), c::number(&spec.claims.len().to_string()));
println!("{}: {}", c::dim("Code examples"), c::number(&spec.code_examples.len().to_string()));
println!("{}: {}", c::dim("Acceptance criteria"), c::number(&spec.acceptance_criteria.len().to_string()));
println!();
println!("{}", c::header("Validation Results (Popperian: FALSE until PROVEN)"));
println!();
let mut category_scores: HashMap<String, (u32, u32)> = HashMap::new();
for cat in &[
ClaimCategory::Falsifiability,
ClaimCategory::Implementation,
ClaimCategory::Testing,
ClaimCategory::Documentation,
ClaimCategory::Integration,
] {
let cat_name = format!("{:?}", cat);
category_scores.insert(cat_name, (0, 0));
}
for claim in &spec.claims {
let cat_name = format!("{:?}", claim.category);
let entry = category_scores.entry(cat_name.clone()).or_insert((0, 0));
entry.1 += 1;
let (status, evidence) = if claim.automatable {
if let Some(ref cmd) = claim.validation_cmd {
match run_validation_command(cmd, project_path).await {
Ok(output) => {
if let Some(ref pattern) = claim.expected_pattern {
if output.contains(pattern) {
(SpecValidationStatus::Proven, Some(output))
} else {
(SpecValidationStatus::Falsified, Some(output))
}
} else {
(SpecValidationStatus::Proven, Some(output))
}
}
Err(e) => (
SpecValidationStatus::Falsified,
Some(format!("Error: {}", e)),
),
}
} else {
(SpecValidationStatus::Unfalsified, None)
}
} else {
(SpecValidationStatus::ManualRequired, None)
};
if status == SpecValidationStatus::Proven {
entry.0 += 1;
} else if status == SpecValidationStatus::ManualRequired {
entry.0 += 1; }
let status_str = match status {
SpecValidationStatus::Proven => format!("{}✓ PROVEN{}", c::GREEN, c::RESET),
SpecValidationStatus::Falsified => format!("{}✗ FALSIFIED{}", c::RED, c::RESET),
SpecValidationStatus::Unfalsified => format!("{}? UNFALSIFIED{}", c::YELLOW, c::RESET),
SpecValidationStatus::ManualRequired => format!("{}⚙ MANUAL{}", c::BLUE, c::RESET),
SpecValidationStatus::Skipped => format!("{}- SKIPPED{}", c::DIM, c::RESET),
};
let truncated: String = claim.text.chars().take(60).collect();
println!(
" {} [{}] {} - {}",
status_str,
claim.id,
truncated,
c::dim(&cat_name)
);
if let Some(ref ev) = evidence {
if ev.len() < 100 {
println!(" {}: {}", c::dim("Evidence"), ev);
}
}
}
println!();
println!("{}", c::header("Category Scores (100-point Popperian Framework)"));
println!();
let mut total_score: f64 = 0.0;
let mut gateway_score: f64 = 0.0;
for cat in &[
ClaimCategory::Falsifiability,
ClaimCategory::Implementation,
ClaimCategory::Testing,
ClaimCategory::Documentation,
ClaimCategory::Integration,
] {
let cat_name = format!("{:?}", cat);
let (passed, total) = category_scores.get(&cat_name).unwrap_or(&(0, 0));
let max_pts = cat.max_points();
let cat_score = if *total > 0 {
(*passed as f64 / *total as f64) * max_pts as f64
} else {
0.0
};
let pct = if *total > 0 {
(*passed as f64 / *total as f64) * 100.0
} else {
0.0
};
if *cat == ClaimCategory::Falsifiability {
gateway_score = cat_score;
print!(" {} ", c::label("GATE"));
} else {
print!(" ");
}
println!(
"{:<15} {}/{} pts ({:.0}%) - {}/{} claims",
cat_name,
c::number(&format!("{:.1}", cat_score)),
max_pts,
pct,
passed,
total
);
total_score += cat_score;
}
println!();
println!("{}", c::rule());
let gateway_passed = gateway_score >= gateway_threshold as f64;
let final_score = if gateway_passed { total_score } else { 0.0 };
if !gateway_passed {
println!(
"{} GATEWAY FAILED: Falsifiability score {:.1} < {} (total score forced to 0)",
c::fail(""),
gateway_score,
gateway_threshold
);
println!(
" {}",
c::dim("Per Popper: Without falsifiable claims, the specification is non-scientific.")
);
} else {
println!(
"{} Gateway passed: Falsifiability score {:.1} >= {}",
c::pass(""),
gateway_score,
gateway_threshold
);
}
println!();
println!(
"{}: {}/100 (threshold: {})",
c::label("Total Score"),
c::number(&format!("{:.1}", final_score)),
threshold
);
let passed = final_score >= threshold as f64;
if passed {
println!("{}", c::pass("PASSED"));
} else {
println!("{}", c::fail("FAILED (score below threshold)"));
}
if let Some(output_path) = output {
let result = serde_json::json!({
"spec_path": spec_path.display().to_string(),
"title": spec.title,
"issue_refs": spec.issue_refs,
"claims_total": spec.claims.len(),
"gateway_score": gateway_score,
"gateway_passed": gateway_passed,
"total_score": final_score,
"threshold": threshold,
"passed": passed,
"category_scores": category_scores,
});
let output_content = match format {
QaOutputFormat::Json => serde_json::to_string_pretty(&result)?,
QaOutputFormat::Yaml => serde_yaml_ng::to_string(&result)?,
QaOutputFormat::Markdown => format_spec_result_markdown(&result),
QaOutputFormat::Text => format!("{:#?}", result),
};
fs::write(output_path, &output_content)?;
println!("\n{} Results saved to: {}", c::pass(""), c::path(&output_path.display().to_string()));
}
if !passed {
anyhow::bail!("Specification validation failed");
}
Ok(())
}
fn resolve_spec_path(target: &str, project_path: &Path) -> Result<PathBuf> {
let direct_path = PathBuf::from(target);
if direct_path.exists() && direct_path.extension().map(|e| e == "md").unwrap_or(false) {
return Ok(direct_path);
}
let project_relative = project_path.join(target);
if project_relative.exists() {
return Ok(project_relative);
}
let specs_dir = project_path.join("docs/specifications");
if specs_dir.exists() {
let spec_path = specs_dir.join(format!("{}.md", target));
if spec_path.exists() {
return Ok(spec_path);
}
let normalized = target.to_lowercase().replace('_', "-");
let spec_path = specs_dir.join(format!("{}.md", normalized));
if spec_path.exists() {
return Ok(spec_path);
}
if let Ok(entries) = std::fs::read_dir(&specs_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_lowercase();
if name.contains(&target.to_lowercase()) && name.ends_with(".md") {
return Ok(entry.path());
}
}
}
}
if target.starts_with("GH-") || target.starts_with('#') {
let issue_num = target.trim_start_matches("GH-").trim_start_matches('#');
let spec_path = specs_dir.join(format!("gh-{}.md", issue_num));
if spec_path.exists() {
return Ok(spec_path);
}
}
anyhow::bail!(
"Specification not found: {}\n\nSearched:\n - {}\n - docs/specifications/{}.md",
target,
project_path.join(target).display(),
target
)
}
async fn run_validation_command(cmd: &str, project_path: &Path) -> Result<String> {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.is_empty() {
anyhow::bail!("Empty command");
}
let output = Command::new(parts[0])
.args(&parts[1..])
.current_dir(project_path)
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
Ok(stdout.to_string())
} else {
Ok(format!("FAILED: {}{}", stdout, stderr))
}
}
fn format_spec_result_markdown(result: &serde_json::Value) -> String {
format!(
r#"# Specification Validation Report
## Summary
- **Specification**: {}
- **Title**: {}
- **Issues**: {:?}
- **Total Claims**: {}
## Scores
- **Gateway (Falsifiability)**: {:.1}/25 - {}
- **Total Score**: {:.1}/100
- **Threshold**: {}
- **Status**: {}
## Category Breakdown
| Category | Score | Status |
|----------|-------|--------|
| Falsifiability | {:.1}/25 | {} |
| Implementation | TBD | TBD |
| Testing | TBD | TBD |
| Documentation | TBD | TBD |
| Integration | TBD | TBD |
---
*Generated by pmat qa spec (Popperian 100-point framework)*
"#,
result["spec_path"].as_str().unwrap_or("unknown"),
result["title"].as_str().unwrap_or("unknown"),
result["issue_refs"],
result["claims_total"],
result["gateway_score"].as_f64().unwrap_or(0.0),
if result["gateway_passed"].as_bool().unwrap_or(false) {
"PASSED"
} else {
"FAILED"
},
result["total_score"].as_f64().unwrap_or(0.0),
result["threshold"],
if result["passed"].as_bool().unwrap_or(false) {
"PASSED"
} else {
"FAILED"
},
result["gateway_score"].as_f64().unwrap_or(0.0),
if result["gateway_passed"].as_bool().unwrap_or(false) {
"✓"
} else {
"✗"
},
)
}