1use std::collections::{HashMap, HashSet, VecDeque};
13
14use anyhow::Result;
15use clap::Args;
16use serde_json::{Value, json};
17
18use agentstatedeveloper_core::{
19 AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, CandidateAggregates,
20 EffectStore, Engine, FeedbackStore, FtsFilters, IndexStore, LedgerKind, LedgerStore,
21 SearchFtsDb, Symbol, aggregate_candidate_data, apply_feedback_adjustments, classify_file_role,
22 classify_layer_sym, compute_trust_score, compute_uncertainty, confidence_scores,
23 derive_cold_hints, detect_ambiguous_tokens, detect_possible_misses, estimate_tokens,
24 explain_match, extract_summary, fetch_all_test_file_paths, finalize_file_scores,
25 find_candidates, gather_recency, git_dirty_files, glob_match, intent_focus, intent_layer_order,
26 load_layer_overrides, parse_intent, parse_query, propagate_caller_invariants,
27 propose_test_path, propose_test_stub, resolve_scope, result_bucket, stale_warning, symbol_tier,
28 test_files_for_source, trim_for_agent,
29};
30
31use crate::commands::impact::git_recent_touches_pub;
32use crate::config::Config;
33
34#[derive(Debug, Args)]
35pub struct PrepareChangeArgs {
36 pub description: String,
38
39 #[arg(long, default_value = "10")]
41 pub depth: usize,
42
43 #[arg(long)]
45 pub kind: Option<String>,
46
47 #[arg(long)]
49 pub language: Option<String>,
50
51 #[arg(long)]
53 pub include_tests: bool,
54
55 #[arg(long)]
57 pub quiet: bool,
58
59 #[arg(long)]
62 pub intent: Option<String>,
63
64 #[arg(long, default_value = "2")]
66 pub test_depth: usize,
67
68 #[arg(long, default_value = "10")]
70 pub git_depth: usize,
71
72 #[arg(long)]
75 pub agent: bool,
76
77 #[arg(long, default_value = "8000")]
79 pub agent_budget: usize,
80
81 #[arg(long, visible_alias = "avoid")]
86 pub exclude: Option<String>,
87
88 #[arg(long)]
92 pub paths: Option<String>,
93
94 #[arg(long)]
96 pub scope: Option<String>,
97
98 #[arg(long)]
103 pub task_context: Option<String>,
104
105 #[arg(long)]
108 pub debug_classification: bool,
109
110 #[arg(long)]
114 pub check_commit: Option<String>,
115
116 #[arg(long)]
123 pub thinking_floor: Option<f64>,
124}
125
126pub fn run(cfg: &Config, args: PrepareChangeArgs) -> Result<()> {
127 if !args.quiet {
134 if let Some(warn) = agentstatedeveloper_core::stale_warning_classified(
135 &cfg.db_path,
136 agentstatedeveloper_core::SOFT_STALE_THRESHOLD_SECS,
137 ) {
138 if warn.severity == agentstatedeveloper_core::StaleSeverity::Critical {
139 eprintln!("{}", warn.message);
140 }
141 }
145 }
146 let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
147 let layer_overrides = load_layer_overrides(&cfg.db_path);
148 let engine = Engine::open_sqlite(&cfg.db_path)?;
149 let index_store = AsgIndexStore::from_engine(&engine);
150 let ledger_store = AsgLedgerStore::from_engine(&engine);
151 let effect_store = AsgEffectStore::from_engine(&engine);
152 let id_map = index_store.build_id_map(&engine);
153
154 let (mut tokens, mut exclusions) = parse_query(&args.description);
155 if let Some(ref excl) = args.exclude {
156 for term in excl
157 .split(',')
158 .map(|t| t.trim().to_lowercase())
159 .filter(|t| !t.is_empty())
160 {
161 exclusions.push(term);
162 }
163 }
164 let auto_ctx_plan = std::env::var("CTXONE_PLAN").ok().filter(|s| !s.is_empty());
167 let auto_ctx_task = std::env::var("CTXONE_TASK").ok().filter(|s| !s.is_empty());
168 let ctx_text = args.task_context.clone().or_else(|| {
169 let parts: Vec<&str> = [auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()]
170 .iter()
171 .filter_map(|x| *x)
172 .collect();
173 if parts.is_empty() {
174 None
175 } else {
176 Some(parts.join(" "))
177 }
178 });
179 if let Some(ref ctx) = ctx_text {
180 let (ctx_tokens, _) = parse_query(ctx);
181 for t in ctx_tokens {
182 if !tokens.contains(&t) {
183 tokens.push(t);
184 }
185 }
186 }
187 if tokens.is_empty() {
188 println!(
189 "{}",
190 json!({"description": args.description, "entry_points": {}})
191 );
192 return Ok(());
193 }
194
195 let mut paths_filter: Vec<String> = Vec::new();
196 if let Some(ref scope) = args.scope {
197 paths_filter.extend(resolve_scope(scope, &cfg.db_path));
198 }
199 if let Some(ref paths) = args.paths {
200 paths_filter.extend(
201 paths
202 .split(',')
203 .map(|p| p.trim().to_string())
204 .filter(|p| !p.is_empty()),
205 );
206 }
207 let _has_paths_filter = !paths_filter.is_empty();
208 let filters = FtsFilters {
209 kind: args.kind.as_deref().map(|k| k.to_lowercase()),
210 language: args.language.as_deref().map(|l| l.to_lowercase()),
211 include_tests: args.include_tests,
212 tests_only: false,
213 exclude_terms: exclusions,
214 paths_filter,
215 exclude_paths: Vec::new(),
216 exclude_languages: Vec::new(),
217 };
218
219 let mut candidates = find_candidates(
220 &engine,
221 &args.description,
222 &tokens,
223 &filters,
224 &ledger_store,
225 &index_store,
226 args.depth,
227 );
228
229 let feedback_store = AsgFeedbackStore::from_engine(&engine);
233 let all_fb = feedback_store
234 .list_all(&engine.ref_name)
235 .unwrap_or_default();
236 let feedback_verdicts: Vec<(
240 String,
241 String,
242 agentstatedeveloper_core::FeedbackVerdict,
243 chrono::DateTime<chrono::Utc>,
244 )> = all_fb
245 .iter()
246 .filter(|e| e.file_scope.is_none())
247 .map(|e| {
248 (
249 e.symbol_id.clone(),
250 e.query.clone(),
251 e.verdict,
252 e.created_at,
253 )
254 })
255 .collect();
256 let feedback_metrics = apply_feedback_adjustments(
257 &engine,
258 &index_store,
259 &args.description,
260 &mut candidates,
261 &feedback_verdicts,
262 );
263
264 let recency = gather_recency(200, 14.0);
266
267 let layer_order = intent_layer_order(intent);
275 let CandidateAggregates {
276 mut by_layer,
277 mut design_invariants,
278 known_hazards,
279 validation_scenarios_ledger,
280 effects_summary,
281 mut file_scores,
282 top_sym_id,
283 mut seen_inv,
284 } = aggregate_candidate_data(
285 &engine,
286 &index_store,
287 &ledger_store,
288 &effect_store,
289 &candidates,
290 &tokens,
291 &recency,
292 &layer_overrides,
293 );
294
295 let propagated = propagate_caller_invariants(
317 &engine,
318 &index_store,
319 &ledger_store,
320 &cfg.db_path,
321 &candidates,
322 &mut seen_inv,
323 );
324 design_invariants.extend(propagated);
325
326 let mut ordered_by_layer: serde_json::Map<String, Value> = serde_json::Map::new();
328 for lk in layer_order {
329 if let Some(v) = by_layer.remove(*lk) {
330 ordered_by_layer.insert(lk.to_string(), v);
331 }
332 }
333
334 let dirty_files = git_dirty_files();
336 let likely_edit_files = finalize_file_scores(&mut file_scores, &dirty_files, true);
341
342 let affected_tests = gather_affected_tests(
345 &engine,
346 &index_store,
347 &id_map,
348 top_sym_id.as_deref(),
349 args.test_depth,
350 &design_invariants,
351 );
352
353 let blast_radius = compute_blast_radius(
356 &engine,
357 &index_store,
358 &id_map,
359 &candidates,
360 &layer_overrides,
361 );
362
363 let top_files: Vec<(String, usize)> = file_scores
365 .iter()
366 .take(3)
367 .map(|(_, f, _, _, _, _, _)| (f.clone(), 0))
368 .collect();
369 let recently_touched = git_recent_touches_pub(&top_files, args.git_depth);
370
371 let stale_symbols: Vec<&str> = file_scores
374 .iter()
375 .filter(|(_, f, _, _, _, _, _)| dirty_files.contains(f.as_str()))
376 .map(|(_, f, _, _, _, _, _)| f.as_str())
377 .collect();
378
379 let all_test_file_paths = fetch_all_test_file_paths(&cfg.db_path);
383 let (test_gap, proposed_test_path, proposed_test_stub) =
385 detect_test_gap(&file_scores, &affected_tests, &all_test_file_paths);
386 let suggested_test_coverage: Vec<Value> = if test_gap {
396 let mut out: Vec<Value> = Vec::new();
397 let mut seen: HashSet<String> = HashSet::new();
398 for inv in &design_invariants {
399 if let Some(eid) = inv.get("entry_id").and_then(Value::as_str) {
400 if seen.insert(format!("ref:{eid}")) {
401 out.push(json!({ "ref": eid }));
402 }
403 }
404 }
405 for eff in &effects_summary {
406 if let Some(cat) = eff.get("category").and_then(Value::as_str) {
407 let hint = format!("verify {} after change", cat.to_lowercase());
408 if seen.insert(format!("hint:{hint}")) {
409 out.push(json!({ "hint": hint }));
410 }
411 }
412 }
413 if design_invariants.is_empty() {
417 if let Some((_, qname)) = candidates.first() {
418 if let Ok(Some(sym)) = index_store.get_symbol_by_qname(&engine.ref_name, qname) {
419 for h in
420 derive_cold_hints(&sym.qname, sym.signature.as_deref(), sym.doc.as_deref())
421 {
422 if seen.insert(format!("hint:{h}")) {
423 out.push(json!({ "hint": h }));
424 }
425 }
426 }
427 }
428 }
429 out
430 } else {
431 vec![]
432 };
433
434 const CONSTRAINT_WORDS: &[&str] = &[
435 "must",
436 "never",
437 "shall",
438 "always",
439 "only",
440 "cannot",
441 "no ",
442 "not ",
443 "require",
444 "ensure",
445 "prevent",
446 "guarantee",
447 "invariant",
448 "forbidden",
449 ];
450 let scenario_tests: Vec<Value> = design_invariants
457 .iter()
458 .filter_map(|inv| {
459 let summary = inv.get("summary").and_then(Value::as_str)?;
460 let entry_id = inv.get("entry_id").and_then(Value::as_str)?;
461 let sl = summary.to_lowercase();
462 if CONSTRAINT_WORDS.iter().any(|w| sl.contains(w)) {
463 Some(json!({ "ref": entry_id }))
464 } else {
465 None
466 }
467 })
468 .collect();
469
470 let ambiguous_terms = detect_ambiguous_tokens(&tokens, engine.fts.as_ref(), &filters);
471 let broad_query = !ambiguous_terms.is_empty() && {
473 let amb_set: HashSet<&str> = ambiguous_terms.iter().map(|s| s.as_str()).collect();
474 let amb_count = tokens
475 .iter()
476 .filter(|t| amb_set.contains(t.as_str()))
477 .count();
478 amb_count * 2 >= tokens.len().max(1)
479 };
480
481 let layers_present: std::collections::HashSet<&str> = file_scores
482 .iter()
483 .map(|(_, _, layer, _, _, _, _)| layer.as_str())
484 .collect();
485 let scope_narrowed = !filters.paths_filter.is_empty() || !filters.exclude_terms.is_empty();
487 let possible_misses = if scope_narrowed {
488 vec![]
489 } else {
490 detect_possible_misses(&args.description, &layers_present, file_scores.len())
491 };
492
493 let fts_omitted_files: Vec<Value> = if scope_narrowed && !filters.paths_filter.is_empty() {
497 let unscoped_filters = FtsFilters {
498 tests_only: false,
499 kind: filters.kind.clone(),
500 language: filters.language.clone(),
501 include_tests: filters.include_tests,
502 exclude_terms: filters.exclude_terms.clone(),
503 paths_filter: vec![], exclude_paths: filters.exclude_paths.clone(),
505 exclude_languages: filters.exclude_languages.clone(),
506 };
507 let unscoped_hits = SearchFtsDb::open(&cfg.db_path)
508 .ok()
509 .filter(|fts| fts.has_data())
510 .and_then(|fts| fts.search(&args.description, &unscoped_filters, 20).ok())
511 .unwrap_or_default();
512 let scoped_file_set: std::collections::HashSet<&str> = file_scores
513 .iter()
514 .map(|(_, f, _, _, _, _, _)| f.as_str())
515 .collect();
516 unscoped_hits
517 .iter()
518 .filter(|h| !filters.paths_filter.iter().any(|p| glob_match(p, &h.file)))
519 .filter(|h| !scoped_file_set.contains(h.file.as_str()))
520 .take(5)
521 .map(|h| {
522 let dir = std::path::Path::new(&h.file)
523 .parent()
524 .map(|d| format!("{}/**", d.to_string_lossy()))
525 .unwrap_or_else(|| h.file.clone());
526 json!({
527 "file": h.file,
528 "relation": "unscoped_fts_match",
529 "score": h.bm25_score,
530 "suggested_paths": dir,
531 "note": "matched query in broad search but excluded by current scope",
532 })
533 })
534 .collect()
535 } else {
536 vec![]
537 };
538
539 let likely_omitted_files: Vec<Value> = if scope_narrowed && !filters.paths_filter.is_empty() {
541 let top_sids_omit: Vec<String> = candidates
542 .iter()
543 .take(3)
544 .filter_map(|(_, q)| {
545 index_store
546 .get_symbol_by_qname(&engine.ref_name, q)
547 .ok()
548 .flatten()
549 })
550 .map(|s| s.symbol_id)
551 .collect();
552 let mut omitted: Vec<Value> = Vec::new();
553 let mut seen_files: HashSet<String> = HashSet::new();
554 for sid in &top_sids_omit {
555 let anchor_qname = id_map.get(sid).map(|s| s.qname.clone()).unwrap_or_default();
556 for caller_id in index_store
558 .get_callers(&engine.ref_name, sid)
559 .unwrap_or_default()
560 {
561 if let Some(sym) = id_map.get(&caller_id) {
562 let in_scope = filters
563 .paths_filter
564 .iter()
565 .any(|p| glob_match(p, &sym.file));
566 if !in_scope && seen_files.insert(sym.file.clone()) {
567 let dir = std::path::Path::new(&sym.file)
568 .parent()
569 .map(|d| format!("{}/**", d.to_string_lossy()))
570 .unwrap_or_else(|| sym.file.clone());
571 omitted.push(json!({
572 "file": sym.file,
573 "relation": "caller",
574 "of_symbol": anchor_qname,
575 "suggested_paths": dir,
576 }));
577 }
578 }
579 }
580 for callee_id in index_store
582 .get_callees(&engine.ref_name, sid)
583 .unwrap_or_default()
584 {
585 if let Some(sym) = id_map.get(&callee_id) {
586 let in_scope = filters
587 .paths_filter
588 .iter()
589 .any(|p| glob_match(p, &sym.file));
590 if !in_scope && seen_files.insert(sym.file.clone()) {
591 let dir = std::path::Path::new(&sym.file)
592 .parent()
593 .map(|d| format!("{}/**", d.to_string_lossy()))
594 .unwrap_or_else(|| sym.file.clone());
595 omitted.push(json!({
596 "file": sym.file,
597 "relation": "callee",
598 "of_symbol": anchor_qname,
599 "suggested_paths": dir,
600 }));
601 }
602 }
603 }
604 }
605 omitted
606 } else {
607 vec![]
608 };
609 let mut seen_omit: std::collections::HashSet<String> = std::collections::HashSet::new();
611 let mut likely_omitted_files = likely_omitted_files;
612 for item in fts_omitted_files {
613 if let Some(f) = item["file"].as_str() {
614 if seen_omit.insert(f.to_string()) {
615 likely_omitted_files.push(item);
616 }
617 }
618 }
619 for item in &likely_omitted_files {
620 if let Some(f) = item["file"].as_str() {
621 seen_omit.insert(f.to_string());
622 }
623 }
624
625 let recipe_inspect: Vec<Value> = file_scores
628 .iter()
629 .map(|(score, file, layer, days, hot, top_symbol, why)| {
630 json!({
631 "file": file, "layer": layer, "score": score,
632 "last_touched_days": days, "hot": hot,
633 "top_symbol": top_symbol, "why": why,
634 })
635 })
636 .collect();
637 let recipe_preserve: Vec<Value> = design_invariants
644 .iter()
645 .filter_map(|inv| {
646 inv.get("entry_id")
647 .and_then(Value::as_str)
648 .map(|eid| json!({ "ref": eid, "kind": "invariant" }))
649 })
650 .chain(known_hazards.iter().map(|h| {
651 json!({
652 "constraint": h["summary"],
653 "source": h["source"],
654 "kind": "hazard",
655 })
656 }))
657 .collect();
658 use std::collections::HashSet as _HSet;
662 let wrong_layer_files: _HSet<String> = {
663 let desc_norm = args.description.to_lowercase();
664 let desc_tokens: std::collections::HashSet<String> = desc_norm
665 .split(|c: char| !c.is_alphabetic())
666 .filter(|t: &&str| t.len() > 2)
667 .map(|t| t.to_string())
668 .collect();
669 let mut wl_files = _HSet::new();
670 for entry in &all_fb {
671 if !matches!(
672 entry.verdict,
673 agentstatedeveloper_core::FeedbackVerdict::WrongLayer
674 ) {
675 continue;
676 }
677 let fb_tokens: std::collections::HashSet<String> = entry
679 .query
680 .split(|c: char| !c.is_alphabetic())
681 .filter(|t: &&str| t.len() > 2)
682 .map(|t: &str| t.to_string())
683 .collect();
684 let overlaps = desc_tokens.iter().any(|t| fb_tokens.contains(t));
685 if !overlaps {
686 continue;
687 }
688 if let Ok(Some(sym)) =
690 index_store.get_symbol_by_qname(&engine.ref_name, &entry.symbol_qname)
691 {
692 wl_files.insert(sym.file);
693 }
694 }
695 wl_files
696 };
697 let mut file_to_tests: HashMap<String, Vec<String>> = HashMap::new();
699 for test in &affected_tests {
700 if let Some(test_file) = test["file"].as_str() {
701 for (_, file, _, _, _, _, _) in &file_scores {
702 let entry = file_to_tests.entry(file.clone()).or_default();
703 let tf = test_file.to_string();
704 if !entry.contains(&tf) {
705 entry.push(tf);
706 }
707 }
708 }
709 }
710
711 let recipe_edit: Vec<Value> = likely_edit_files
717 .iter()
718 .filter(|f| {
719 let file = f["file"].as_str().unwrap_or("");
720 let layer = f["layer"].as_str().unwrap_or("");
721 let names_file = query_names_file(&tokens, file);
722 let stem_words = split_camel_lower(
724 std::path::Path::new(file)
725 .file_stem()
726 .and_then(|n| n.to_str())
727 .unwrap_or(""),
728 );
729 let domain_overlap = stem_words
730 .iter()
731 .filter(|w| tokens.iter().any(|t| t == *w))
732 .count();
733 let has_domain_anchor = domain_overlap >= 2;
734 let surface_demote = is_rendering_surface(file) && !names_file;
736 let broad_demote =
738 broad_query && is_view_like_file(file, layer) && !names_file && !has_domain_anchor;
739 let anchor_missing_demote =
744 is_view_like_file(file, layer) && !names_file && domain_overlap == 0;
745 let demote = !wrong_layer_files.contains(file)
746 && (surface_demote || broad_demote || anchor_missing_demote);
747 f["file_role"].as_str() == Some("impl") && !wrong_layer_files.contains(file) && !demote
748 })
749 .map(|f| {
750 let file = f["file"].as_str().unwrap_or("");
752 let mut indexed = test_files_for_source(&all_test_file_paths, file);
753 if let Some(extra) = file_to_tests.get(file) {
754 for t in extra {
755 if !indexed.contains(t) {
756 indexed.push(t.clone());
757 }
758 }
759 }
760 let run_cmd = detect_test_command(file);
761 let mut entry = f.clone();
762 if let Some(obj) = entry.as_object_mut() {
763 let covered = if indexed.is_empty() {
766 json!([{"note": "none found — no test callers discovered for this file"}])
767 } else {
768 json!(indexed)
769 };
770 let run = if run_cmd.is_none() {
771 json!("none found — add test targets for this file")
772 } else {
773 json!(run_cmd)
774 };
775 obj.insert("covered_by_tests".into(), covered);
776 obj.insert("run_command".into(), run);
777 }
778 entry
779 })
780 .collect();
781 let recipe_reference: Vec<Value> = likely_edit_files
783 .iter()
784 .filter(|f| {
785 let file = f["file"].as_str().unwrap_or("");
786 let layer = f["layer"].as_str().unwrap_or("");
787 let names_file = query_names_file(&tokens, file);
788 let stem_words = split_camel_lower(
789 std::path::Path::new(file)
790 .file_stem()
791 .and_then(|n| n.to_str())
792 .unwrap_or(""),
793 );
794 let domain_overlap = stem_words
795 .iter()
796 .filter(|w| tokens.iter().any(|t| t == *w))
797 .count();
798 let has_domain_anchor = domain_overlap >= 2;
799 let surface_demote = is_rendering_surface(file) && !names_file;
800 let broad_demote =
801 broad_query && is_view_like_file(file, layer) && !names_file && !has_domain_anchor;
802 let anchor_missing_demote =
803 is_view_like_file(file, layer) && !names_file && domain_overlap == 0;
804 let view_demote = surface_demote || broad_demote || anchor_missing_demote;
805 matches!(f["file_role"].as_str(), Some("example") | Some("reference"))
806 || (f["file_role"].as_str() == Some("impl") && wrong_layer_files.contains(file))
807 || view_demote
808 })
809 .cloned()
810 .collect();
811 let all_candidate_files: Vec<Value> = likely_edit_files.clone();
819 let edit_file_set: HashSet<&str> = recipe_edit
820 .iter()
821 .filter_map(|e| e["file"].as_str())
822 .collect();
823 let likely_edit_files: Vec<Value> = likely_edit_files
824 .into_iter()
825 .filter(|e| {
826 e["file"]
827 .as_str()
828 .map_or(false, |f| edit_file_set.contains(f))
829 })
830 .collect();
831
832 let mut likely_edit_files = likely_edit_files;
842 let scores: Vec<f64> = likely_edit_files
843 .iter()
844 .map(|e| e["score"].as_f64().unwrap_or(0.0))
845 .collect();
846 let mut sorted_desc = scores.clone();
847 sorted_desc.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
848 let cliff_cut2 = agentstatedeveloper_core::cliff_cutoff_index(sorted_desc.iter().copied());
849 if cliff_cut2 < likely_edit_files.len() {
850 let cutoff = sorted_desc[cliff_cut2 - 1];
851 likely_edit_files.retain(|e| e["score"].as_f64().unwrap_or(0.0) >= cutoff);
852 }
853
854 let mut recipe_run: Vec<Value> = affected_tests
856 .iter()
857 .map(|t| {
858 let file = t["file"].as_str().unwrap_or("");
859 let cmd = detect_test_command(file);
860 json!({
861 "qname": t["qname"],
862 "file": file,
863 "covers_invariants": t["covers_invariants"],
864 "run_command": cmd,
865 })
866 })
867 .collect();
868 if recipe_run.is_empty() {
870 if let Some((_, top_file, _, _, _, _, _)) = file_scores.first() {
871 if let Some(cmd) = detect_test_command(top_file) {
872 recipe_run.push(json!({
873 "qname": Value::Null,
874 "file": top_file,
875 "covers_invariants": [],
876 "run_command": cmd,
877 }));
878 }
879 }
880 }
881 let mut recipe_manually_validate: Vec<Value> = validation_scenarios_ledger
883 .iter()
884 .map(|vs| {
885 let text = vs["scenario"].as_str().unwrap_or("");
886 let scenario = invariant_to_scenario(text);
887 json!({
888 "source": vs["source"],
889 "kind": "validation_scenario",
890 "step": scenario["step"],
891 "expected": scenario["expected"],
892 "raw": text,
893 })
894 })
895 .collect();
896 for inv in &design_invariants {
897 let text = inv["summary"].as_str().unwrap_or("");
898 let sl = text.to_lowercase();
899 if CONSTRAINT_WORDS.iter().any(|w| sl.contains(w)) {
900 let scenario = invariant_to_scenario(text);
901 recipe_manually_validate.push(json!({
902 "source": inv["source"],
903 "kind": "constraint_check",
904 "step": scenario["step"],
905 "expected": scenario["expected"],
906 "raw": text,
907 }));
908 }
909 }
910 for eff in &effects_summary {
911 let cat = eff["category"].as_str().unwrap_or("").to_lowercase();
912 recipe_manually_validate.push(json!({
913 "source": eff["source"],
914 "kind": "effect_check",
915 "step": format!("Execute the code path that triggers {}", cat),
916 "expected": format!("{} side-effect behaves correctly after change", cat),
917 "raw": format!("verify {} effect", cat),
918 }));
919 }
920 if test_gap {
926 let top_impl_source = file_scores
927 .first()
928 .map(|(_, f, _, _, _, _, _)| f.as_str())
929 .unwrap_or("");
930 let suggestion = proposed_test_path
931 .as_deref()
932 .unwrap_or("(no proposed path; see proposed_test_path)");
933 recipe_manually_validate.push(json!({
934 "source": top_impl_source,
935 "kind": "missing_test",
936 "step": format!(
937 "No test currently exercises this change set. Add a test \
938 covering the planned edit; suggested target: {suggestion}"
939 ),
940 "expected": "A new (or extended) test that exercises the changed code path and fails before the edit, passes after.",
941 "raw": "test_gap: affected_tests is empty",
942 }));
943 }
944 let classification_debug: Vec<Value> = all_candidate_files
949 .iter()
950 .map(|f| {
951 let file = f["file"].as_str().unwrap_or("");
952 let layer = f["layer"].as_str().unwrap_or("");
953 let file_role = f["file_role"].as_str().unwrap_or("unknown");
954 let names_file = query_names_file(&tokens, file);
955 let stem_words = split_camel_lower(
956 std::path::Path::new(file)
957 .file_stem()
958 .and_then(|n| n.to_str())
959 .unwrap_or(""),
960 );
961 let matched_stem_words: Vec<&str> = stem_words
962 .iter()
963 .filter(|w| tokens.iter().any(|t| t == *w))
964 .map(|w| w.as_str())
965 .collect();
966 let domain_overlap = matched_stem_words.len();
967 let has_domain_anchor = domain_overlap >= 2;
968 let surface_demoted = is_rendering_surface(file) && !names_file;
969 let broad_demoted =
970 broad_query && is_view_like_file(file, layer) && !names_file && !has_domain_anchor;
971 let anchor_missing_demoted =
972 is_view_like_file(file, layer) && !names_file && domain_overlap == 0;
973 let is_wrong_layer = wrong_layer_files.contains(file);
974 let rule_that_won = if file_role == "test" {
975 "test"
976 } else if is_wrong_layer {
977 "wrong-layer → reference"
978 } else if surface_demoted {
979 "surface → reference"
980 } else if broad_demoted {
981 "broad-query view → reference"
982 } else if anchor_missing_demoted {
983 "anchor-missing view → reference"
984 } else if file_role == "impl" {
985 "edit"
986 } else {
987 file_role
988 };
989 let rationale = make_file_rationale(
990 rule_that_won,
991 surface_demoted,
992 has_domain_anchor,
993 &matched_stem_words,
994 names_file,
995 broad_query,
996 );
997 json!({
998 "file": file,
999 "file_role": file_role,
1000 "surface_demoted": surface_demoted,
1001 "domain_anchor_retained": has_domain_anchor,
1002 "matched_stem_words": matched_stem_words,
1003 "domain_overlap": domain_overlap,
1004 "names_file": names_file,
1005 "is_wrong_layer": is_wrong_layer,
1006 "broad_query": broad_query,
1007 "rule_that_won": rule_that_won,
1008 "rationale": rationale,
1009 })
1010 })
1011 .collect();
1012
1013 let file_rationale: HashMap<String, String> = classification_debug
1015 .iter()
1016 .filter_map(|e| {
1017 let file = e["file"].as_str()?.to_string();
1018 let rat = e["rationale"].as_str()?.to_string();
1019 Some((file, rat))
1020 })
1021 .collect();
1022
1023 let mut rule_counts: std::collections::BTreeMap<String, usize> =
1027 std::collections::BTreeMap::new();
1028 for entry in &classification_debug {
1029 let rule = entry
1030 .get("rule_that_won")
1031 .and_then(Value::as_str)
1032 .unwrap_or("unknown");
1033 *rule_counts.entry(rule.to_string()).or_default() += 1;
1034 }
1035
1036 let trust_dq = compute_trust_score(&cfg.db_path);
1038 let (edit_confidence, edit_confidence_note) = match trust_dq.data_quality.state.as_str() {
1039 "clean_room" => (
1040 "reduced",
1041 "fresh workspace — classification relies on structural signals only; run `asd annotate-commit` after each commit to improve accuracy",
1042 ),
1043 "unannotated" => (
1044 "reduced",
1045 "no annotation data — classification uses structural signals only; ledger annotations boost accuracy",
1046 ),
1047 "degraded" => (
1048 "low",
1049 "possible state loss — ledger signals may be incomplete; verify with `asd trust`",
1050 ),
1051 _ => ("normal", ""),
1052 };
1053
1054 let mut classification_summary = serde_json::Map::new();
1055 for (k, v) in &rule_counts {
1056 classification_summary.insert(k.clone(), json!(*v));
1057 }
1058 classification_summary.insert("edit_confidence".into(), json!(edit_confidence));
1059 if !edit_confidence_note.is_empty() {
1060 classification_summary.insert("edit_confidence_note".into(), json!(edit_confidence_note));
1061 }
1062 let classification_summary = Value::Object(classification_summary);
1063
1064 let likely_edit_files: Vec<Value> = likely_edit_files
1066 .into_iter()
1067 .map(|mut f| {
1068 if let Some(obj) = f.as_object_mut() {
1069 let file = obj
1070 .get("file")
1071 .and_then(Value::as_str)
1072 .unwrap_or("")
1073 .to_string();
1074 if let Some(rat) = file_rationale.get(&file) {
1075 obj.insert("rationale".into(), json!(rat));
1076 }
1077 }
1078 f
1079 })
1080 .collect();
1081
1082 let recipe_edit: Vec<Value> = recipe_edit
1084 .into_iter()
1085 .map(|mut f| {
1086 if let Some(obj) = f.as_object_mut() {
1087 let file = obj
1088 .get("file")
1089 .and_then(Value::as_str)
1090 .unwrap_or("")
1091 .to_string();
1092 if let Some(rat) = file_rationale.get(&file) {
1093 obj.entry("rationale".to_string())
1094 .or_insert_with(|| json!(rat));
1095 }
1096 }
1097 f
1098 })
1099 .collect();
1100 let recipe_reference: Vec<Value> = recipe_reference
1101 .into_iter()
1102 .map(|mut f| {
1103 if let Some(obj) = f.as_object_mut() {
1104 let file = obj
1105 .get("file")
1106 .and_then(Value::as_str)
1107 .unwrap_or("")
1108 .to_string();
1109 if let Some(rat) = file_rationale.get(&file) {
1110 obj.entry("rationale".to_string())
1111 .or_insert_with(|| json!(rat));
1112 }
1113 }
1114 f
1115 })
1116 .collect();
1117
1118 let safe_change_recipe = agentstatedeveloper_core::drop_empty_recursive(json!({
1126 "inspect": recipe_inspect,
1127 "preserve": recipe_preserve,
1128 "edit": recipe_edit,
1129 "reference_only": recipe_reference,
1130 "run": recipe_run,
1131 "manually_validate": recipe_manually_validate,
1132 "blast_radius": blast_radius,
1133 "likely_omitted_files": likely_omitted_files,
1134 }));
1135
1136 let edit_file_names: Vec<String> = likely_edit_files
1138 .iter()
1139 .filter_map(|v| v.get("file").and_then(Value::as_str).map(|s| s.to_string()))
1140 .collect();
1141 let scoped_suggestions_pc: Vec<String> = if !ambiguous_terms.is_empty() {
1142 agentstatedeveloper_core::suggest_scoped_queries(
1143 &tokens,
1144 &ambiguous_terms,
1145 &edit_file_names,
1146 )
1147 } else {
1148 vec![]
1149 };
1150 let uncertainty = compute_uncertainty(
1152 &tokens,
1153 &ambiguous_terms,
1154 &possible_misses,
1155 file_scores.len(),
1156 &scoped_suggestions_pc,
1157 engine.fts.as_ref(),
1158 Some(trust_dq.data_quality.state.as_str()),
1159 );
1160
1161 let focus = intent_focus(intent);
1162 let ctx_context_val = match (auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()) {
1163 (None, None) => Value::Null,
1164 _ => json!({
1165 "plan": auto_ctx_plan,
1166 "task": auto_ctx_task,
1167 "injected": ctx_text.is_some(),
1168 }),
1169 };
1170 let thinking_qnames: Vec<String> = likely_edit_files
1177 .iter()
1178 .filter_map(|f| {
1179 f.get("top_symbol")
1180 .and_then(Value::as_str)
1181 .map(String::from)
1182 })
1183 .collect();
1184 let thinking_floor = args
1185 .thinking_floor
1186 .unwrap_or(agentstatedeveloper_core::thinking::DEFAULT_CONFIDENCE_FLOOR);
1187 let pt = agentstatedeveloper_core::thinking::gather_prior_thinking(
1188 &engine,
1189 &thinking_qnames,
1190 thinking_floor,
1191 );
1192 let prior_thinking = pt.entries;
1193 let thinking_summary = serde_json::to_value(&pt.summary).unwrap_or(Value::Null);
1194
1195 let stale_classified = agentstatedeveloper_core::stale_warning_classified(
1197 &cfg.db_path,
1198 agentstatedeveloper_core::SOFT_STALE_THRESHOLD_SECS,
1199 );
1200 let stale_msg = stale_classified
1201 .as_ref()
1202 .map(|w| Value::String(w.message.clone()))
1203 .unwrap_or(Value::Null);
1204 let stale_severity = stale_classified
1205 .as_ref()
1206 .map(|w| serde_json::to_value(w.severity).unwrap_or(Value::Null))
1207 .unwrap_or(Value::Null);
1208
1209 let feedback_summary = {
1215 let mut m = serde_json::Map::new();
1216 if feedback_metrics.entries_applied > 0 {
1217 m.insert(
1218 "entries_applied".into(),
1219 json!(feedback_metrics.entries_applied),
1220 );
1221 }
1222 if feedback_metrics.suppressed > 0 {
1223 m.insert("suppressed".into(), json!(feedback_metrics.suppressed));
1224 }
1225 if feedback_metrics.preserved_useful_siblings > 0 {
1226 m.insert(
1227 "preserved_useful_siblings".into(),
1228 json!(feedback_metrics.preserved_useful_siblings),
1229 );
1230 }
1231 if feedback_metrics.boosted > 0 {
1232 m.insert("boosted".into(), json!(feedback_metrics.boosted));
1233 }
1234 if feedback_metrics.recurring_fp_suppressed > 0 {
1235 m.insert(
1236 "recurring_fp_suppressed".into(),
1237 json!(feedback_metrics.recurring_fp_suppressed),
1238 );
1239 }
1240 if !feedback_metrics.rules_applied.is_empty() {
1241 m.insert(
1242 "rules_applied".into(),
1243 json!(feedback_metrics.rules_applied),
1244 );
1245 }
1246 Value::Object(m)
1247 };
1248
1249 let round3 = |x: f64| (x * 1000.0).round() / 1000.0;
1257 let stage_dump = {
1258 let low_conf = matches!(uncertainty.level.as_str(), "high" | "critical");
1259 if low_conf || likely_edit_files.is_empty() {
1260 let stage1: Vec<Value> = candidates
1261 .iter()
1262 .map(|(score, qname)| json!({ "score": round3(*score), "qname": qname }))
1263 .collect();
1264 let stage2: Vec<Value> = file_scores
1265 .iter()
1266 .map(|(score, file, layer, _days, _hot, sym, why)| {
1267 json!({
1268 "file": file,
1269 "score": round3(*score),
1270 "layer": layer,
1271 "top_symbol": sym,
1272 "why": why,
1273 })
1274 })
1275 .collect();
1276 let demoted = safe_change_recipe
1277 .get("reference_only")
1278 .cloned()
1279 .unwrap_or_else(|| json!([]));
1280 json!({
1281 "triggered_by": if likely_edit_files.is_empty() {
1282 "empty_edit_files"
1283 } else {
1284 "low_confidence"
1285 },
1286 "uncertainty_level": uncertainty.level,
1287 "note": "Agents normally see only likely_edit_files (the final stage-3 list). \
1288 This result is low-confidence or empty, so here is what the earlier \
1289 filtering stages ranked and cut. A file missing from likely_edit_files \
1290 but present below may have been demoted, not absent.",
1291 "stage1_symbol_candidates": { "count": candidates.len(), "ranked": stage1 },
1292 "stage2_surviving_files": stage2,
1293 "stage3_demoted_to_reference_only": demoted,
1294 })
1295 } else {
1296 Value::Null
1297 }
1298 };
1299
1300 let out = json!({
1301 "description": args.description,
1302 "task_context": args.task_context,
1303 "ctx_context": ctx_context_val,
1304 "stale": stale_msg,
1305 "stale_severity": stale_severity,
1306 "intent": if intent.is_empty() { Value::Null } else { json!(intent) },
1307 "focus": if focus.is_empty() { Value::Null } else { json!(focus) },
1308 "uncertainty": uncertainty.to_json(),
1309 "ambiguous_terms": ambiguous_terms,
1310 "possible_misses": possible_misses,
1311 "scope_narrowed": scope_narrowed,
1312 "feedback_summary": feedback_summary,
1313 "safe_change_recipe": safe_change_recipe,
1314 "design_invariants": design_invariants,
1315 "known_hazards": known_hazards,
1316 "prior_thinking": prior_thinking,
1317 "thinking_summary": thinking_summary,
1318 "validation_scenarios": validation_scenarios_ledger,
1319 "entry_points": { "by_layer": ordered_by_layer },
1320 "likely_edit_files": likely_edit_files,
1321 "stage_dump": stage_dump,
1322 "affected_tests": affected_tests,
1323 "test_gap": test_gap,
1324 "proposed_test_path": proposed_test_path,
1325 "proposed_test_stub": proposed_test_stub,
1326 "suggested_test_coverage": suggested_test_coverage,
1327 "scenario_tests": scenario_tests,
1328 "stale_symbols": stale_symbols,
1329 "effects_summary": effects_summary,
1330 "recently_touched": recently_touched,
1331 "classification_summary": classification_summary,
1332 "classification_debug": if args.debug_classification { json!(classification_debug) } else { Value::Null },
1333 "edit_precision_metrics": if let Some(ref sha) = args.check_commit {
1334 compute_edit_precision(&likely_edit_files, sha)
1335 } else {
1336 Value::Null
1337 },
1338 });
1339 let out = agentstatedeveloper_core::drop_empty_top_level(out);
1354
1355 let (out, compact_for_agent) = if args.agent {
1356 let max_list = (args.agent_budget / 500).max(3).min(20);
1357 let mut trimmed = trim_for_agent(&out, max_list);
1358
1359 if let Some(obj) = trimmed.as_object_mut() {
1363 obj.remove("stale");
1364 obj.remove("description");
1366 obj.remove("task_context");
1367 obj.remove("ctx_context");
1368 }
1369
1370 let trimmed = agentstatedeveloper_core::drop_empty_top_level(trimmed);
1374
1375 let compact = serde_json::to_string(&trimmed)?;
1376 let token_est = estimate_tokens(&compact);
1377 let mut v = trimmed;
1378 if let Some(obj) = v.as_object_mut() {
1379 obj.insert("token_estimate".into(), json!(token_est));
1380 }
1381 (v, true)
1382 } else {
1383 (out, false)
1384 };
1385 let json_str = if compact_for_agent {
1386 serde_json::to_string(&out)?
1387 } else {
1388 serde_json::to_string_pretty(&out)?
1389 };
1390 println!("{}", json_str);
1391 Ok(())
1392}
1393
1394fn detect_test_command(file: &str) -> Option<String> {
1408 use std::path::Path;
1409 let p = Path::new(file);
1410 let mut dir = p.parent()?;
1412 loop {
1413 if dir.join("Cargo.toml").exists() {
1414 let pkg_name = std::fs::read_to_string(dir.join("Cargo.toml"))
1416 .ok()
1417 .and_then(|s| {
1418 s.lines()
1419 .skip_while(|l| !l.trim_start().starts_with("[package]"))
1420 .find(|l| l.trim_start().starts_with("name"))
1421 .and_then(|l| l.splitn(2, '=').nth(1))
1422 .map(|v| v.trim().trim_matches('"').to_string())
1423 });
1424 return Some(match pkg_name {
1425 Some(name) => format!("cargo test -p {}", name),
1426 None => "cargo test".to_string(),
1427 });
1428 }
1429 if dir.join("package.json").exists() {
1430 let has_yarn = dir.join("yarn.lock").exists();
1431 let has_pnpm = dir.join("pnpm-lock.yaml").exists();
1432 return Some(if has_pnpm {
1433 "pnpm test".to_string()
1434 } else if has_yarn {
1435 "yarn test".to_string()
1436 } else {
1437 "npm test".to_string()
1438 });
1439 }
1440 if dir.join("pyproject.toml").exists()
1441 || dir.join("setup.py").exists()
1442 || dir.join("pytest.ini").exists()
1443 || dir.join("setup.cfg").exists()
1444 {
1445 let rel = p.strip_prefix(dir).unwrap_or(p).to_string_lossy();
1446 return Some(format!("pytest {}", rel));
1447 }
1448 if dir.join("Gemfile").exists() {
1449 let rel = p.strip_prefix(dir).unwrap_or(p).to_string_lossy();
1450 return Some(format!("bundle exec rspec {}", rel));
1451 }
1452 if dir.join("build.gradle").exists() || dir.join("build.gradle.kts").exists() {
1453 return Some("./gradlew test".to_string());
1454 }
1455 if dir.join("pom.xml").exists() {
1456 return Some("mvn test".to_string());
1457 }
1458 if dir.join("go.mod").exists() {
1459 let pkg_dir = p
1460 .parent()
1461 .map(|d| d.to_string_lossy().to_string())
1462 .unwrap_or_else(|| ".".to_string());
1463 return Some(format!("go test {}/...", pkg_dir));
1464 }
1465 if dir.join("Makefile").exists() || dir.join("makefile").exists() {
1466 return Some("make test".to_string());
1467 }
1468 match dir.parent() {
1469 Some(parent) => dir = parent,
1470 None => break,
1471 }
1472 }
1473 None
1474}
1475
1476const GENERIC_FILE_STEMS: &[&str] = &[
1487 "playhead",
1488 "state",
1489 "update",
1490 "position",
1491 "value",
1492 "cursor",
1493 "progress",
1494 "indicator",
1495 "status",
1496 "mode",
1497 "flag",
1498 "current",
1499 "local",
1500];
1501
1502fn is_rendering_surface(file: &str) -> bool {
1505 let name = std::path::Path::new(file)
1506 .file_stem()
1507 .and_then(|n| n.to_str())
1508 .unwrap_or(file);
1509 name.ends_with("Canvas")
1510 || name.ends_with("Overlay")
1511 || name.ends_with("Surface")
1512 || name.ends_with("Roll")
1513 || name.ends_with("Sheet")
1514 || name.ends_with("Layer")
1515 || name.ends_with("Renderer")
1516 || name.ends_with("Drawable")
1517}
1518
1519fn is_view_like_file(file: &str, layer: &str) -> bool {
1520 if layer == "view" {
1521 return true;
1522 }
1523 let name = std::path::Path::new(file)
1524 .file_stem()
1525 .and_then(|n| n.to_str())
1526 .unwrap_or(file);
1527 if name.ends_with("View")
1529 || name.ends_with("ViewController")
1530 || name.ends_with("Screen")
1531 || name.ends_with("Widget")
1532 || name.ends_with("Panel")
1533 || name.ends_with("Cell")
1534 || name.ends_with("Button")
1535 || name.ends_with("Label")
1536 || name.ends_with("Row")
1537 || name.ends_with("Canvas")
1538 || name.ends_with("Overlay")
1539 || name.ends_with("Surface")
1540 || name.ends_with("Roll")
1541 || name.ends_with("Sheet")
1542 || name.ends_with("Layer")
1543 || name.ends_with("Renderer")
1544 || name.ends_with("Drawable")
1545 || name.contains("ViewController")
1546 || name.contains("Renderer")
1547 || name.ends_with("Page")
1548 || name.ends_with("Fragment")
1549 {
1550 return true;
1551 }
1552 if matches!(layer, "view" | "viewmodel" | "ui") {
1555 let name_lower = name.to_lowercase();
1556 let stem_words = split_camel_lower(&name_lower);
1557 if !stem_words.is_empty()
1558 && stem_words
1559 .iter()
1560 .all(|w| GENERIC_FILE_STEMS.iter().any(|g| *g == w.as_str()))
1561 {
1562 return true;
1563 }
1564 }
1565 false
1566}
1567
1568fn query_names_file(query_tokens: &[String], file: &str) -> bool {
1571 let name = std::path::Path::new(file)
1572 .file_stem()
1573 .and_then(|n| n.to_str())
1574 .unwrap_or("")
1575 .to_lowercase();
1576 if name.is_empty() {
1577 return false;
1578 }
1579 let stem_words = split_camel_lower(&name);
1580 let matches = stem_words
1581 .iter()
1582 .filter(|w| query_tokens.iter().any(|t| t == *w))
1583 .count();
1584 matches >= 2.min(stem_words.len())
1585}
1586
1587fn split_camel_lower(s: &str) -> Vec<String> {
1589 let mut words: Vec<String> = Vec::new();
1590 let mut current = String::new();
1591 for ch in s.chars() {
1592 if ch.is_uppercase() && !current.is_empty() {
1593 words.push(current.to_lowercase());
1594 current = ch.to_string();
1595 } else if ch == '_' || ch == '-' || ch == '.' {
1596 if !current.is_empty() {
1597 words.push(current.to_lowercase());
1598 current = String::new();
1599 }
1600 } else {
1601 current.push(ch);
1602 }
1603 }
1604 if !current.is_empty() {
1605 words.push(current.to_lowercase());
1606 }
1607 words
1608}
1609
1610fn extract_subject(text: &str) -> String {
1617 const SKIP: &[&str] = &[
1618 "the",
1619 "a",
1620 "an",
1621 "this",
1622 "that",
1623 "it",
1624 "must",
1625 "never",
1626 "cannot",
1627 "shall",
1628 "always",
1629 "only",
1630 "not",
1631 "no",
1632 "should",
1633 "will",
1634 "would",
1635 "is",
1636 "are",
1637 "be",
1638 "been",
1639 "require",
1640 "ensure",
1641 "prevent",
1642 "guarantee",
1643 "invariant",
1644 ];
1645 text.split_whitespace()
1646 .filter(|w| {
1647 let wl = w.to_lowercase();
1648 let base: String = wl.chars().filter(|c| c.is_alphabetic()).collect();
1649 !SKIP.contains(&base.as_str())
1650 })
1651 .take(5)
1652 .collect::<Vec<_>>()
1653 .join(" ")
1654}
1655
1656fn invariant_to_scenario(text: &str) -> Value {
1657 let tl = text.to_lowercase();
1658 let subject = extract_subject(text);
1659 let subj = if subject.is_empty() {
1660 text.to_string()
1661 } else {
1662 subject
1663 };
1664
1665 let (step, expected) = if tl.contains("must not")
1666 || tl.contains("never")
1667 || tl.contains("cannot")
1668 || tl.contains("forbidden")
1669 {
1670 (
1671 format!("Trigger conditions that would violate: {}", subj),
1672 format!(
1673 "System rejects or prevents the violation — «{}» holds",
1674 text
1675 ),
1676 )
1677 } else if tl.contains("must") || tl.contains("shall") || tl.contains("always") {
1678 (
1679 format!("Exercise the happy path for: {}", subj),
1680 format!("Observe that «{}» is satisfied after execution", text),
1681 )
1682 } else if tl.contains("ensure") || tl.contains("guarantee") {
1683 (
1684 format!("Run the scenario that exercises: {}", subj),
1685 format!("Post-condition confirmed: «{}»", text),
1686 )
1687 } else if tl.contains("require") {
1688 (
1689 format!(
1690 "Attempt to call without satisfying the precondition for: {}",
1691 subj
1692 ),
1693 format!("Guard fires or error raised before violating «{}»", text),
1694 )
1695 } else if tl.contains("only") || tl.contains("prevent") {
1696 (
1697 format!("Attempt a bypass around: {}", subj),
1698 format!("Prevention still enforced — «{}»", text),
1699 )
1700 } else {
1701 (
1702 format!("Verify the observable behaviour of: {}", subj),
1703 format!("Constraint «{}» holds after the change", text),
1704 )
1705 };
1706 json!({ "step": step, "expected": expected })
1707}
1708
1709fn make_file_rationale(
1718 rule: &str,
1719 surface_demoted: bool,
1720 domain_anchor: bool,
1721 matched_stems: &[&str],
1722 names_file: bool,
1723 broad_query: bool,
1724) -> String {
1725 match rule {
1726 "edit" if names_file => "edit: query names this file directly".to_string(),
1727 "edit" if domain_anchor => {
1728 format!(
1729 "edit: domain-anchored — {} query token(s) match file name ({})",
1730 matched_stems.len(),
1731 matched_stems.join(", ")
1732 )
1733 }
1734 "edit" if !matched_stems.is_empty() => {
1735 format!(
1736 "edit: impl file matching query token '{}'",
1737 matched_stems[0]
1738 )
1739 }
1740 "edit" => "edit: impl file in query scope".to_string(),
1741
1742 r if r.contains("surface") => {
1743 "reference: rendering surface — read-only layer (WaveformCanvas, SheetMusicView, etc.)"
1744 .to_string()
1745 }
1746 r if r.contains("broad-query view") => {
1747 if domain_anchor {
1748 format!(
1749 "reference: view layer but domain-anchored ({})",
1750 matched_stems.join(", ")
1751 )
1752 } else {
1753 format!(
1754 "reference: view layer on broad query — needs ≥2 matching tokens to enter edit (matched: {})",
1755 matched_stems.len()
1756 )
1757 }
1758 }
1759 r if r.contains("anchor-missing") => {
1760 "reference: view/surface layer with no domain overlap — unrelated rendering file"
1761 .to_string()
1762 }
1763 r if r.contains("wrong-layer") => {
1764 "reference: wrong layer for this change type — structural mismatch".to_string()
1765 }
1766
1767 "example" | "reference" => "reference: documentation or example file".to_string(),
1768 "test" => "test: run to validate this change".to_string(),
1769
1770 _ => {
1771 if broad_query {
1773 format!("reference: broad query heuristic — {}", rule)
1774 } else {
1775 format!(
1776 "classified as: {} (surface_demoted={}, domain_anchor={})",
1777 rule, surface_demoted, domain_anchor
1778 )
1779 }
1780 }
1781 }
1782}
1783
1784fn compute_edit_precision(likely_edit_files: &[Value], sha: &str) -> Value {
1792 let predicted_files: Vec<String> = likely_edit_files
1793 .iter()
1794 .filter_map(|v| v.get("file").and_then(Value::as_str).map(|s| s.to_string()))
1795 .collect();
1796 let predicted_files = &predicted_files;
1797 let output = std::process::Command::new("git")
1798 .args(["diff-tree", "--no-commit-id", "-r", "--name-only", sha])
1799 .output();
1800 let output = match output {
1801 Ok(o) if o.status.success() => o,
1802 Ok(o) => {
1803 let err = String::from_utf8_lossy(&o.stderr);
1804 return json!({ "error": format!("git diff-tree failed: {}", err.trim()) });
1805 }
1806 Err(e) => return json!({ "error": format!("git not available: {}", e) }),
1807 };
1808 let actual_files: Vec<String> = String::from_utf8_lossy(&output.stdout)
1809 .lines()
1810 .map(|l| l.trim().to_string())
1811 .filter(|l| !l.is_empty())
1812 .collect();
1813
1814 if actual_files.is_empty() {
1815 return json!({
1816 "sha": sha,
1817 "error": "no files changed in commit (or commit not found)",
1818 });
1819 }
1820
1821 let is_match = |pred: &str, actual: &str| -> bool {
1824 actual.ends_with(pred) || pred.ends_with(actual) || actual == pred
1825 };
1826
1827 let mut tp: Vec<String> = Vec::new();
1828 let mut fp: Vec<String> = Vec::new();
1829 let mut fn_: Vec<String> = Vec::new();
1830
1831 for pred in predicted_files {
1832 if actual_files.iter().any(|a| is_match(pred, a)) {
1833 tp.push(pred.clone());
1834 } else {
1835 fp.push(pred.clone());
1836 }
1837 }
1838 for actual in &actual_files {
1839 if !predicted_files.iter().any(|p| is_match(p, actual)) {
1840 fn_.push(actual.clone());
1841 }
1842 }
1843
1844 let precision = if tp.len() + fp.len() > 0 {
1845 tp.len() as f64 / (tp.len() + fp.len()) as f64
1846 } else {
1847 0.0
1848 };
1849 let recall = if tp.len() + fn_.len() > 0 {
1850 tp.len() as f64 / (tp.len() + fn_.len()) as f64
1851 } else {
1852 0.0
1853 };
1854 let f1 = if precision + recall > 0.0 {
1855 2.0 * precision * recall / (precision + recall)
1856 } else {
1857 0.0
1858 };
1859
1860 json!({
1861 "sha": sha,
1862 "predicted_count": predicted_files.len(),
1863 "actual_count": actual_files.len(),
1864 "true_positives": tp,
1865 "false_positives": fp,
1866 "false_negatives": fn_,
1867 "precision": (precision * 1000.0).round() / 1000.0,
1868 "recall": (recall * 1000.0).round() / 1000.0,
1869 "f1": (f1 * 1000.0).round() / 1000.0,
1870 })
1871}
1872
1873fn gather_affected_tests(
1889 engine: &Engine,
1890 index_store: &AsgIndexStore,
1891 id_map: &HashMap<String, Symbol>,
1892 top_sym_id: Option<&str>,
1893 test_depth: usize,
1894 design_invariants: &[Value],
1895) -> Vec<Value> {
1896 let mut out: Vec<Value> = Vec::new();
1897 let Some(start_id) = top_sym_id else {
1898 return out;
1899 };
1900 let mut visited: HashSet<String> = HashSet::new();
1901 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
1902 let mut seen_test_names: HashSet<String> = HashSet::new();
1903 visited.insert(start_id.to_string());
1904 queue.push_back((start_id.to_string(), 0));
1905 while let Some((sid, depth)) = queue.pop_front() {
1906 if depth >= test_depth {
1907 continue;
1908 }
1909 let callers = index_store
1910 .get_callers(&engine.ref_name, &sid)
1911 .unwrap_or_default();
1912 for cid in callers {
1913 if visited.contains(&cid) {
1914 continue;
1915 }
1916 visited.insert(cid.clone());
1917 if let Some(s) = id_map.get(&cid) {
1918 if symbol_tier(&s.file) == 2 && seen_test_names.insert(s.qname.clone()) {
1919 let qname_words: Vec<String> = s
1925 .qname
1926 .split(|c: char| !c.is_alphabetic())
1927 .filter(|t| t.len() > 2)
1928 .map(|t| t.to_lowercase())
1929 .collect();
1930 let doc_words: Vec<String> = s
1931 .doc
1932 .as_deref()
1933 .unwrap_or("")
1934 .split(|c: char| !c.is_alphabetic())
1935 .filter(|t| t.len() > 2)
1936 .map(|t| t.to_lowercase())
1937 .collect();
1938 let test_tokens: Vec<&str> = qname_words
1939 .iter()
1940 .chain(doc_words.iter())
1941 .map(|s| s.as_str())
1942 .collect();
1943 let covers: Vec<&str> = design_invariants
1944 .iter()
1945 .filter_map(|inv| inv.get("summary").and_then(Value::as_str))
1946 .filter(|summary| {
1947 let sl = summary.to_lowercase();
1948 test_tokens.iter().any(|t| sl.contains(*t))
1949 })
1950 .collect();
1951 out.push(json!({
1952 "qname": s.qname,
1953 "file": s.file,
1954 "line": s.start.line,
1955 "covers_invariants": covers,
1956 }));
1957 }
1958 if depth + 1 < test_depth {
1959 queue.push_back((cid, depth + 1));
1960 }
1961 }
1962 }
1963 }
1964 out
1965}
1966
1967fn compute_blast_radius(
1975 engine: &Engine,
1976 index_store: &AsgIndexStore,
1977 id_map: &HashMap<String, Symbol>,
1978 candidates: &[(f64, String)],
1979 layer_overrides: &[(String, String)],
1980) -> Value {
1981 let mut caller_layers: HashMap<String, usize> = HashMap::new();
1982 let mut callee_layers: HashMap<String, usize> = HashMap::new();
1983 let mut total_callers = 0usize;
1984 let mut total_callees = 0usize;
1985 let top_sids: Vec<String> = candidates
1986 .iter()
1987 .take(5)
1988 .filter_map(|(_, q)| {
1989 index_store
1990 .get_symbol_by_qname(&engine.ref_name, q)
1991 .ok()
1992 .flatten()
1993 })
1994 .map(|s| s.symbol_id)
1995 .collect();
1996
1997 let mut top_caller_chains: Vec<Vec<String>> = Vec::new();
2000
2001 for sid in &top_sids {
2002 let anchor_qname = id_map.get(sid).map(|s| s.qname.clone()).unwrap_or_default();
2003 let mut visited: HashSet<String> = HashSet::new();
2004 let mut q: VecDeque<(String, Vec<String>)> = VecDeque::new();
2005 visited.insert(sid.clone());
2006 q.push_back((sid.clone(), vec![anchor_qname.clone()]));
2007 while let Some((cid, path)) = q.pop_front() {
2008 if path.len() > 4 {
2009 continue;
2010 }
2011 for caller_id in index_store
2012 .get_callers(&engine.ref_name, &cid)
2013 .unwrap_or_default()
2014 {
2015 if visited.insert(caller_id.clone()) {
2016 if let Some(sym) = id_map.get(&caller_id) {
2017 let tier = symbol_tier(&sym.file);
2018 let layer =
2019 classify_layer_sym(&sym.file, &sym.qname, tier, layer_overrides);
2020 *caller_layers.entry(layer.to_string()).or_default() += 1;
2021 total_callers += 1;
2022 let mut new_path = vec![sym.qname.clone()];
2023 new_path.extend_from_slice(&path);
2024 if top_caller_chains.len() < 5 {
2025 top_caller_chains.push(new_path.clone());
2026 }
2027 q.push_back((caller_id, new_path));
2028 }
2029 }
2030 }
2031 }
2032 for callee_id in index_store
2033 .get_callees(&engine.ref_name, sid)
2034 .unwrap_or_default()
2035 {
2036 if let Some(sym) = id_map.get(&callee_id) {
2037 let tier = symbol_tier(&sym.file);
2038 let layer = classify_layer_sym(&sym.file, &sym.qname, tier, layer_overrides);
2039 *callee_layers.entry(layer.to_string()).or_default() += 1;
2040 total_callees += 1;
2041 }
2042 }
2043 }
2044 json!({
2045 "total_callers": total_callers,
2046 "total_callees": total_callees,
2047 "caller_layer_distribution": caller_layers,
2048 "callee_layer_distribution": callee_layers,
2049 "top_caller_chains": top_caller_chains,
2050 })
2051}
2052
2053fn detect_test_gap(
2059 file_scores: &[(f64, String, String, Option<f64>, bool, String, String)],
2060 affected_tests: &[Value],
2061 all_test_file_paths: &[String],
2062) -> (bool, Option<String>, Option<String>) {
2063 let test_gap = affected_tests.is_empty();
2064 if !test_gap {
2065 return (false, None, None);
2066 }
2067 let source = file_scores
2068 .first()
2069 .map(|(_, f, _, _, _, _, _)| f.as_str())
2070 .unwrap_or("");
2071 if source.is_empty() {
2072 return (true, None, None);
2073 }
2074 let real = test_files_for_source(all_test_file_paths, source);
2075 let proposed_test_path = if real.is_empty() {
2076 Some(format!(
2077 "no known test target (suggested: {})",
2078 propose_test_path(source)
2079 ))
2080 } else {
2081 Some(real.join(", "))
2082 };
2083 let symbol = file_scores
2085 .first()
2086 .map(|(_, _, _, _, _, qname, _)| qname.as_str())
2087 .unwrap_or("change");
2088 let proposed_test_stub = Some(propose_test_stub(source, symbol));
2089 (true, proposed_test_path, proposed_test_stub)
2090}