use std::collections::{HashSet, VecDeque};
use anyhow::Result;
use clap::Args;
use serde_json::{Value, json};
use agentstatedeveloper_core::{
AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, EffectStore, Engine,
FeedbackStore, FtsFilters, IndexStore, LedgerKind, LedgerStore, apply_feedback_adjustments,
classify_layer_sym, derive_cold_hints, estimate_tokens, find_candidates, git_dirty_files,
intent_focus, load_layer_overrides, parse_intent, parse_query, propose_test_path,
resolve_scope, stale_warning, symbol_tier, trim_for_agent,
};
use crate::commands::graph::build_id_map;
use crate::config::Config;
#[derive(Debug, Args)]
pub struct ChecklistArgs {
pub query: String,
#[arg(long)]
pub task_context: Option<String>,
#[arg(long, default_value = "10")]
pub depth: usize,
#[arg(long)]
pub kind: Option<String>,
#[arg(long)]
pub language: Option<String>,
#[arg(long)]
pub include_tests: bool,
#[arg(long)]
pub quiet: bool,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub intent: Option<String>,
#[arg(long, default_value = "2")]
pub test_depth: usize,
#[arg(long)]
pub agent: bool,
#[arg(long, default_value = "8000")]
pub agent_budget: usize,
#[arg(long)]
pub exclude: Option<String>,
#[arg(long)]
pub paths: Option<String>,
#[arg(long)]
pub scope: Option<String>,
}
pub fn run(cfg: &Config, args: ChecklistArgs) -> Result<()> {
if !args.quiet {
if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
eprintln!("{warn}");
}
}
let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
let layer_overrides = load_layer_overrides(&cfg.db_path);
let engine = Engine::open_sqlite(&cfg.db_path)?;
let index_store = AsgIndexStore::from_engine(&engine);
let ledger_store = AsgLedgerStore::from_engine(&engine);
let effect_store = AsgEffectStore::from_engine(&engine);
let id_map = build_id_map(&engine);
let (mut tokens, mut exclusions) = parse_query(&args.query);
if let Some(ref excl) = args.exclude {
for term in excl
.split(',')
.map(|t| t.trim().to_lowercase())
.filter(|t| !t.is_empty())
{
exclusions.push(term);
}
}
let auto_ctx_plan = std::env::var("CTXONE_PLAN").ok().filter(|s| !s.is_empty());
let auto_ctx_task = std::env::var("CTXONE_TASK").ok().filter(|s| !s.is_empty());
let ctx_text = args.task_context.clone().or_else(|| {
let parts: Vec<&str> = [auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()]
.iter()
.filter_map(|x| *x)
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
});
if let Some(ref ctx) = ctx_text {
let (ctx_tokens, _) = parse_query(ctx);
for t in ctx_tokens {
if !tokens.contains(&t) {
tokens.push(t);
}
}
}
if tokens.is_empty() {
if args.json {
println!("{}", json!({"query": args.query, "items": {}}));
} else {
println!(
"# Pre-edit checklist: {}\n\nNo matching symbols.",
args.query
);
}
return Ok(());
}
let mut paths_filter: Vec<String> = Vec::new();
if let Some(ref scope) = args.scope {
paths_filter.extend(resolve_scope(scope, &cfg.db_path));
}
if let Some(ref paths) = args.paths {
paths_filter.extend(
paths
.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty()),
);
}
let filters = FtsFilters {
kind: args.kind.as_deref().map(|k| k.to_lowercase()),
language: args.language.as_deref().map(|l| l.to_lowercase()),
include_tests: args.include_tests,
tests_only: false,
exclude_terms: exclusions,
paths_filter,
exclude_paths: Vec::new(),
exclude_languages: Vec::new(),
};
let mut candidates = find_candidates(
&engine,
&args.query,
&tokens,
&filters,
&ledger_store,
&index_store,
args.depth,
);
let feedback_store = AsgFeedbackStore::from_engine(&engine);
let feedback_verdicts = feedback_store
.flat_verdicts(&engine.ref_name)
.unwrap_or_default();
apply_feedback_adjustments(
&engine,
&index_store,
&args.query,
&mut candidates,
&feedback_verdicts,
);
let mut files_to_inspect: Vec<Value> = Vec::new();
let mut seen_files: HashSet<String> = HashSet::new();
let mut invariants: Vec<Value> = Vec::new();
let mut hazards: Vec<Value> = Vec::new();
let mut effects_list: Vec<Value> = Vec::new();
let mut seen_inv: HashSet<String> = HashSet::new();
let mut test_rows: Vec<Value> = Vec::new();
let mut seen_tests: HashSet<String> = HashSet::new();
for (_score, qname) in &candidates {
let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(s)) => s,
_ => continue,
};
let tier = symbol_tier(&sym.file);
let layer = classify_layer_sym(&sym.file, &sym.qname, tier, &layer_overrides);
if seen_files.insert(sym.file.clone()) {
files_to_inspect.push(json!({
"file": sym.file,
"qname": sym.qname,
"layer": layer,
"line": sym.start.line,
}));
}
let entries = ledger_store
.list_entries(&engine.ref_name, &sym.symbol_id)
.unwrap_or_default();
for entry in &entries {
let key = entry.summary.clone();
match entry.kind {
LedgerKind::Invariant => {
if seen_inv.insert(key) {
invariants.push(json!({
"summary": entry.summary,
"source": sym.qname,
"body": entry.body,
}));
}
}
LedgerKind::Hazard => {
hazards.push(json!({
"summary": entry.summary,
"source": sym.qname,
"body": entry.body,
}));
}
_ => {}
}
}
if let Ok(Some(decl)) = effect_store.get_effects(&engine.ref_name, &sym.symbol_id) {
for eff in &decl.declared {
let key = format!("{:?}", eff.effect);
effects_list.push(json!({
"category": key,
"source": sym.qname,
}));
}
}
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
visited.insert(sym.symbol_id.clone());
queue.push_back((sym.symbol_id.clone(), 0));
while let Some((sid, depth)) = queue.pop_front() {
if depth >= args.test_depth {
continue;
}
let callers = index_store
.get_callers(&engine.ref_name, &sid)
.unwrap_or_default();
for caller_id in callers {
if visited.contains(&caller_id) {
continue;
}
visited.insert(caller_id.clone());
if let Some(s) = id_map.get(&caller_id) {
if symbol_tier(&s.file) == 2 && seen_tests.insert(s.qname.clone()) {
test_rows.push(json!({
"qname": s.qname,
"file": s.file,
"line": s.start.line,
}));
}
if depth + 1 < args.test_depth {
queue.push_back((caller_id, depth + 1));
}
}
}
}
}
effects_list.dedup_by(|a, b| {
a.get("category").and_then(Value::as_str) == b.get("category").and_then(Value::as_str)
&& a.get("source").and_then(Value::as_str) == b.get("source").and_then(Value::as_str)
});
let focus = intent_focus(intent);
let dirty = git_dirty_files();
let stale_symbols: Vec<&str> = files_to_inspect
.iter()
.filter_map(|v| v.get("file").and_then(Value::as_str))
.filter(|f| dirty.contains(*f))
.collect();
let test_gap = test_rows.is_empty();
let proposed_test_path = test_gap
.then(|| {
files_to_inspect
.first()
.and_then(|v| v.get("file").and_then(Value::as_str))
.map(propose_test_path)
})
.flatten();
let suggested_test_coverage: Vec<String> = if test_gap {
let mut hints: Vec<String> = invariants
.iter()
.filter_map(|inv| inv.get("summary").and_then(Value::as_str))
.map(|s| s.to_string())
.collect();
for eff in &effects_list {
if let Some(cat) = eff.get("category").and_then(Value::as_str) {
let hint = format!("verify {} after change", cat.to_lowercase());
if !hints.contains(&hint) {
hints.push(hint);
}
}
}
if invariants.is_empty() {
if let Some((_, qname)) = candidates.first() {
if let Ok(Some(sym)) = index_store.get_symbol_by_qname(&engine.ref_name, qname) {
for h in
derive_cold_hints(&sym.qname, sym.signature.as_deref(), sym.doc.as_deref())
{
if !hints.contains(&h) {
hints.push(h);
}
}
}
}
}
hints
} else {
vec![]
};
const CONSTRAINT_WORDS: &[&str] = &[
"must",
"never",
"shall",
"always",
"only",
"cannot",
"no ",
"not ",
"require",
"ensure",
"prevent",
"guarantee",
"invariant",
"forbidden",
];
let scenario_tests: Vec<String> = invariants
.iter()
.filter_map(|inv| inv.get("summary").and_then(Value::as_str))
.filter(|s| {
let sl = s.to_lowercase();
CONSTRAINT_WORDS.iter().any(|w| sl.contains(w))
})
.map(|s| s.to_string())
.collect();
let task_close_suggestions: Vec<Value> = {
let mut suggestions: Vec<Value> = Vec::new();
for inv in invariants.iter().take(4) {
let source = inv.get("source").and_then(Value::as_str).unwrap_or("");
let summary = inv.get("summary").and_then(Value::as_str).unwrap_or("");
if !source.is_empty() && !summary.is_empty() {
let core = summary.trim_start_matches(|c: char| !c.is_alphabetic());
let core = ["must ", "never ", "shall ", "always ", "cannot ", "only "]
.iter()
.fold(core, |s, prefix| s.strip_prefix(prefix).unwrap_or(s));
suggestions.push(json!({
"action": "ledger_append",
"kind": "proof",
"symbol": source,
"suggested_summary": format!("confirm {} still holds after this change", core.trim()),
}));
}
}
for h in hazards.iter().take(2) {
let source = h.get("source").and_then(Value::as_str).unwrap_or("");
let summary = h.get("summary").and_then(Value::as_str).unwrap_or("");
if !source.is_empty() && !summary.is_empty() {
suggestions.push(json!({
"action": "ledger_append",
"kind": "validation_scenario",
"symbol": source,
"suggested_summary": format!("check that '{}' was not introduced by this change", summary),
}));
}
}
for eff in effects_list.iter().take(2) {
let source = eff.get("source").and_then(Value::as_str).unwrap_or("");
let cat = eff.get("category").and_then(Value::as_str).unwrap_or("");
if !source.is_empty() && !cat.is_empty() {
suggestions.push(json!({
"action": "ledger_append",
"kind": "proof",
"symbol": source,
"suggested_summary": format!("{} behaviour is still correct after this change", cat.to_lowercase()),
}));
}
}
suggestions
};
if args.agent || args.json {
let ctx_context_val = match (auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()) {
(None, None) => Value::Null,
_ => json!({ "plan": auto_ctx_plan, "task": auto_ctx_task }),
};
let out = json!({
"query": args.query,
"ctx_context": ctx_context_val,
"intent": if intent.is_empty() { Value::Null } else { json!(intent) },
"focus": if focus.is_empty() { Value::Null } else { json!(focus) },
"files_to_inspect": files_to_inspect,
"invariants_to_preserve": invariants,
"tests_to_run": test_rows,
"test_gap": test_gap,
"proposed_test_path": proposed_test_path,
"suggested_test_coverage": suggested_test_coverage,
"scenario_tests": scenario_tests,
"stale_symbols": stale_symbols,
"known_hazards": hazards,
"effects_to_verify": effects_list,
"task_close_suggestions": task_close_suggestions,
});
let out = if args.agent {
let max_list = (args.agent_budget / 500).max(3).min(20);
let trimmed = trim_for_agent(&out, max_list);
let json_str = serde_json::to_string_pretty(&trimmed)?;
let token_est = estimate_tokens(&json_str);
let mut v = trimmed;
if let Some(obj) = v.as_object_mut() {
obj.insert("token_estimate".into(), json!(token_est));
}
v
} else {
out
};
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
print_markdown(
&args.query,
intent,
focus,
&files_to_inspect,
&invariants,
&test_rows,
test_gap,
proposed_test_path.as_deref(),
&suggested_test_coverage,
&stale_symbols,
&hazards,
&effects_list,
&task_close_suggestions,
);
}
Ok(())
}
fn print_markdown(
query: &str,
intent: &str,
focus: &str,
files: &[Value],
invariants: &[Value],
tests: &[Value],
test_gap: bool,
proposed_test_path: Option<&str>,
suggested_coverage: &[String],
stale_symbols: &[&str],
hazards: &[Value],
effects: &[Value],
task_close_suggestions: &[Value],
) {
println!("# Pre-edit checklist: {query}");
if !intent.is_empty() {
println!("\n**Intent:** {intent} ");
println!("**Focus:** {focus}");
}
println!("\n## Files to inspect");
if files.is_empty() {
println!("_(no entry points found)_");
} else {
for f in files {
let file = f.get("file").and_then(Value::as_str).unwrap_or("");
let qname = f.get("qname").and_then(Value::as_str).unwrap_or("");
let layer = f.get("layer").and_then(Value::as_str).unwrap_or("");
let line = f.get("line").and_then(Value::as_u64).unwrap_or(0);
println!("- [ ] `{file}:{line}` — `{qname}` [{layer}]");
}
}
println!("\n## Invariants to preserve");
if invariants.is_empty() {
println!("_(no invariants recorded — add with `asd ledger append --kind invariant`)_");
} else {
for inv in invariants {
let summary = inv.get("summary").and_then(Value::as_str).unwrap_or("");
let source = inv.get("source").and_then(Value::as_str).unwrap_or("");
println!("- [ ] {summary} _(from `{source}`)_");
}
}
println!("\n## Tests to run");
if tests.is_empty() {
if test_gap {
println!("_(no test callers found — test gap detected)_");
if let Some(path) = proposed_test_path {
println!("_Suggested test file: `{path}`_");
}
if !suggested_coverage.is_empty() {
println!("\n_Suggested behaviours to cover:_");
for hint in suggested_coverage {
println!("- [ ] {hint}");
}
}
} else {
println!("_(no test callers found within BFS depth)_");
}
} else {
for t in tests {
let qname = t.get("qname").and_then(Value::as_str).unwrap_or("");
let file = t.get("file").and_then(Value::as_str).unwrap_or("");
println!("- [ ] `{qname}` — {file}");
}
}
if !stale_symbols.is_empty() {
println!("\n## Staleness warning");
println!(
"_The following files are modified since the last index — symbol ranges may be stale:_"
);
for f in stale_symbols {
println!("- `{f}`");
}
}
if !hazards.is_empty() {
println!("\n## Known hazards");
for h in hazards {
let summary = h.get("summary").and_then(Value::as_str).unwrap_or("");
let source = h.get("source").and_then(Value::as_str).unwrap_or("");
println!("- [ ] {summary} _(from `{source}`)_");
}
}
if !effects.is_empty() {
println!("\n## Effects to verify");
let mut seen: HashSet<String> = HashSet::new();
for e in effects {
let cat = e.get("category").and_then(Value::as_str).unwrap_or("");
let src = e.get("source").and_then(Value::as_str).unwrap_or("");
let key = format!("{cat}:{src}");
if seen.insert(key) {
println!("- [ ] `{cat}` — declared by `{src}`");
}
}
}
if !task_close_suggestions.is_empty() {
println!("\n## Before closing this task — record in ledger");
println!("_Run `asd task-close` or `asd ledger append` to capture these:_");
for s in task_close_suggestions {
let kind = s.get("kind").and_then(Value::as_str).unwrap_or("");
let symbol = s.get("symbol").and_then(Value::as_str).unwrap_or("");
let summary = s
.get("suggested_summary")
.and_then(Value::as_str)
.unwrap_or("");
println!("- [ ] [{kind}] `{symbol}` — {summary}");
}
}
}