1use std::collections::{HashSet, VecDeque};
10
11use anyhow::Result;
12use clap::Args;
13use serde_json::{Value, json};
14
15use agentstatedeveloper_core::{
16 AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, EffectStore, Engine,
17 FeedbackStore, FtsFilters, IndexStore, LedgerKind, LedgerStore, apply_feedback_adjustments,
18 classify_layer_sym, derive_cold_hints, estimate_tokens, find_candidates, git_dirty_files,
19 intent_focus, load_layer_overrides, parse_intent, parse_query, propose_test_path,
20 resolve_scope, stale_warning, symbol_tier, trim_for_agent,
21};
22
23use crate::commands::graph::build_id_map;
24use crate::config::Config;
25
26#[derive(Debug, Args)]
27pub struct ChecklistArgs {
28 pub query: String,
30
31 #[arg(long)]
34 pub task_context: Option<String>,
35
36 #[arg(long, default_value = "10")]
38 pub depth: usize,
39
40 #[arg(long)]
42 pub kind: Option<String>,
43
44 #[arg(long)]
46 pub language: Option<String>,
47
48 #[arg(long)]
50 pub include_tests: bool,
51
52 #[arg(long)]
54 pub quiet: bool,
55
56 #[arg(long)]
58 pub json: bool,
59
60 #[arg(long)]
63 pub intent: Option<String>,
64
65 #[arg(long, default_value = "2")]
67 pub test_depth: usize,
68
69 #[arg(long)]
71 pub agent: bool,
72
73 #[arg(long, default_value = "8000")]
75 pub agent_budget: usize,
76
77 #[arg(long)]
80 pub exclude: Option<String>,
81
82 #[arg(long)]
84 pub paths: Option<String>,
85
86 #[arg(long)]
88 pub scope: Option<String>,
89}
90
91pub fn run(cfg: &Config, args: ChecklistArgs) -> Result<()> {
92 if !args.quiet {
93 if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
94 eprintln!("{warn}");
95 }
96 }
97 let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
98 let layer_overrides = load_layer_overrides(&cfg.db_path);
99 let engine = Engine::open_sqlite(&cfg.db_path)?;
100 let index_store = AsgIndexStore::from_engine(&engine);
101 let ledger_store = AsgLedgerStore::from_engine(&engine);
102 let effect_store = AsgEffectStore::from_engine(&engine);
103 let id_map = build_id_map(&engine);
104
105 let (mut tokens, mut exclusions) = parse_query(&args.query);
106 if let Some(ref excl) = args.exclude {
107 for term in excl
108 .split(',')
109 .map(|t| t.trim().to_lowercase())
110 .filter(|t| !t.is_empty())
111 {
112 exclusions.push(term);
113 }
114 }
115 let auto_ctx_plan = std::env::var("CTXONE_PLAN").ok().filter(|s| !s.is_empty());
117 let auto_ctx_task = std::env::var("CTXONE_TASK").ok().filter(|s| !s.is_empty());
118 let ctx_text = args.task_context.clone().or_else(|| {
119 let parts: Vec<&str> = [auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()]
120 .iter()
121 .filter_map(|x| *x)
122 .collect();
123 if parts.is_empty() {
124 None
125 } else {
126 Some(parts.join(" "))
127 }
128 });
129 if let Some(ref ctx) = ctx_text {
130 let (ctx_tokens, _) = parse_query(ctx);
131 for t in ctx_tokens {
132 if !tokens.contains(&t) {
133 tokens.push(t);
134 }
135 }
136 }
137 if tokens.is_empty() {
138 if args.json {
139 println!("{}", json!({"query": args.query, "items": {}}));
140 } else {
141 println!(
142 "# Pre-edit checklist: {}\n\nNo matching symbols.",
143 args.query
144 );
145 }
146 return Ok(());
147 }
148
149 let mut paths_filter: Vec<String> = Vec::new();
150 if let Some(ref scope) = args.scope {
151 paths_filter.extend(resolve_scope(scope, &cfg.db_path));
152 }
153 if let Some(ref paths) = args.paths {
154 paths_filter.extend(
155 paths
156 .split(',')
157 .map(|p| p.trim().to_string())
158 .filter(|p| !p.is_empty()),
159 );
160 }
161 let filters = FtsFilters {
162 kind: args.kind.as_deref().map(|k| k.to_lowercase()),
163 language: args.language.as_deref().map(|l| l.to_lowercase()),
164 include_tests: args.include_tests,
165 tests_only: false,
166 exclude_terms: exclusions,
167 paths_filter,
168 exclude_paths: Vec::new(),
169 exclude_languages: Vec::new(),
170 };
171
172 let mut candidates = find_candidates(
173 &engine,
174 &args.query,
175 &tokens,
176 &filters,
177 &ledger_store,
178 &index_store,
179 args.depth,
180 );
181
182 let feedback_store = AsgFeedbackStore::from_engine(&engine);
184 let feedback_verdicts = feedback_store
185 .flat_verdicts(&engine.ref_name)
186 .unwrap_or_default();
187 apply_feedback_adjustments(
188 &engine,
189 &index_store,
190 &args.query,
191 &mut candidates,
192 &feedback_verdicts,
193 );
194
195 let mut files_to_inspect: Vec<Value> = Vec::new();
197 let mut seen_files: HashSet<String> = HashSet::new();
198
199 let mut invariants: Vec<Value> = Vec::new();
201 let mut hazards: Vec<Value> = Vec::new();
202 let mut effects_list: Vec<Value> = Vec::new();
203 let mut seen_inv: HashSet<String> = HashSet::new();
204
205 let mut test_rows: Vec<Value> = Vec::new();
207 let mut seen_tests: HashSet<String> = HashSet::new();
208
209 for (_score, qname) in &candidates {
210 let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
211 Ok(Some(s)) => s,
212 _ => continue,
213 };
214 let tier = symbol_tier(&sym.file);
215 let layer = classify_layer_sym(&sym.file, &sym.qname, tier, &layer_overrides);
216
217 if seen_files.insert(sym.file.clone()) {
219 files_to_inspect.push(json!({
220 "file": sym.file,
221 "qname": sym.qname,
222 "layer": layer,
223 "line": sym.start.line,
224 }));
225 }
226
227 let entries = ledger_store
229 .list_entries(&engine.ref_name, &sym.symbol_id)
230 .unwrap_or_default();
231 for entry in &entries {
232 let key = entry.summary.clone();
233 match entry.kind {
234 LedgerKind::Invariant => {
235 if seen_inv.insert(key) {
236 invariants.push(json!({
237 "summary": entry.summary,
238 "source": sym.qname,
239 "body": entry.body,
240 }));
241 }
242 }
243 LedgerKind::Hazard => {
244 hazards.push(json!({
245 "summary": entry.summary,
246 "source": sym.qname,
247 "body": entry.body,
248 }));
249 }
250 _ => {}
251 }
252 }
253
254 if let Ok(Some(decl)) = effect_store.get_effects(&engine.ref_name, &sym.symbol_id) {
256 for eff in &decl.declared {
257 let key = format!("{:?}", eff.effect);
258 effects_list.push(json!({
259 "category": key,
260 "source": sym.qname,
261 }));
262 }
263 }
264
265 let mut visited: HashSet<String> = HashSet::new();
267 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
268 visited.insert(sym.symbol_id.clone());
269 queue.push_back((sym.symbol_id.clone(), 0));
270 while let Some((sid, depth)) = queue.pop_front() {
271 if depth >= args.test_depth {
272 continue;
273 }
274 let callers = index_store
275 .get_callers(&engine.ref_name, &sid)
276 .unwrap_or_default();
277 for caller_id in callers {
278 if visited.contains(&caller_id) {
279 continue;
280 }
281 visited.insert(caller_id.clone());
282 if let Some(s) = id_map.get(&caller_id) {
283 if symbol_tier(&s.file) == 2 && seen_tests.insert(s.qname.clone()) {
284 test_rows.push(json!({
285 "qname": s.qname,
286 "file": s.file,
287 "line": s.start.line,
288 }));
289 }
290 if depth + 1 < args.test_depth {
291 queue.push_back((caller_id, depth + 1));
292 }
293 }
294 }
295 }
296 }
297
298 effects_list.dedup_by(|a, b| {
300 a.get("category").and_then(Value::as_str) == b.get("category").and_then(Value::as_str)
301 && a.get("source").and_then(Value::as_str) == b.get("source").and_then(Value::as_str)
302 });
303
304 let focus = intent_focus(intent);
305
306 let dirty = git_dirty_files();
308 let stale_symbols: Vec<&str> = files_to_inspect
309 .iter()
310 .filter_map(|v| v.get("file").and_then(Value::as_str))
311 .filter(|f| dirty.contains(*f))
312 .collect();
313 let test_gap = test_rows.is_empty();
314 let proposed_test_path = test_gap
315 .then(|| {
316 files_to_inspect
317 .first()
318 .and_then(|v| v.get("file").and_then(Value::as_str))
319 .map(propose_test_path)
320 })
321 .flatten();
322 let suggested_test_coverage: Vec<String> = if test_gap {
323 let mut hints: Vec<String> = invariants
324 .iter()
325 .filter_map(|inv| inv.get("summary").and_then(Value::as_str))
326 .map(|s| s.to_string())
327 .collect();
328 for eff in &effects_list {
329 if let Some(cat) = eff.get("category").and_then(Value::as_str) {
330 let hint = format!("verify {} after change", cat.to_lowercase());
331 if !hints.contains(&hint) {
332 hints.push(hint);
333 }
334 }
335 }
336 if invariants.is_empty() {
339 if let Some((_, qname)) = candidates.first() {
340 if let Ok(Some(sym)) = index_store.get_symbol_by_qname(&engine.ref_name, qname) {
341 for h in
342 derive_cold_hints(&sym.qname, sym.signature.as_deref(), sym.doc.as_deref())
343 {
344 if !hints.contains(&h) {
345 hints.push(h);
346 }
347 }
348 }
349 }
350 }
351 hints
352 } else {
353 vec![]
354 };
355
356 const CONSTRAINT_WORDS: &[&str] = &[
359 "must",
360 "never",
361 "shall",
362 "always",
363 "only",
364 "cannot",
365 "no ",
366 "not ",
367 "require",
368 "ensure",
369 "prevent",
370 "guarantee",
371 "invariant",
372 "forbidden",
373 ];
374 let scenario_tests: Vec<String> = invariants
375 .iter()
376 .filter_map(|inv| inv.get("summary").and_then(Value::as_str))
377 .filter(|s| {
378 let sl = s.to_lowercase();
379 CONSTRAINT_WORDS.iter().any(|w| sl.contains(w))
380 })
381 .map(|s| s.to_string())
382 .collect();
383
384 let task_close_suggestions: Vec<Value> = {
387 let mut suggestions: Vec<Value> = Vec::new();
388 for inv in invariants.iter().take(4) {
390 let source = inv.get("source").and_then(Value::as_str).unwrap_or("");
391 let summary = inv.get("summary").and_then(Value::as_str).unwrap_or("");
392 if !source.is_empty() && !summary.is_empty() {
393 let core = summary.trim_start_matches(|c: char| !c.is_alphabetic());
395 let core = ["must ", "never ", "shall ", "always ", "cannot ", "only "]
396 .iter()
397 .fold(core, |s, prefix| s.strip_prefix(prefix).unwrap_or(s));
398 suggestions.push(json!({
399 "action": "ledger_append",
400 "kind": "proof",
401 "symbol": source,
402 "suggested_summary": format!("confirm {} still holds after this change", core.trim()),
403 }));
404 }
405 }
406 for h in hazards.iter().take(2) {
408 let source = h.get("source").and_then(Value::as_str).unwrap_or("");
409 let summary = h.get("summary").and_then(Value::as_str).unwrap_or("");
410 if !source.is_empty() && !summary.is_empty() {
411 suggestions.push(json!({
412 "action": "ledger_append",
413 "kind": "validation_scenario",
414 "symbol": source,
415 "suggested_summary": format!("check that '{}' was not introduced by this change", summary),
416 }));
417 }
418 }
419 for eff in effects_list.iter().take(2) {
421 let source = eff.get("source").and_then(Value::as_str).unwrap_or("");
422 let cat = eff.get("category").and_then(Value::as_str).unwrap_or("");
423 if !source.is_empty() && !cat.is_empty() {
424 suggestions.push(json!({
425 "action": "ledger_append",
426 "kind": "proof",
427 "symbol": source,
428 "suggested_summary": format!("{} behaviour is still correct after this change", cat.to_lowercase()),
429 }));
430 }
431 }
432 suggestions
433 };
434
435 if args.agent || args.json {
436 let ctx_context_val = match (auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()) {
437 (None, None) => Value::Null,
438 _ => json!({ "plan": auto_ctx_plan, "task": auto_ctx_task }),
439 };
440 let out = json!({
441 "query": args.query,
442 "ctx_context": ctx_context_val,
443 "intent": if intent.is_empty() { Value::Null } else { json!(intent) },
444 "focus": if focus.is_empty() { Value::Null } else { json!(focus) },
445 "files_to_inspect": files_to_inspect,
446 "invariants_to_preserve": invariants,
447 "tests_to_run": test_rows,
448 "test_gap": test_gap,
449 "proposed_test_path": proposed_test_path,
450 "suggested_test_coverage": suggested_test_coverage,
451 "scenario_tests": scenario_tests,
452 "stale_symbols": stale_symbols,
453 "known_hazards": hazards,
454 "effects_to_verify": effects_list,
455 "task_close_suggestions": task_close_suggestions,
456 });
457 let out = if args.agent {
458 let max_list = (args.agent_budget / 500).max(3).min(20);
459 let trimmed = trim_for_agent(&out, max_list);
460 let json_str = serde_json::to_string_pretty(&trimmed)?;
461 let token_est = estimate_tokens(&json_str);
462 let mut v = trimmed;
463 if let Some(obj) = v.as_object_mut() {
464 obj.insert("token_estimate".into(), json!(token_est));
465 }
466 v
467 } else {
468 out
469 };
470 println!("{}", serde_json::to_string_pretty(&out)?);
471 } else {
472 print_markdown(
473 &args.query,
474 intent,
475 focus,
476 &files_to_inspect,
477 &invariants,
478 &test_rows,
479 test_gap,
480 proposed_test_path.as_deref(),
481 &suggested_test_coverage,
482 &stale_symbols,
483 &hazards,
484 &effects_list,
485 &task_close_suggestions,
486 );
487 }
488 Ok(())
489}
490
491fn print_markdown(
492 query: &str,
493 intent: &str,
494 focus: &str,
495 files: &[Value],
496 invariants: &[Value],
497 tests: &[Value],
498 test_gap: bool,
499 proposed_test_path: Option<&str>,
500 suggested_coverage: &[String],
501 stale_symbols: &[&str],
502 hazards: &[Value],
503 effects: &[Value],
504 task_close_suggestions: &[Value],
505) {
506 println!("# Pre-edit checklist: {query}");
507 if !intent.is_empty() {
508 println!("\n**Intent:** {intent} ");
509 println!("**Focus:** {focus}");
510 }
511
512 println!("\n## Files to inspect");
513 if files.is_empty() {
514 println!("_(no entry points found)_");
515 } else {
516 for f in files {
517 let file = f.get("file").and_then(Value::as_str).unwrap_or("");
518 let qname = f.get("qname").and_then(Value::as_str).unwrap_or("");
519 let layer = f.get("layer").and_then(Value::as_str).unwrap_or("");
520 let line = f.get("line").and_then(Value::as_u64).unwrap_or(0);
521 println!("- [ ] `{file}:{line}` — `{qname}` [{layer}]");
522 }
523 }
524
525 println!("\n## Invariants to preserve");
526 if invariants.is_empty() {
527 println!("_(no invariants recorded — add with `asd ledger append --kind invariant`)_");
528 } else {
529 for inv in invariants {
530 let summary = inv.get("summary").and_then(Value::as_str).unwrap_or("");
531 let source = inv.get("source").and_then(Value::as_str).unwrap_or("");
532 println!("- [ ] {summary} _(from `{source}`)_");
533 }
534 }
535
536 println!("\n## Tests to run");
537 if tests.is_empty() {
538 if test_gap {
539 println!("_(no test callers found — test gap detected)_");
540 if let Some(path) = proposed_test_path {
541 println!("_Suggested test file: `{path}`_");
542 }
543 if !suggested_coverage.is_empty() {
544 println!("\n_Suggested behaviours to cover:_");
545 for hint in suggested_coverage {
546 println!("- [ ] {hint}");
547 }
548 }
549 } else {
550 println!("_(no test callers found within BFS depth)_");
551 }
552 } else {
553 for t in tests {
554 let qname = t.get("qname").and_then(Value::as_str).unwrap_or("");
555 let file = t.get("file").and_then(Value::as_str).unwrap_or("");
556 println!("- [ ] `{qname}` — {file}");
557 }
558 }
559
560 if !stale_symbols.is_empty() {
561 println!("\n## Staleness warning");
562 println!(
563 "_The following files are modified since the last index — symbol ranges may be stale:_"
564 );
565 for f in stale_symbols {
566 println!("- `{f}`");
567 }
568 }
569
570 if !hazards.is_empty() {
571 println!("\n## Known hazards");
572 for h in hazards {
573 let summary = h.get("summary").and_then(Value::as_str).unwrap_or("");
574 let source = h.get("source").and_then(Value::as_str).unwrap_or("");
575 println!("- [ ] {summary} _(from `{source}`)_");
576 }
577 }
578
579 if !effects.is_empty() {
580 println!("\n## Effects to verify");
581 let mut seen: HashSet<String> = HashSet::new();
582 for e in effects {
583 let cat = e.get("category").and_then(Value::as_str).unwrap_or("");
584 let src = e.get("source").and_then(Value::as_str).unwrap_or("");
585 let key = format!("{cat}:{src}");
586 if seen.insert(key) {
587 println!("- [ ] `{cat}` — declared by `{src}`");
588 }
589 }
590 }
591
592 if !task_close_suggestions.is_empty() {
593 println!("\n## Before closing this task — record in ledger");
594 println!("_Run `asd task-close` or `asd ledger append` to capture these:_");
595 for s in task_close_suggestions {
596 let kind = s.get("kind").and_then(Value::as_str).unwrap_or("");
597 let symbol = s.get("symbol").and_then(Value::as_str).unwrap_or("");
598 let summary = s
599 .get("suggested_summary")
600 .and_then(Value::as_str)
601 .unwrap_or("");
602 println!("- [ ] [{kind}] `{symbol}` — {summary}");
603 }
604 }
605}