1use std::io::Write as _;
7use std::path::{Path, PathBuf};
8use std::time::{Instant, SystemTime};
9
10use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
11use clap_complete::Shell;
12use serde::{Deserialize, Serialize};
13
14use crate::cli::output::{format_size, progress, progress_done, Styled};
15use crate::engine::query::{
16 CallDirection, CallGraphParams, CouplingParams, DeadCodeParams, DependencyParams,
17 HotspotParams, ImpactParams, MatchMode, ProphecyParams, QueryEngine, SimilarityParams,
18 StabilityResult, SymbolLookupParams, TestGapParams,
19};
20use crate::format::{AcbReader, AcbWriter};
21use crate::graph::CodeGraph;
22use crate::grounding::{Grounded, GroundingEngine, GroundingResult};
23use crate::parse::parser::{ParseOptions, Parser as AcbParser};
24use crate::semantic::analyzer::{AnalyzeOptions, SemanticAnalyzer};
25use crate::types::FileHeader;
26use crate::workspace::{ContextRole, WorkspaceManager};
27
28const DEFAULT_STORAGE_BUDGET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
30const DEFAULT_STORAGE_BUDGET_HORIZON_YEARS: u32 = 20;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34struct WorkspaceContextState {
35 path: String,
36 role: String,
37 language: Option<String>,
38}
39
40#[derive(Debug, Default, Serialize, Deserialize)]
41struct WorkspaceState {
42 workspaces: std::collections::HashMap<String, Vec<WorkspaceContextState>>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum StorageBudgetMode {
47 AutoRollup,
48 Warn,
49 Off,
50}
51
52impl StorageBudgetMode {
53 fn from_env(name: &str) -> Self {
54 let raw = read_env_string(name).unwrap_or_else(|| "auto-rollup".to_string());
55 match raw.trim().to_ascii_lowercase().as_str() {
56 "warn" => Self::Warn,
57 "off" | "disabled" | "none" => Self::Off,
58 _ => Self::AutoRollup,
59 }
60 }
61
62 fn as_str(self) -> &'static str {
63 match self {
64 Self::AutoRollup => "auto-rollup",
65 Self::Warn => "warn",
66 Self::Off => "off",
67 }
68 }
69}
70
71#[derive(Parser)]
77#[command(
78 name = "acb",
79 about = "AgenticCodebase \u{2014} Semantic code compiler for AI agents",
80 long_about = "AgenticCodebase compiles multi-language codebases into navigable concept \
81 graphs that AI agents can query. Supports Python, Rust, TypeScript, and Go.\n\n\
82 Quick start:\n\
83 \x20 acb compile ./my-project # build a graph\n\
84 \x20 acb info my-project.acb # inspect the graph\n\
85 \x20 acb query my-project.acb symbol --name UserService\n\
86 \x20 acb query my-project.acb impact --unit-id 42\n\n\
87 For AI agent integration, use the companion MCP server: agentic-codebase-mcp",
88 after_help = "Run 'acb <command> --help' for details on a specific command.\n\
89 Set ACB_LOG=debug for verbose tracing. Set NO_COLOR=1 to disable colors.",
90 version
91)]
92pub struct Cli {
93 #[command(subcommand)]
94 pub command: Option<Command>,
95
96 #[arg(long, short = 'f', default_value = "text", global = true)]
98 pub format: OutputFormat,
99
100 #[arg(long, short = 'v', global = true)]
102 pub verbose: bool,
103
104 #[arg(long, short = 'q', global = true)]
106 pub quiet: bool,
107}
108
109#[derive(Clone, ValueEnum)]
111pub enum OutputFormat {
112 Text,
114 Json,
116}
117
118#[derive(Subcommand)]
120pub enum Command {
121 Init {
123 file: PathBuf,
125 },
126
127 #[command(alias = "build")]
138 Compile {
139 path: PathBuf,
141
142 #[arg(short, long)]
144 output: Option<PathBuf>,
145
146 #[arg(long, short = 'e')]
148 exclude: Vec<String>,
149
150 #[arg(long, default_value_t = true)]
152 include_tests: bool,
153
154 #[arg(long)]
156 coverage_report: Option<PathBuf>,
157 },
158
159 #[command(alias = "stat")]
168 Info {
169 file: PathBuf,
171 },
172
173 #[command(alias = "q")]
195 Query {
196 file: PathBuf,
198
199 query_type: String,
202
203 #[arg(long, short = 'n')]
205 name: Option<String>,
206
207 #[arg(long, short = 'u')]
209 unit_id: Option<u64>,
210
211 #[arg(long, short = 'd', default_value_t = 3)]
213 depth: u32,
214
215 #[arg(long, short = 'l', default_value_t = 20)]
217 limit: usize,
218 },
219
220 Get {
229 file: PathBuf,
231
232 unit_id: u64,
234 },
235
236 Completions {
246 shell: Shell,
248 },
249
250 Health {
252 file: PathBuf,
254
255 #[arg(long, short = 'l', default_value_t = 10)]
257 limit: usize,
258 },
259
260 Gate {
262 file: PathBuf,
264
265 #[arg(long, short = 'u')]
267 unit_id: u64,
268
269 #[arg(long, default_value_t = 0.60)]
271 max_risk: f32,
272
273 #[arg(long, short = 'd', default_value_t = 3)]
275 depth: u32,
276
277 #[arg(long, default_value_t = true)]
279 require_tests: bool,
280 },
281
282 Budget {
284 file: PathBuf,
286
287 #[arg(long, default_value_t = DEFAULT_STORAGE_BUDGET_BYTES)]
289 max_bytes: u64,
290
291 #[arg(long, default_value_t = DEFAULT_STORAGE_BUDGET_HORIZON_YEARS)]
293 horizon_years: u32,
294 },
295
296 Export {
298 file: PathBuf,
300
301 #[arg(short, long)]
303 output: Option<PathBuf>,
304 },
305
306 Ground {
308 file: PathBuf,
310 claim: String,
312 },
313
314 Evidence {
316 file: PathBuf,
318 query: String,
320 #[arg(long, short = 'l', default_value_t = 20)]
322 limit: usize,
323 },
324
325 Suggest {
327 file: PathBuf,
329 query: String,
331 #[arg(long, short = 'l', default_value_t = 10)]
333 limit: usize,
334 },
335
336 Workspace {
338 #[command(subcommand)]
339 command: WorkspaceCommand,
340 },
341}
342
343#[derive(Subcommand)]
344pub enum WorkspaceCommand {
345 Create { name: String },
347
348 Add {
350 workspace: String,
351 file: PathBuf,
352 #[arg(long, default_value = "source")]
353 role: String,
354 #[arg(long)]
355 language: Option<String>,
356 },
357
358 List { workspace: String },
360
361 Query {
363 workspace: String,
364 query: String,
365 },
366
367 Compare {
369 workspace: String,
370 symbol: String,
371 },
372
373 Xref {
375 workspace: String,
376 symbol: String,
377 },
378}
379
380pub fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
388 let command_name = match &cli.command {
389 None => "repl",
390 Some(Command::Init { .. }) => "init",
391 Some(Command::Compile { .. }) => "compile",
392 Some(Command::Info { .. }) => "info",
393 Some(Command::Query { .. }) => "query",
394 Some(Command::Get { .. }) => "get",
395 Some(Command::Completions { .. }) => "completions",
396 Some(Command::Health { .. }) => "health",
397 Some(Command::Gate { .. }) => "gate",
398 Some(Command::Budget { .. }) => "budget",
399 Some(Command::Export { .. }) => "export",
400 Some(Command::Ground { .. }) => "ground",
401 Some(Command::Evidence { .. }) => "evidence",
402 Some(Command::Suggest { .. }) => "suggest",
403 Some(Command::Workspace { .. }) => "workspace",
404 };
405 let started = Instant::now();
406 let result = match &cli.command {
407 None => crate::cli::repl::run(),
409
410 Some(Command::Init { file }) => cmd_init(file, &cli),
411 Some(Command::Compile {
412 path,
413 output,
414 exclude,
415 include_tests,
416 coverage_report,
417 }) => cmd_compile(
418 path,
419 output.as_deref(),
420 exclude,
421 *include_tests,
422 coverage_report.as_deref(),
423 &cli,
424 ),
425 Some(Command::Info { file }) => cmd_info(file, &cli),
426 Some(Command::Query {
427 file,
428 query_type,
429 name,
430 unit_id,
431 depth,
432 limit,
433 }) => cmd_query(
434 file,
435 query_type,
436 name.as_deref(),
437 *unit_id,
438 *depth,
439 *limit,
440 &cli,
441 ),
442 Some(Command::Get { file, unit_id }) => cmd_get(file, *unit_id, &cli),
443 Some(Command::Completions { shell }) => {
444 let mut cmd = Cli::command();
445 clap_complete::generate(*shell, &mut cmd, "acb", &mut std::io::stdout());
446 Ok(())
447 }
448 Some(Command::Health { file, limit }) => cmd_health(file, *limit, &cli),
449 Some(Command::Gate {
450 file,
451 unit_id,
452 max_risk,
453 depth,
454 require_tests,
455 }) => cmd_gate(file, *unit_id, *max_risk, *depth, *require_tests, &cli),
456 Some(Command::Budget {
457 file,
458 max_bytes,
459 horizon_years,
460 }) => cmd_budget(file, *max_bytes, *horizon_years, &cli),
461 Some(Command::Export { file, output }) => cmd_export_graph(file, output.as_deref(), &cli),
462 Some(Command::Ground { file, claim }) => cmd_ground(file, claim, &cli),
463 Some(Command::Evidence { file, query, limit }) => cmd_evidence(file, query, *limit, &cli),
464 Some(Command::Suggest { file, query, limit }) => cmd_suggest(file, query, *limit, &cli),
465 Some(Command::Workspace { command }) => cmd_workspace(command, &cli),
466 };
467
468 emit_cli_health_ledger(command_name, started.elapsed(), result.is_ok());
469 result
470}
471
472fn emit_cli_health_ledger(command: &str, duration: std::time::Duration, ok: bool) {
477 let dir = resolve_health_ledger_dir();
478 if std::fs::create_dir_all(&dir).is_err() {
479 return;
480 }
481 let path = dir.join("agentic-codebase-cli.json");
482 let tmp = dir.join("agentic-codebase-cli.json.tmp");
483 let profile = read_env_string("ACB_AUTONOMIC_PROFILE").unwrap_or_else(|| "desktop".to_string());
484 let payload = serde_json::json!({
485 "project": "AgenticCodebase",
486 "surface": "cli",
487 "timestamp": chrono::Utc::now().to_rfc3339(),
488 "status": if ok { "ok" } else { "error" },
489 "autonomic": {
490 "profile": profile.to_ascii_lowercase(),
491 "command": command,
492 "duration_ms": duration.as_millis(),
493 }
494 });
495 let Ok(bytes) = serde_json::to_vec_pretty(&payload) else {
496 return;
497 };
498 if std::fs::write(&tmp, bytes).is_err() {
499 return;
500 }
501 let _ = std::fs::rename(&tmp, &path);
502}
503
504fn resolve_health_ledger_dir() -> PathBuf {
505 if let Some(custom) = read_env_string("ACB_HEALTH_LEDGER_DIR") {
506 if !custom.is_empty() {
507 return PathBuf::from(custom);
508 }
509 }
510 if let Some(custom) = read_env_string("AGENTRA_HEALTH_LEDGER_DIR") {
511 if !custom.is_empty() {
512 return PathBuf::from(custom);
513 }
514 }
515 let home = std::env::var("HOME")
516 .ok()
517 .map(PathBuf::from)
518 .unwrap_or_else(|| PathBuf::from("."));
519 home.join(".agentra").join("health-ledger")
520}
521
522fn styled(cli: &Cli) -> Styled {
524 match cli.format {
525 OutputFormat::Json => Styled::plain(),
526 OutputFormat::Text => Styled::auto(),
527 }
528}
529
530fn validate_acb_path(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
532 let s = Styled::auto();
533 if !path.exists() {
534 return Err(format!(
535 "{} File not found: {}\n {} Check the path and try again",
536 s.fail(),
537 path.display(),
538 s.info()
539 )
540 .into());
541 }
542 if !path.is_file() {
543 return Err(format!(
544 "{} Not a file: {}\n {} Provide a path to an .acb file, not a directory",
545 s.fail(),
546 path.display(),
547 s.info()
548 )
549 .into());
550 }
551 if path.extension().and_then(|e| e.to_str()) != Some("acb") {
552 return Err(format!(
553 "{} Expected .acb file, got: {}\n {} Compile a repository first: acb compile <dir>",
554 s.fail(),
555 path.display(),
556 s.info()
557 )
558 .into());
559 }
560 Ok(())
561}
562
563fn workspace_state_path() -> PathBuf {
564 let home = std::env::var("HOME")
565 .ok()
566 .map(PathBuf::from)
567 .unwrap_or_else(|| PathBuf::from("."));
568 home.join(".agentic").join("codebase").join("workspaces.json")
569}
570
571fn load_workspace_state() -> Result<WorkspaceState, Box<dyn std::error::Error>> {
572 let path = workspace_state_path();
573 if !path.exists() {
574 return Ok(WorkspaceState::default());
575 }
576 let raw = std::fs::read_to_string(path)?;
577 let state = serde_json::from_str::<WorkspaceState>(&raw)?;
578 Ok(state)
579}
580
581fn save_workspace_state(state: &WorkspaceState) -> Result<(), Box<dyn std::error::Error>> {
582 let path = workspace_state_path();
583 if let Some(dir) = path.parent() {
584 std::fs::create_dir_all(dir)?;
585 }
586 let raw = serde_json::to_string_pretty(state)?;
587 std::fs::write(path, raw)?;
588 Ok(())
589}
590
591fn build_workspace_manager(
592 workspace: &str,
593) -> Result<(WorkspaceManager, String, WorkspaceState), Box<dyn std::error::Error>> {
594 let state = load_workspace_state()?;
595 let contexts = state
596 .workspaces
597 .get(workspace)
598 .ok_or_else(|| format!("workspace '{}' not found", workspace))?;
599
600 let mut manager = WorkspaceManager::new();
601 let ws_id = manager.create(workspace);
602
603 for ctx in contexts {
604 let role = ContextRole::from_str(&ctx.role).unwrap_or(ContextRole::Source);
605 let graph = AcbReader::read_from_file(Path::new(&ctx.path))?;
606 manager.add_context(&ws_id, &ctx.path, role, ctx.language.clone(), graph)?;
607 }
608
609 Ok((manager, ws_id, state))
610}
611
612fn cmd_init(file: &PathBuf, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
613 if file.extension().and_then(|e| e.to_str()) != Some("acb") {
614 return Err("init target must use .acb extension".into());
615 }
616 let graph = CodeGraph::with_default_dimension();
617 let writer = AcbWriter::new(graph.dimension());
618 writer.write_to_file(&graph, file)?;
619
620 if matches!(cli.format, OutputFormat::Json) {
621 println!(
622 "{}",
623 serde_json::to_string_pretty(&serde_json::json!({
624 "file": file.display().to_string(),
625 "created": true,
626 "units": 0,
627 "edges": 0
628 }))?
629 );
630 } else if !cli.quiet {
631 println!("Initialized {}", file.display());
632 }
633 Ok(())
634}
635
636fn cmd_export_graph(
637 file: &PathBuf,
638 output: Option<&Path>,
639 cli: &Cli,
640) -> Result<(), Box<dyn std::error::Error>> {
641 validate_acb_path(file)?;
642 let graph = AcbReader::read_from_file(file)?;
643 let payload = serde_json::json!({
644 "file": file.display().to_string(),
645 "units": graph.units().iter().map(|u| serde_json::json!({
646 "id": u.id,
647 "name": u.name,
648 "qualified_name": u.qualified_name,
649 "type": u.unit_type.label(),
650 "language": u.language.name(),
651 "file_path": u.file_path.display().to_string(),
652 "signature": u.signature,
653 })).collect::<Vec<_>>(),
654 "edges": graph.edges().iter().map(|e| serde_json::json!({
655 "source_id": e.source_id,
656 "target_id": e.target_id,
657 "type": e.edge_type.label(),
658 "weight": e.weight,
659 })).collect::<Vec<_>>(),
660 });
661
662 let raw = serde_json::to_string_pretty(&payload)?;
663 if let Some(path) = output {
664 std::fs::write(path, raw)?;
665 if !cli.quiet && matches!(cli.format, OutputFormat::Text) {
666 println!("Exported {} -> {}", file.display(), path.display());
667 }
668 } else {
669 println!("{}", raw);
670 }
671 Ok(())
672}
673
674fn cmd_ground(file: &PathBuf, claim: &str, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
675 validate_acb_path(file)?;
676 let graph = AcbReader::read_from_file(file)?;
677 let engine = GroundingEngine::new(&graph);
678 match engine.ground_claim(claim) {
679 GroundingResult::Verified {
680 evidence,
681 confidence,
682 } => {
683 if matches!(cli.format, OutputFormat::Json) {
684 println!(
685 "{}",
686 serde_json::to_string_pretty(&serde_json::json!({
687 "status": "verified",
688 "claim": claim,
689 "confidence": confidence,
690 "evidence_count": evidence.len(),
691 "evidence": evidence.iter().map(|e| serde_json::json!({
692 "node_id": e.node_id,
693 "name": e.name,
694 "type": e.node_type,
695 "file": e.file_path,
696 "line": e.line_number,
697 "snippet": e.snippet,
698 })).collect::<Vec<_>>()
699 }))?
700 );
701 } else {
702 println!("Status: verified (confidence {:.2})", confidence);
703 println!("Evidence: {}", evidence.len());
704 }
705 }
706 GroundingResult::Partial {
707 supported,
708 unsupported,
709 suggestions,
710 } => {
711 if matches!(cli.format, OutputFormat::Json) {
712 println!(
713 "{}",
714 serde_json::to_string_pretty(&serde_json::json!({
715 "status": "partial",
716 "claim": claim,
717 "supported": supported,
718 "unsupported": unsupported,
719 "suggestions": suggestions
720 }))?
721 );
722 } else {
723 println!("Status: partial");
724 println!("Supported: {:?}", supported);
725 println!("Unsupported: {:?}", unsupported);
726 if !suggestions.is_empty() {
727 println!("Suggestions: {:?}", suggestions);
728 }
729 }
730 }
731 GroundingResult::Ungrounded { suggestions, .. } => {
732 if matches!(cli.format, OutputFormat::Json) {
733 println!(
734 "{}",
735 serde_json::to_string_pretty(&serde_json::json!({
736 "status": "ungrounded",
737 "claim": claim,
738 "suggestions": suggestions
739 }))?
740 );
741 } else {
742 println!("Status: ungrounded");
743 if suggestions.is_empty() {
744 println!("Suggestions: none");
745 } else {
746 println!("Suggestions: {:?}", suggestions);
747 }
748 }
749 }
750 }
751 Ok(())
752}
753
754fn cmd_evidence(
755 file: &PathBuf,
756 query: &str,
757 limit: usize,
758 cli: &Cli,
759) -> Result<(), Box<dyn std::error::Error>> {
760 validate_acb_path(file)?;
761 let graph = AcbReader::read_from_file(file)?;
762 let engine = GroundingEngine::new(&graph);
763 let mut evidence = engine.find_evidence(query);
764 evidence.truncate(limit);
765
766 if matches!(cli.format, OutputFormat::Json) {
767 println!(
768 "{}",
769 serde_json::to_string_pretty(&serde_json::json!({
770 "query": query,
771 "count": evidence.len(),
772 "evidence": evidence.iter().map(|e| serde_json::json!({
773 "node_id": e.node_id,
774 "name": e.name,
775 "type": e.node_type,
776 "file": e.file_path,
777 "line": e.line_number,
778 "snippet": e.snippet,
779 })).collect::<Vec<_>>()
780 }))?
781 );
782 } else if evidence.is_empty() {
783 println!("No evidence found.");
784 } else {
785 println!("Evidence for {:?}:", query);
786 for e in &evidence {
787 println!(
788 " - [{}] {} ({}) {}",
789 e.node_id, e.name, e.node_type, e.file_path
790 );
791 }
792 }
793 Ok(())
794}
795
796fn cmd_suggest(
797 file: &PathBuf,
798 query: &str,
799 limit: usize,
800 cli: &Cli,
801) -> Result<(), Box<dyn std::error::Error>> {
802 validate_acb_path(file)?;
803 let graph = AcbReader::read_from_file(file)?;
804 let engine = GroundingEngine::new(&graph);
805 let suggestions = engine.suggest_similar(query, limit);
806
807 if matches!(cli.format, OutputFormat::Json) {
808 println!(
809 "{}",
810 serde_json::to_string_pretty(&serde_json::json!({
811 "query": query,
812 "suggestions": suggestions
813 }))?
814 );
815 } else if suggestions.is_empty() {
816 println!("No suggestions found.");
817 } else {
818 println!("Suggestions:");
819 for s in suggestions {
820 println!(" - {}", s);
821 }
822 }
823 Ok(())
824}
825
826fn cmd_workspace(
827 command: &WorkspaceCommand,
828 cli: &Cli,
829) -> Result<(), Box<dyn std::error::Error>> {
830 match command {
831 WorkspaceCommand::Create { name } => {
832 let mut state = load_workspace_state()?;
833 state.workspaces.entry(name.clone()).or_default();
834 save_workspace_state(&state)?;
835 if matches!(cli.format, OutputFormat::Json) {
836 println!(
837 "{}",
838 serde_json::to_string_pretty(&serde_json::json!({
839 "workspace": name,
840 "created": true
841 }))?
842 );
843 } else if !cli.quiet {
844 println!("Created workspace '{}'", name);
845 }
846 Ok(())
847 }
848 WorkspaceCommand::Add {
849 workspace,
850 file,
851 role,
852 language,
853 } => {
854 validate_acb_path(file)?;
855 let mut state = load_workspace_state()?;
856 let contexts = state.workspaces.entry(workspace.clone()).or_default();
857 let path = file.display().to_string();
858 if !contexts.iter().any(|ctx| ctx.path == path) {
859 contexts.push(WorkspaceContextState {
860 path: path.clone(),
861 role: role.to_ascii_lowercase(),
862 language: language.clone(),
863 });
864 save_workspace_state(&state)?;
865 }
866
867 if matches!(cli.format, OutputFormat::Json) {
868 println!(
869 "{}",
870 serde_json::to_string_pretty(&serde_json::json!({
871 "workspace": workspace,
872 "path": path,
873 "added": true
874 }))?
875 );
876 } else if !cli.quiet {
877 println!("Added {} to workspace '{}'", file.display(), workspace);
878 }
879 Ok(())
880 }
881 WorkspaceCommand::List { workspace } => {
882 let state = load_workspace_state()?;
883 let contexts = state
884 .workspaces
885 .get(workspace)
886 .ok_or_else(|| format!("workspace '{}' not found", workspace))?;
887 if matches!(cli.format, OutputFormat::Json) {
888 println!(
889 "{}",
890 serde_json::to_string_pretty(&serde_json::json!({
891 "workspace": workspace,
892 "contexts": contexts
893 }))?
894 );
895 } else {
896 println!("Workspace '{}':", workspace);
897 for ctx in contexts {
898 println!(
899 " - {} (role={}, language={})",
900 ctx.path,
901 ctx.role,
902 ctx.language.clone().unwrap_or_else(|| "-".to_string())
903 );
904 }
905 }
906 Ok(())
907 }
908 WorkspaceCommand::Query { workspace, query } => {
909 let (manager, ws_id, _) = build_workspace_manager(workspace)?;
910 let results = manager.query_all(&ws_id, query)?;
911 if matches!(cli.format, OutputFormat::Json) {
912 println!(
913 "{}",
914 serde_json::to_string_pretty(&serde_json::json!({
915 "workspace": workspace,
916 "query": query,
917 "results": results.iter().map(|r| serde_json::json!({
918 "context_id": r.context_id,
919 "role": r.context_role.label(),
920 "matches": r.matches.iter().map(|m| serde_json::json!({
921 "unit_id": m.unit_id,
922 "name": m.name,
923 "qualified_name": m.qualified_name,
924 "unit_type": m.unit_type,
925 "file_path": m.file_path,
926 })).collect::<Vec<_>>()
927 })).collect::<Vec<_>>()
928 }))?
929 );
930 } else {
931 println!("Workspace query {:?}:", query);
932 for r in results {
933 println!(" Context {} ({})", r.context_id, r.context_role.label());
934 for m in r.matches {
935 println!(" - [{}] {}", m.unit_id, m.qualified_name);
936 }
937 }
938 }
939 Ok(())
940 }
941 WorkspaceCommand::Compare { workspace, symbol } => {
942 let (manager, ws_id, _) = build_workspace_manager(workspace)?;
943 let comparison = manager.compare(&ws_id, symbol)?;
944 if matches!(cli.format, OutputFormat::Json) {
945 println!(
946 "{}",
947 serde_json::to_string_pretty(&serde_json::json!({
948 "workspace": workspace,
949 "symbol": comparison.symbol,
950 "contexts": comparison.contexts.iter().map(|c| serde_json::json!({
951 "context_id": c.context_id,
952 "role": c.role.label(),
953 "found": c.found,
954 "unit_type": c.unit_type,
955 "signature": c.signature,
956 "file_path": c.file_path,
957 })).collect::<Vec<_>>(),
958 "semantic_match": comparison.semantic_match,
959 "structural_diff": comparison.structural_diff,
960 }))?
961 );
962 } else {
963 println!("Comparison for {:?}:", symbol);
964 for c in comparison.contexts {
965 println!(" - {} ({}) found={}", c.context_id, c.role.label(), c.found);
966 }
967 }
968 Ok(())
969 }
970 WorkspaceCommand::Xref { workspace, symbol } => {
971 let (manager, ws_id, _) = build_workspace_manager(workspace)?;
972 let xref = manager.cross_reference(&ws_id, symbol)?;
973 if matches!(cli.format, OutputFormat::Json) {
974 println!(
975 "{}",
976 serde_json::to_string_pretty(&serde_json::json!({
977 "workspace": workspace,
978 "symbol": xref.symbol,
979 "found_in": xref.found_in.iter().map(|(id, role)| serde_json::json!({
980 "context_id": id,
981 "role": role.label(),
982 })).collect::<Vec<_>>(),
983 "missing_from": xref.missing_from.iter().map(|(id, role)| serde_json::json!({
984 "context_id": id,
985 "role": role.label(),
986 })).collect::<Vec<_>>(),
987 }))?
988 );
989 } else {
990 println!("Found in: {:?}", xref.found_in);
991 println!("Missing from: {:?}", xref.missing_from);
992 }
993 Ok(())
994 }
995 }
996}
997
998fn cmd_compile(
1003 path: &Path,
1004 output: Option<&std::path::Path>,
1005 exclude: &[String],
1006 include_tests: bool,
1007 coverage_report: Option<&Path>,
1008 cli: &Cli,
1009) -> Result<(), Box<dyn std::error::Error>> {
1010 let s = styled(cli);
1011
1012 if !path.exists() {
1013 return Err(format!(
1014 "{} Path does not exist: {}\n {} Create the directory or check the path",
1015 s.fail(),
1016 path.display(),
1017 s.info()
1018 )
1019 .into());
1020 }
1021 if !path.is_dir() {
1022 return Err(format!(
1023 "{} Path is not a directory: {}\n {} Provide the root directory of a source repository",
1024 s.fail(),
1025 path.display(),
1026 s.info()
1027 )
1028 .into());
1029 }
1030
1031 let out_path = match output {
1032 Some(p) => p.to_path_buf(),
1033 None => {
1034 let dir_name = path
1035 .file_name()
1036 .map(|n| n.to_string_lossy().to_string())
1037 .unwrap_or_else(|| "output".to_string());
1038 PathBuf::from(format!("{}.acb", dir_name))
1039 }
1040 };
1041
1042 let mut opts = ParseOptions {
1044 include_tests,
1045 ..ParseOptions::default()
1046 };
1047 for pat in exclude {
1048 opts.exclude.push(pat.clone());
1049 }
1050
1051 if !cli.quiet {
1052 if let OutputFormat::Text = cli.format {
1053 eprintln!(
1054 " {} Compiling {} {} {}",
1055 s.info(),
1056 s.bold(&path.display().to_string()),
1057 s.arrow(),
1058 s.cyan(&out_path.display().to_string()),
1059 );
1060 }
1061 }
1062
1063 if cli.verbose {
1065 eprintln!(" {} Parsing source files...", s.info());
1066 }
1067 let parser = AcbParser::new();
1068 let parse_result = parser.parse_directory(path, &opts)?;
1069
1070 if !cli.quiet {
1071 if let OutputFormat::Text = cli.format {
1072 eprintln!(
1073 " {} Parsed {} files ({} units found)",
1074 s.ok(),
1075 parse_result.stats.files_parsed,
1076 parse_result.units.len(),
1077 );
1078 let cov = &parse_result.stats.coverage;
1079 eprintln!(
1080 " {} Ingestion seen:{} candidate:{} skipped:{} errored:{}",
1081 s.info(),
1082 cov.files_seen,
1083 cov.files_candidate,
1084 cov.total_skipped(),
1085 parse_result.stats.files_errored
1086 );
1087 if !parse_result.errors.is_empty() {
1088 eprintln!(
1089 " {} {} parse errors (use --verbose to see details)",
1090 s.warn(),
1091 parse_result.errors.len()
1092 );
1093 }
1094 }
1095 }
1096
1097 if cli.verbose && !parse_result.errors.is_empty() {
1098 for err in &parse_result.errors {
1099 eprintln!(" {} {:?}", s.warn(), err);
1100 }
1101 }
1102
1103 if cli.verbose {
1105 eprintln!(" {} Running semantic analysis...", s.info());
1106 }
1107 let unit_count = parse_result.units.len();
1108 progress("Analyzing", 0, unit_count);
1109 let analyzer = SemanticAnalyzer::new();
1110 let analyze_opts = AnalyzeOptions::default();
1111 let graph = analyzer.analyze(parse_result.units, &analyze_opts)?;
1112 progress("Analyzing", unit_count, unit_count);
1113 progress_done();
1114
1115 if cli.verbose {
1116 eprintln!(
1117 " {} Graph built: {} units, {} edges",
1118 s.ok(),
1119 graph.unit_count(),
1120 graph.edge_count()
1121 );
1122 }
1123
1124 if cli.verbose {
1126 eprintln!(" {} Writing binary format...", s.info());
1127 }
1128 let backup_path = maybe_backup_existing_output(&out_path)?;
1129 if cli.verbose {
1130 if let Some(backup) = &backup_path {
1131 eprintln!(
1132 " {} Backed up previous graph to {}",
1133 s.info(),
1134 s.dim(&backup.display().to_string())
1135 );
1136 }
1137 }
1138 let writer = AcbWriter::with_default_dimension();
1139 writer.write_to_file(&graph, &out_path)?;
1140
1141 let file_size = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
1143 let budget_report = match maybe_enforce_storage_budget_on_output(&out_path) {
1144 Ok(report) => report,
1145 Err(e) => {
1146 tracing::warn!("ACB storage budget check skipped: {e}");
1147 AcbStorageBudgetReport {
1148 mode: "off",
1149 max_bytes: DEFAULT_STORAGE_BUDGET_BYTES,
1150 horizon_years: DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
1151 target_fraction: 0.85,
1152 current_size_bytes: file_size,
1153 projected_size_bytes: None,
1154 family_size_bytes: file_size,
1155 over_budget: false,
1156 backups_trimmed: 0,
1157 bytes_freed: 0,
1158 }
1159 }
1160 };
1161 let cov = &parse_result.stats.coverage;
1162 let coverage_json = serde_json::json!({
1163 "files_seen": cov.files_seen,
1164 "files_candidate": cov.files_candidate,
1165 "files_parsed": parse_result.stats.files_parsed,
1166 "files_skipped_total": cov.total_skipped(),
1167 "files_errored_total": parse_result.stats.files_errored,
1168 "skip_reasons": {
1169 "unknown_language": cov.skipped_unknown_language,
1170 "language_filter": cov.skipped_language_filter,
1171 "exclude_pattern": cov.skipped_excluded_pattern,
1172 "too_large": cov.skipped_too_large,
1173 "test_file_filtered": cov.skipped_test_file
1174 },
1175 "errors": {
1176 "read_errors": cov.read_errors,
1177 "parse_errors": cov.parse_errors
1178 },
1179 "parse_time_ms": parse_result.stats.parse_time_ms,
1180 "by_language": parse_result.stats.by_language,
1181 });
1182
1183 if let Some(report_path) = coverage_report {
1184 if let Some(parent) = report_path.parent() {
1185 if !parent.as_os_str().is_empty() {
1186 std::fs::create_dir_all(parent)?;
1187 }
1188 }
1189 let payload = serde_json::json!({
1190 "status": "ok",
1191 "source_root": path.display().to_string(),
1192 "output_graph": out_path.display().to_string(),
1193 "generated_at": chrono::Utc::now().to_rfc3339(),
1194 "coverage": coverage_json,
1195 });
1196 std::fs::write(report_path, serde_json::to_string_pretty(&payload)? + "\n")?;
1197 }
1198
1199 let stdout = std::io::stdout();
1200 let mut out = stdout.lock();
1201
1202 match cli.format {
1203 OutputFormat::Text => {
1204 if !cli.quiet {
1205 let _ = writeln!(out);
1206 let _ = writeln!(out, " {} Compiled successfully!", s.ok());
1207 let _ = writeln!(
1208 out,
1209 " Units: {}",
1210 s.bold(&graph.unit_count().to_string())
1211 );
1212 let _ = writeln!(
1213 out,
1214 " Edges: {}",
1215 s.bold(&graph.edge_count().to_string())
1216 );
1217 let _ = writeln!(
1218 out,
1219 " Languages: {}",
1220 s.bold(&graph.languages().len().to_string())
1221 );
1222 let _ = writeln!(out, " Size: {}", s.dim(&format_size(file_size)));
1223 if budget_report.over_budget {
1224 let projected = budget_report
1225 .projected_size_bytes
1226 .map(format_size)
1227 .unwrap_or_else(|| "unavailable".to_string());
1228 let _ = writeln!(
1229 out,
1230 " Budget: {} current={} projected={} limit={}",
1231 s.warn(),
1232 format_size(budget_report.current_size_bytes),
1233 projected,
1234 format_size(budget_report.max_bytes)
1235 );
1236 }
1237 if budget_report.backups_trimmed > 0 {
1238 let _ = writeln!(
1239 out,
1240 " Budget fix: trimmed {} backups ({} freed)",
1241 budget_report.backups_trimmed,
1242 format_size(budget_report.bytes_freed)
1243 );
1244 }
1245 let _ = writeln!(
1246 out,
1247 " Coverage: seen={} candidate={} skipped={} errored={}",
1248 cov.files_seen,
1249 cov.files_candidate,
1250 cov.total_skipped(),
1251 parse_result.stats.files_errored
1252 );
1253 if let Some(report_path) = coverage_report {
1254 let _ = writeln!(
1255 out,
1256 " Report: {}",
1257 s.dim(&report_path.display().to_string())
1258 );
1259 }
1260 let _ = writeln!(out);
1261 let _ = writeln!(
1262 out,
1263 " Next: {} or {}",
1264 s.cyan(&format!("acb info {}", out_path.display())),
1265 s.cyan(&format!(
1266 "acb query {} symbol --name <search>",
1267 out_path.display()
1268 )),
1269 );
1270 }
1271 }
1272 OutputFormat::Json => {
1273 let obj = serde_json::json!({
1274 "status": "ok",
1275 "source": path.display().to_string(),
1276 "output": out_path.display().to_string(),
1277 "units": graph.unit_count(),
1278 "edges": graph.edge_count(),
1279 "languages": graph.languages().len(),
1280 "file_size_bytes": file_size,
1281 "storage_budget": {
1282 "mode": budget_report.mode,
1283 "max_bytes": budget_report.max_bytes,
1284 "horizon_years": budget_report.horizon_years,
1285 "target_fraction": budget_report.target_fraction,
1286 "current_size_bytes": budget_report.current_size_bytes,
1287 "projected_size_bytes": budget_report.projected_size_bytes,
1288 "family_size_bytes": budget_report.family_size_bytes,
1289 "over_budget": budget_report.over_budget,
1290 "backups_trimmed": budget_report.backups_trimmed,
1291 "bytes_freed": budget_report.bytes_freed
1292 },
1293 "coverage": coverage_json,
1294 });
1295 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1296 }
1297 }
1298
1299 Ok(())
1300}
1301
1302#[derive(Debug, Clone)]
1303struct AcbStorageBudgetReport {
1304 mode: &'static str,
1305 max_bytes: u64,
1306 horizon_years: u32,
1307 target_fraction: f32,
1308 current_size_bytes: u64,
1309 projected_size_bytes: Option<u64>,
1310 family_size_bytes: u64,
1311 over_budget: bool,
1312 backups_trimmed: usize,
1313 bytes_freed: u64,
1314}
1315
1316#[derive(Debug, Clone)]
1317struct BackupEntry {
1318 path: PathBuf,
1319 size: u64,
1320 modified: SystemTime,
1321}
1322
1323fn maybe_enforce_storage_budget_on_output(
1324 out_path: &Path,
1325) -> Result<AcbStorageBudgetReport, Box<dyn std::error::Error>> {
1326 let mode = StorageBudgetMode::from_env("ACB_STORAGE_BUDGET_MODE");
1327 let max_bytes = read_env_u64("ACB_STORAGE_BUDGET_BYTES", DEFAULT_STORAGE_BUDGET_BYTES).max(1);
1328 let horizon_years = read_env_u32(
1329 "ACB_STORAGE_BUDGET_HORIZON_YEARS",
1330 DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
1331 )
1332 .max(1);
1333 let target_fraction =
1334 read_env_f32("ACB_STORAGE_BUDGET_TARGET_FRACTION", 0.85).clamp(0.50, 0.99);
1335
1336 let current_meta = std::fs::metadata(out_path)?;
1337 let current_size = current_meta.len();
1338 let current_modified = current_meta.modified().unwrap_or(SystemTime::now());
1339 let mut backups = list_backup_entries(out_path)?;
1340 let mut family_size = current_size.saturating_add(backups.iter().map(|b| b.size).sum::<u64>());
1341 let projected =
1342 projected_size_from_samples(&backups, current_modified, current_size, horizon_years);
1343 let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
1344
1345 let mut trimmed = 0usize;
1346 let mut bytes_freed = 0u64;
1347
1348 if mode == StorageBudgetMode::Warn && over_budget {
1349 tracing::warn!(
1350 "ACB storage budget warning: current={} projected={:?} limit={}",
1351 current_size,
1352 projected,
1353 max_bytes
1354 );
1355 }
1356
1357 if mode == StorageBudgetMode::AutoRollup && (over_budget || family_size > max_bytes) {
1358 let target_bytes = ((max_bytes as f64 * target_fraction as f64).round() as u64).max(1);
1359 backups.sort_by_key(|b| b.modified);
1360 for backup in backups {
1361 if family_size <= target_bytes {
1362 break;
1363 }
1364 if std::fs::remove_file(&backup.path).is_ok() {
1365 family_size = family_size.saturating_sub(backup.size);
1366 trimmed = trimmed.saturating_add(1);
1367 bytes_freed = bytes_freed.saturating_add(backup.size);
1368 }
1369 }
1370
1371 if trimmed > 0 {
1372 tracing::info!(
1373 "ACB storage budget rollup: trimmed_backups={} freed_bytes={} family_size={}",
1374 trimmed,
1375 bytes_freed,
1376 family_size
1377 );
1378 }
1379 }
1380
1381 Ok(AcbStorageBudgetReport {
1382 mode: mode.as_str(),
1383 max_bytes,
1384 horizon_years,
1385 target_fraction,
1386 current_size_bytes: current_size,
1387 projected_size_bytes: projected,
1388 family_size_bytes: family_size,
1389 over_budget,
1390 backups_trimmed: trimmed,
1391 bytes_freed,
1392 })
1393}
1394
1395fn list_backup_entries(out_path: &Path) -> Result<Vec<BackupEntry>, Box<dyn std::error::Error>> {
1396 let backups_dir = resolve_backup_dir(out_path);
1397 if !backups_dir.exists() {
1398 return Ok(Vec::new());
1399 }
1400
1401 let original_name = out_path
1402 .file_name()
1403 .and_then(|n| n.to_str())
1404 .unwrap_or("graph.acb");
1405
1406 let mut out = Vec::new();
1407 for entry in std::fs::read_dir(&backups_dir)? {
1408 let entry = entry?;
1409 let name = entry.file_name();
1410 let Some(name_str) = name.to_str() else {
1411 continue;
1412 };
1413 if !(name_str.starts_with(original_name) && name_str.ends_with(".bak")) {
1414 continue;
1415 }
1416 let meta = entry.metadata()?;
1417 out.push(BackupEntry {
1418 path: entry.path(),
1419 size: meta.len(),
1420 modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
1421 });
1422 }
1423 Ok(out)
1424}
1425
1426fn projected_size_from_samples(
1427 backups: &[BackupEntry],
1428 current_modified: SystemTime,
1429 current_size: u64,
1430 horizon_years: u32,
1431) -> Option<u64> {
1432 let mut samples = backups
1433 .iter()
1434 .map(|b| (b.modified, b.size))
1435 .collect::<Vec<_>>();
1436 samples.push((current_modified, current_size));
1437 if samples.len() < 2 {
1438 return None;
1439 }
1440 samples.sort_by_key(|(ts, _)| *ts);
1441 let (first_ts, first_size) = samples.first().copied()?;
1442 let (last_ts, last_size) = samples.last().copied()?;
1443 if last_ts <= first_ts {
1444 return None;
1445 }
1446 let span_secs = last_ts
1447 .duration_since(first_ts)
1448 .ok()?
1449 .as_secs_f64()
1450 .max(1.0);
1451 let delta = (last_size as f64 - first_size as f64).max(0.0);
1452 if delta <= 0.0 {
1453 return Some(current_size);
1454 }
1455 let per_sec = delta / span_secs;
1456 let horizon_secs = (horizon_years.max(1) as f64) * 365.25 * 24.0 * 3600.0;
1457 let projected = (current_size as f64 + per_sec * horizon_secs).round();
1458 Some(projected.max(0.0).min(u64::MAX as f64) as u64)
1459}
1460
1461fn maybe_backup_existing_output(
1462 out_path: &Path,
1463) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
1464 if !auto_backup_enabled() || !out_path.exists() || !out_path.is_file() {
1465 return Ok(None);
1466 }
1467
1468 let backups_dir = resolve_backup_dir(out_path);
1469 std::fs::create_dir_all(&backups_dir)?;
1470
1471 let original_name = out_path
1472 .file_name()
1473 .and_then(|n| n.to_str())
1474 .unwrap_or("graph.acb");
1475 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
1476 let backup_path = backups_dir.join(format!("{original_name}.{ts}.bak"));
1477 std::fs::copy(out_path, &backup_path)?;
1478 prune_old_backups(&backups_dir, original_name, auto_backup_retention())?;
1479
1480 Ok(Some(backup_path))
1481}
1482
1483fn auto_backup_enabled() -> bool {
1484 match std::env::var("ACB_AUTO_BACKUP") {
1485 Ok(v) => {
1486 let value = v.trim().to_ascii_lowercase();
1487 value != "0" && value != "false" && value != "off" && value != "no"
1488 }
1489 Err(_) => true,
1490 }
1491}
1492
1493fn auto_backup_retention() -> usize {
1494 let default_retention = match read_env_string("ACB_AUTONOMIC_PROFILE")
1495 .unwrap_or_else(|| "desktop".to_string())
1496 .to_ascii_lowercase()
1497 .as_str()
1498 {
1499 "cloud" => 40,
1500 "aggressive" => 12,
1501 _ => 20,
1502 };
1503 std::env::var("ACB_AUTO_BACKUP_RETENTION")
1504 .ok()
1505 .and_then(|v| v.parse::<usize>().ok())
1506 .unwrap_or(default_retention)
1507 .max(1)
1508}
1509
1510fn resolve_backup_dir(out_path: &Path) -> PathBuf {
1511 if let Ok(custom) = std::env::var("ACB_AUTO_BACKUP_DIR") {
1512 let trimmed = custom.trim();
1513 if !trimmed.is_empty() {
1514 return PathBuf::from(trimmed);
1515 }
1516 }
1517 out_path
1518 .parent()
1519 .unwrap_or_else(|| Path::new("."))
1520 .join(".acb-backups")
1521}
1522
1523fn read_env_string(name: &str) -> Option<String> {
1524 std::env::var(name).ok().map(|v| v.trim().to_string())
1525}
1526
1527fn read_env_u64(name: &str, default_value: u64) -> u64 {
1528 std::env::var(name)
1529 .ok()
1530 .and_then(|v| v.parse::<u64>().ok())
1531 .unwrap_or(default_value)
1532}
1533
1534fn read_env_u32(name: &str, default_value: u32) -> u32 {
1535 std::env::var(name)
1536 .ok()
1537 .and_then(|v| v.parse::<u32>().ok())
1538 .unwrap_or(default_value)
1539}
1540
1541fn read_env_f32(name: &str, default_value: f32) -> f32 {
1542 std::env::var(name)
1543 .ok()
1544 .and_then(|v| v.parse::<f32>().ok())
1545 .unwrap_or(default_value)
1546}
1547
1548fn prune_old_backups(
1549 backup_dir: &Path,
1550 original_name: &str,
1551 retention: usize,
1552) -> Result<(), Box<dyn std::error::Error>> {
1553 let mut backups = std::fs::read_dir(backup_dir)?
1554 .filter_map(Result::ok)
1555 .filter(|entry| {
1556 entry
1557 .file_name()
1558 .to_str()
1559 .map(|name| name.starts_with(original_name) && name.ends_with(".bak"))
1560 .unwrap_or(false)
1561 })
1562 .collect::<Vec<_>>();
1563
1564 if backups.len() <= retention {
1565 return Ok(());
1566 }
1567
1568 backups.sort_by_key(|entry| {
1569 entry
1570 .metadata()
1571 .and_then(|m| m.modified())
1572 .ok()
1573 .unwrap_or(SystemTime::UNIX_EPOCH)
1574 });
1575
1576 let to_remove = backups.len().saturating_sub(retention);
1577 for entry in backups.into_iter().take(to_remove) {
1578 let _ = std::fs::remove_file(entry.path());
1579 }
1580 Ok(())
1581}
1582
1583fn cmd_budget(
1584 file: &Path,
1585 max_bytes: u64,
1586 horizon_years: u32,
1587 cli: &Cli,
1588) -> Result<(), Box<dyn std::error::Error>> {
1589 validate_acb_path(file)?;
1590 let s = styled(cli);
1591 let current_meta = std::fs::metadata(file)?;
1592 let current_size = current_meta.len();
1593 let current_modified = current_meta.modified().unwrap_or(SystemTime::now());
1594 let backups = list_backup_entries(file)?;
1595 let family_size = current_size.saturating_add(backups.iter().map(|b| b.size).sum::<u64>());
1596 let projected =
1597 projected_size_from_samples(&backups, current_modified, current_size, horizon_years);
1598 let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
1599 let daily_budget_bytes = max_bytes as f64 / ((horizon_years.max(1) as f64) * 365.25);
1600
1601 let stdout = std::io::stdout();
1602 let mut out = stdout.lock();
1603
1604 match cli.format {
1605 OutputFormat::Text => {
1606 let status = if over_budget {
1607 s.red("over-budget")
1608 } else {
1609 s.green("within-budget")
1610 };
1611 let _ = writeln!(out, "\n {} {}\n", s.info(), s.bold("ACB Storage Budget"));
1612 let _ = writeln!(out, " File: {}", file.display());
1613 let _ = writeln!(out, " Current: {}", format_size(current_size));
1614 if let Some(v) = projected {
1615 let _ = writeln!(
1616 out,
1617 " Projected: {} ({}y)",
1618 format_size(v),
1619 horizon_years
1620 );
1621 } else {
1622 let _ = writeln!(
1623 out,
1624 " Projected: unavailable (need backup history samples)"
1625 );
1626 }
1627 let _ = writeln!(out, " Family: {}", format_size(family_size));
1628 let _ = writeln!(out, " Budget: {}", format_size(max_bytes));
1629 let _ = writeln!(out, " Status: {}", status);
1630 let _ = writeln!(
1631 out,
1632 " Guidance: {:.1} KB/day target growth",
1633 daily_budget_bytes / 1024.0
1634 );
1635 let _ = writeln!(
1636 out,
1637 " Suggested env: ACB_STORAGE_BUDGET_MODE=auto-rollup ACB_STORAGE_BUDGET_BYTES={} ACB_STORAGE_BUDGET_HORIZON_YEARS={}",
1638 max_bytes,
1639 horizon_years
1640 );
1641 let _ = writeln!(out);
1642 }
1643 OutputFormat::Json => {
1644 let obj = serde_json::json!({
1645 "file": file.display().to_string(),
1646 "current_size_bytes": current_size,
1647 "projected_size_bytes": projected,
1648 "family_size_bytes": family_size,
1649 "max_budget_bytes": max_bytes,
1650 "horizon_years": horizon_years,
1651 "over_budget": over_budget,
1652 "daily_budget_bytes": daily_budget_bytes,
1653 "daily_budget_kb": daily_budget_bytes / 1024.0,
1654 "guidance": {
1655 "recommended_policy_mode": if over_budget { "auto-rollup" } else { "warn" },
1656 "env": {
1657 "ACB_STORAGE_BUDGET_MODE": "auto-rollup|warn|off",
1658 "ACB_STORAGE_BUDGET_BYTES": max_bytes,
1659 "ACB_STORAGE_BUDGET_HORIZON_YEARS": horizon_years,
1660 }
1661 }
1662 });
1663 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1664 }
1665 }
1666 Ok(())
1667}
1668
1669fn cmd_info(file: &PathBuf, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1674 let s = styled(cli);
1675 validate_acb_path(file)?;
1676 let graph = AcbReader::read_from_file(file)?;
1677
1678 let data = std::fs::read(file)?;
1680 let header_bytes: [u8; 128] = data[..128]
1681 .try_into()
1682 .map_err(|_| "File too small for header")?;
1683 let header = FileHeader::from_bytes(&header_bytes)?;
1684 let file_size = data.len() as u64;
1685
1686 let stdout = std::io::stdout();
1687 let mut out = stdout.lock();
1688
1689 match cli.format {
1690 OutputFormat::Text => {
1691 let _ = writeln!(
1692 out,
1693 "\n {} {}",
1694 s.info(),
1695 s.bold(&file.display().to_string())
1696 );
1697 let _ = writeln!(out, " Version: v{}", header.version);
1698 let _ = writeln!(
1699 out,
1700 " Units: {}",
1701 s.bold(&graph.unit_count().to_string())
1702 );
1703 let _ = writeln!(
1704 out,
1705 " Edges: {}",
1706 s.bold(&graph.edge_count().to_string())
1707 );
1708 let _ = writeln!(
1709 out,
1710 " Languages: {}",
1711 s.bold(&graph.languages().len().to_string())
1712 );
1713 let _ = writeln!(out, " Dimension: {}", header.dimension);
1714 let _ = writeln!(out, " File size: {}", format_size(file_size));
1715 let _ = writeln!(out);
1716 for lang in graph.languages() {
1717 let count = graph.units().iter().filter(|u| u.language == *lang).count();
1718 let _ = writeln!(
1719 out,
1720 " {} {} {}",
1721 s.arrow(),
1722 s.cyan(&format!("{:12}", lang)),
1723 s.dim(&format!("{} units", count))
1724 );
1725 }
1726 let _ = writeln!(out);
1727 }
1728 OutputFormat::Json => {
1729 let mut lang_map = serde_json::Map::new();
1730 for lang in graph.languages() {
1731 let count = graph.units().iter().filter(|u| u.language == *lang).count();
1732 lang_map.insert(lang.to_string(), serde_json::json!(count));
1733 }
1734 let obj = serde_json::json!({
1735 "file": file.display().to_string(),
1736 "version": header.version,
1737 "units": graph.unit_count(),
1738 "edges": graph.edge_count(),
1739 "languages": graph.languages().len(),
1740 "dimension": header.dimension,
1741 "file_size_bytes": file_size,
1742 "language_breakdown": lang_map,
1743 });
1744 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1745 }
1746 }
1747
1748 Ok(())
1749}
1750
1751fn cmd_health(file: &Path, limit: usize, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1752 validate_acb_path(file)?;
1753 let graph = AcbReader::read_from_file(file)?;
1754 let engine = QueryEngine::new();
1755 let s = styled(cli);
1756
1757 let prophecy = engine.prophecy(
1758 &graph,
1759 ProphecyParams {
1760 top_k: limit,
1761 min_risk: 0.45,
1762 },
1763 )?;
1764 let test_gaps = engine.test_gap(
1765 &graph,
1766 TestGapParams {
1767 min_changes: 5,
1768 min_complexity: 10,
1769 unit_types: vec![],
1770 },
1771 )?;
1772 let hotspots = engine.hotspot_detection(
1773 &graph,
1774 HotspotParams {
1775 top_k: limit,
1776 min_score: 0.55,
1777 unit_types: vec![],
1778 },
1779 )?;
1780 let dead_code = engine.dead_code(
1781 &graph,
1782 DeadCodeParams {
1783 unit_types: vec![],
1784 include_tests_as_roots: true,
1785 },
1786 )?;
1787
1788 let high_risk = prophecy
1789 .predictions
1790 .iter()
1791 .filter(|p| p.risk_score >= 0.70)
1792 .count();
1793 let avg_risk = if prophecy.predictions.is_empty() {
1794 0.0
1795 } else {
1796 prophecy
1797 .predictions
1798 .iter()
1799 .map(|p| p.risk_score)
1800 .sum::<f32>()
1801 / prophecy.predictions.len() as f32
1802 };
1803 let status = if high_risk >= 3 || test_gaps.len() >= 8 {
1804 "fail"
1805 } else if high_risk > 0 || !test_gaps.is_empty() || !hotspots.is_empty() {
1806 "warn"
1807 } else {
1808 "pass"
1809 };
1810
1811 let stdout = std::io::stdout();
1812 let mut out = stdout.lock();
1813 match cli.format {
1814 OutputFormat::Text => {
1815 let status_label = match status {
1816 "pass" => s.green("PASS"),
1817 "warn" => s.yellow("WARN"),
1818 _ => s.red("FAIL"),
1819 };
1820 let _ = writeln!(
1821 out,
1822 "\n Graph health for {} [{}]\n",
1823 s.bold(&file.display().to_string()),
1824 status_label
1825 );
1826 let _ = writeln!(out, " Units: {}", graph.unit_count());
1827 let _ = writeln!(out, " Edges: {}", graph.edge_count());
1828 let _ = writeln!(out, " Avg risk: {:.2}", avg_risk);
1829 let _ = writeln!(out, " High risk: {}", high_risk);
1830 let _ = writeln!(out, " Test gaps: {}", test_gaps.len());
1831 let _ = writeln!(out, " Hotspots: {}", hotspots.len());
1832 let _ = writeln!(out, " Dead code: {}", dead_code.len());
1833 let _ = writeln!(out);
1834
1835 if !prophecy.predictions.is_empty() {
1836 let _ = writeln!(out, " Top risk predictions:");
1837 for p in prophecy.predictions.iter().take(5) {
1838 let name = graph
1839 .get_unit(p.unit_id)
1840 .map(|u| u.qualified_name.clone())
1841 .unwrap_or_else(|| format!("unit_{}", p.unit_id));
1842 let _ = writeln!(out, " {} {:.2} {}", s.arrow(), p.risk_score, name);
1843 }
1844 let _ = writeln!(out);
1845 }
1846
1847 if !test_gaps.is_empty() {
1848 let _ = writeln!(out, " Top test gaps:");
1849 for g in test_gaps.iter().take(5) {
1850 let name = graph
1851 .get_unit(g.unit_id)
1852 .map(|u| u.qualified_name.clone())
1853 .unwrap_or_else(|| format!("unit_{}", g.unit_id));
1854 let _ = writeln!(
1855 out,
1856 " {} {:.2} {} ({})",
1857 s.arrow(),
1858 g.priority,
1859 name,
1860 g.reason
1861 );
1862 }
1863 let _ = writeln!(out);
1864 }
1865
1866 let _ = writeln!(
1867 out,
1868 " Next: acb gate {} --unit-id <id> --max-risk 0.60",
1869 file.display()
1870 );
1871 let _ = writeln!(out);
1872 }
1873 OutputFormat::Json => {
1874 let predictions = prophecy
1875 .predictions
1876 .iter()
1877 .map(|p| {
1878 serde_json::json!({
1879 "unit_id": p.unit_id,
1880 "name": graph.get_unit(p.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1881 "risk_score": p.risk_score,
1882 "reason": p.reason,
1883 })
1884 })
1885 .collect::<Vec<_>>();
1886 let gaps = test_gaps
1887 .iter()
1888 .map(|g| {
1889 serde_json::json!({
1890 "unit_id": g.unit_id,
1891 "name": graph.get_unit(g.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1892 "priority": g.priority,
1893 "reason": g.reason,
1894 })
1895 })
1896 .collect::<Vec<_>>();
1897 let hotspot_rows = hotspots
1898 .iter()
1899 .map(|h| {
1900 serde_json::json!({
1901 "unit_id": h.unit_id,
1902 "name": graph.get_unit(h.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1903 "score": h.score,
1904 "factors": h.factors,
1905 })
1906 })
1907 .collect::<Vec<_>>();
1908 let dead_rows = dead_code
1909 .iter()
1910 .map(|u| {
1911 serde_json::json!({
1912 "unit_id": u.id,
1913 "name": u.qualified_name,
1914 "type": u.unit_type.label(),
1915 })
1916 })
1917 .collect::<Vec<_>>();
1918
1919 let obj = serde_json::json!({
1920 "status": status,
1921 "graph": file.display().to_string(),
1922 "summary": {
1923 "units": graph.unit_count(),
1924 "edges": graph.edge_count(),
1925 "avg_risk": avg_risk,
1926 "high_risk_count": high_risk,
1927 "test_gap_count": test_gaps.len(),
1928 "hotspot_count": hotspots.len(),
1929 "dead_code_count": dead_code.len(),
1930 },
1931 "risk_predictions": predictions,
1932 "test_gaps": gaps,
1933 "hotspots": hotspot_rows,
1934 "dead_code": dead_rows,
1935 });
1936 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1937 }
1938 }
1939
1940 Ok(())
1941}
1942
1943fn cmd_gate(
1944 file: &Path,
1945 unit_id: u64,
1946 max_risk: f32,
1947 depth: u32,
1948 require_tests: bool,
1949 cli: &Cli,
1950) -> Result<(), Box<dyn std::error::Error>> {
1951 validate_acb_path(file)?;
1952 let graph = AcbReader::read_from_file(file)?;
1953 let engine = QueryEngine::new();
1954 let s = styled(cli);
1955
1956 let result = engine.impact_analysis(
1957 &graph,
1958 ImpactParams {
1959 unit_id,
1960 max_depth: depth,
1961 edge_types: vec![],
1962 },
1963 )?;
1964 let untested_count = result.impacted.iter().filter(|u| !u.has_tests).count();
1965 let risk_pass = result.overall_risk <= max_risk;
1966 let test_pass = !require_tests || untested_count == 0;
1967 let passed = risk_pass && test_pass;
1968
1969 let stdout = std::io::stdout();
1970 let mut out = stdout.lock();
1971
1972 match cli.format {
1973 OutputFormat::Text => {
1974 let label = if passed {
1975 s.green("PASS")
1976 } else {
1977 s.red("FAIL")
1978 };
1979 let unit_name = graph
1980 .get_unit(unit_id)
1981 .map(|u| u.qualified_name.clone())
1982 .unwrap_or_else(|| format!("unit_{}", unit_id));
1983 let _ = writeln!(out, "\n Gate {} for {}\n", label, s.bold(&unit_name));
1984 let _ = writeln!(
1985 out,
1986 " Overall risk: {:.2} (max {:.2})",
1987 result.overall_risk, max_risk
1988 );
1989 let _ = writeln!(out, " Impacted: {}", result.impacted.len());
1990 let _ = writeln!(out, " Untested: {}", untested_count);
1991 let _ = writeln!(out, " Require tests: {}", require_tests);
1992 if !result.recommendations.is_empty() {
1993 let _ = writeln!(out);
1994 for rec in &result.recommendations {
1995 let _ = writeln!(out, " {} {}", s.info(), rec);
1996 }
1997 }
1998 let _ = writeln!(out);
1999 }
2000 OutputFormat::Json => {
2001 let obj = serde_json::json!({
2002 "gate": if passed { "pass" } else { "fail" },
2003 "file": file.display().to_string(),
2004 "unit_id": unit_id,
2005 "max_risk": max_risk,
2006 "overall_risk": result.overall_risk,
2007 "impacted_count": result.impacted.len(),
2008 "untested_count": untested_count,
2009 "require_tests": require_tests,
2010 "recommendations": result.recommendations,
2011 });
2012 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2013 }
2014 }
2015
2016 if !passed {
2017 return Err(format!(
2018 "{} gate failed: risk_pass={} test_pass={} (risk {:.2} / max {:.2}, untested {})",
2019 s.fail(),
2020 risk_pass,
2021 test_pass,
2022 result.overall_risk,
2023 max_risk,
2024 untested_count
2025 )
2026 .into());
2027 }
2028
2029 Ok(())
2030}
2031
2032fn cmd_query(
2037 file: &Path,
2038 query_type: &str,
2039 name: Option<&str>,
2040 unit_id: Option<u64>,
2041 depth: u32,
2042 limit: usize,
2043 cli: &Cli,
2044) -> Result<(), Box<dyn std::error::Error>> {
2045 validate_acb_path(file)?;
2046 let graph = AcbReader::read_from_file(file)?;
2047 let engine = QueryEngine::new();
2048 let s = styled(cli);
2049
2050 match query_type {
2051 "symbol" | "sym" | "s" => query_symbol(&graph, &engine, name, limit, cli, &s),
2052 "deps" | "dep" | "d" => query_deps(&graph, &engine, unit_id, depth, cli, &s),
2053 "rdeps" | "rdep" | "r" => query_rdeps(&graph, &engine, unit_id, depth, cli, &s),
2054 "impact" | "imp" | "i" => query_impact(&graph, &engine, unit_id, depth, cli, &s),
2055 "calls" | "call" | "c" => query_calls(&graph, &engine, unit_id, depth, cli, &s),
2056 "similar" | "sim" => query_similar(&graph, &engine, unit_id, limit, cli, &s),
2057 "prophecy" | "predict" | "p" => query_prophecy(&graph, &engine, limit, cli, &s),
2058 "stability" | "stab" => query_stability(&graph, &engine, unit_id, cli, &s),
2059 "coupling" | "couple" => query_coupling(&graph, &engine, unit_id, cli, &s),
2060 "test-gap" | "testgap" | "gaps" => query_test_gap(&graph, &engine, limit, cli, &s),
2061 "hotspot" | "hotspots" => query_hotspots(&graph, &engine, limit, cli, &s),
2062 "dead" | "dead-code" | "deadcode" => query_dead_code(&graph, &engine, limit, cli, &s),
2063 other => {
2064 let known = [
2065 "symbol",
2066 "deps",
2067 "rdeps",
2068 "impact",
2069 "calls",
2070 "similar",
2071 "prophecy",
2072 "stability",
2073 "coupling",
2074 "test-gap",
2075 "hotspots",
2076 "dead-code",
2077 ];
2078 let suggestion = known
2079 .iter()
2080 .filter(|k| k.starts_with(&other[..1.min(other.len())]))
2081 .copied()
2082 .collect::<Vec<_>>();
2083 let hint = if suggestion.is_empty() {
2084 format!("Available: {}", known.join(", "))
2085 } else {
2086 format!("Did you mean: {}?", suggestion.join(", "))
2087 };
2088 Err(format!(
2089 "{} Unknown query type: {}\n {} {}",
2090 s.fail(),
2091 other,
2092 s.info(),
2093 hint
2094 )
2095 .into())
2096 }
2097 }
2098}
2099
2100fn query_symbol(
2101 graph: &CodeGraph,
2102 engine: &QueryEngine,
2103 name: Option<&str>,
2104 limit: usize,
2105 cli: &Cli,
2106 s: &Styled,
2107) -> Result<(), Box<dyn std::error::Error>> {
2108 let search_name = name.ok_or_else(|| {
2109 format!(
2110 "{} --name is required for symbol queries\n {} Example: acb query file.acb symbol --name UserService",
2111 s.fail(),
2112 s.info()
2113 )
2114 })?;
2115 let params = SymbolLookupParams {
2116 name: search_name.to_string(),
2117 mode: MatchMode::Contains,
2118 limit,
2119 ..Default::default()
2120 };
2121 let results = engine.symbol_lookup(graph, params)?;
2122
2123 let stdout = std::io::stdout();
2124 let mut out = stdout.lock();
2125
2126 match cli.format {
2127 OutputFormat::Text => {
2128 let _ = writeln!(
2129 out,
2130 "\n Symbol lookup: {} ({} results)\n",
2131 s.bold(&format!("\"{}\"", search_name)),
2132 results.len()
2133 );
2134 if results.is_empty() {
2135 let _ = writeln!(
2136 out,
2137 " {} No matches found. Try a broader search term.",
2138 s.warn()
2139 );
2140 }
2141 for (i, unit) in results.iter().enumerate() {
2142 let _ = writeln!(
2143 out,
2144 " {:>3}. {} {} {}",
2145 s.dim(&format!("#{}", i + 1)),
2146 s.bold(&unit.qualified_name),
2147 s.dim(&format!("({})", unit.unit_type)),
2148 s.dim(&format!(
2149 "{}:{}",
2150 unit.file_path.display(),
2151 unit.span.start_line
2152 ))
2153 );
2154 }
2155 let _ = writeln!(out);
2156 }
2157 OutputFormat::Json => {
2158 let entries: Vec<serde_json::Value> = results
2159 .iter()
2160 .map(|u| {
2161 serde_json::json!({
2162 "id": u.id,
2163 "name": u.name,
2164 "qualified_name": u.qualified_name,
2165 "unit_type": u.unit_type.label(),
2166 "language": u.language.name(),
2167 "file": u.file_path.display().to_string(),
2168 "line": u.span.start_line,
2169 })
2170 })
2171 .collect();
2172 let obj = serde_json::json!({
2173 "query": "symbol",
2174 "name": search_name,
2175 "count": results.len(),
2176 "results": entries,
2177 });
2178 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2179 }
2180 }
2181 Ok(())
2182}
2183
2184fn query_deps(
2185 graph: &CodeGraph,
2186 engine: &QueryEngine,
2187 unit_id: Option<u64>,
2188 depth: u32,
2189 cli: &Cli,
2190 s: &Styled,
2191) -> Result<(), Box<dyn std::error::Error>> {
2192 let uid = unit_id.ok_or_else(|| {
2193 format!(
2194 "{} --unit-id is required for deps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
2195 s.fail(), s.info()
2196 )
2197 })?;
2198 let params = DependencyParams {
2199 unit_id: uid,
2200 max_depth: depth,
2201 edge_types: vec![],
2202 include_transitive: true,
2203 };
2204 let result = engine.dependency_graph(graph, params)?;
2205
2206 let stdout = std::io::stdout();
2207 let mut out = stdout.lock();
2208
2209 match cli.format {
2210 OutputFormat::Text => {
2211 let root_name = graph
2212 .get_unit(uid)
2213 .map(|u| u.qualified_name.as_str())
2214 .unwrap_or("?");
2215 let _ = writeln!(
2216 out,
2217 "\n Dependencies of {} ({} found)\n",
2218 s.bold(root_name),
2219 result.nodes.len()
2220 );
2221 for node in &result.nodes {
2222 let unit_name = graph
2223 .get_unit(node.unit_id)
2224 .map(|u| u.qualified_name.as_str())
2225 .unwrap_or("?");
2226 let indent = " ".repeat(node.depth as usize);
2227 let _ = writeln!(
2228 out,
2229 " {}{} {} {}",
2230 indent,
2231 s.arrow(),
2232 s.cyan(unit_name),
2233 s.dim(&format!("[id:{}]", node.unit_id))
2234 );
2235 }
2236 let _ = writeln!(out);
2237 }
2238 OutputFormat::Json => {
2239 let entries: Vec<serde_json::Value> = result
2240 .nodes
2241 .iter()
2242 .map(|n| {
2243 let unit_name = graph
2244 .get_unit(n.unit_id)
2245 .map(|u| u.qualified_name.clone())
2246 .unwrap_or_default();
2247 serde_json::json!({
2248 "unit_id": n.unit_id,
2249 "name": unit_name,
2250 "depth": n.depth,
2251 })
2252 })
2253 .collect();
2254 let obj = serde_json::json!({
2255 "query": "deps",
2256 "root_id": uid,
2257 "count": result.nodes.len(),
2258 "results": entries,
2259 });
2260 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2261 }
2262 }
2263 Ok(())
2264}
2265
2266fn query_rdeps(
2267 graph: &CodeGraph,
2268 engine: &QueryEngine,
2269 unit_id: Option<u64>,
2270 depth: u32,
2271 cli: &Cli,
2272 s: &Styled,
2273) -> Result<(), Box<dyn std::error::Error>> {
2274 let uid = unit_id.ok_or_else(|| {
2275 format!(
2276 "{} --unit-id is required for rdeps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
2277 s.fail(), s.info()
2278 )
2279 })?;
2280 let params = DependencyParams {
2281 unit_id: uid,
2282 max_depth: depth,
2283 edge_types: vec![],
2284 include_transitive: true,
2285 };
2286 let result = engine.reverse_dependency(graph, params)?;
2287
2288 let stdout = std::io::stdout();
2289 let mut out = stdout.lock();
2290
2291 match cli.format {
2292 OutputFormat::Text => {
2293 let root_name = graph
2294 .get_unit(uid)
2295 .map(|u| u.qualified_name.as_str())
2296 .unwrap_or("?");
2297 let _ = writeln!(
2298 out,
2299 "\n Reverse dependencies of {} ({} found)\n",
2300 s.bold(root_name),
2301 result.nodes.len()
2302 );
2303 for node in &result.nodes {
2304 let unit_name = graph
2305 .get_unit(node.unit_id)
2306 .map(|u| u.qualified_name.as_str())
2307 .unwrap_or("?");
2308 let indent = " ".repeat(node.depth as usize);
2309 let _ = writeln!(
2310 out,
2311 " {}{} {} {}",
2312 indent,
2313 s.arrow(),
2314 s.cyan(unit_name),
2315 s.dim(&format!("[id:{}]", node.unit_id))
2316 );
2317 }
2318 let _ = writeln!(out);
2319 }
2320 OutputFormat::Json => {
2321 let entries: Vec<serde_json::Value> = result
2322 .nodes
2323 .iter()
2324 .map(|n| {
2325 let unit_name = graph
2326 .get_unit(n.unit_id)
2327 .map(|u| u.qualified_name.clone())
2328 .unwrap_or_default();
2329 serde_json::json!({
2330 "unit_id": n.unit_id,
2331 "name": unit_name,
2332 "depth": n.depth,
2333 })
2334 })
2335 .collect();
2336 let obj = serde_json::json!({
2337 "query": "rdeps",
2338 "root_id": uid,
2339 "count": result.nodes.len(),
2340 "results": entries,
2341 });
2342 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2343 }
2344 }
2345 Ok(())
2346}
2347
2348fn query_impact(
2349 graph: &CodeGraph,
2350 engine: &QueryEngine,
2351 unit_id: Option<u64>,
2352 depth: u32,
2353 cli: &Cli,
2354 s: &Styled,
2355) -> Result<(), Box<dyn std::error::Error>> {
2356 let uid =
2357 unit_id.ok_or_else(|| format!("{} --unit-id is required for impact queries", s.fail()))?;
2358 let params = ImpactParams {
2359 unit_id: uid,
2360 max_depth: depth,
2361 edge_types: vec![],
2362 };
2363 let result = engine.impact_analysis(graph, params)?;
2364
2365 let stdout = std::io::stdout();
2366 let mut out = stdout.lock();
2367
2368 match cli.format {
2369 OutputFormat::Text => {
2370 let root_name = graph
2371 .get_unit(uid)
2372 .map(|u| u.qualified_name.as_str())
2373 .unwrap_or("?");
2374
2375 let risk_label = if result.overall_risk >= 0.7 {
2376 s.red("HIGH")
2377 } else if result.overall_risk >= 0.4 {
2378 s.yellow("MEDIUM")
2379 } else {
2380 s.green("LOW")
2381 };
2382
2383 let _ = writeln!(
2384 out,
2385 "\n Impact analysis for {} (risk: {})\n",
2386 s.bold(root_name),
2387 risk_label,
2388 );
2389 let _ = writeln!(
2390 out,
2391 " {} impacted units, overall risk {:.2}\n",
2392 result.impacted.len(),
2393 result.overall_risk
2394 );
2395 for imp in &result.impacted {
2396 let unit_name = graph
2397 .get_unit(imp.unit_id)
2398 .map(|u| u.qualified_name.as_str())
2399 .unwrap_or("?");
2400 let risk_sym = if imp.risk_score >= 0.7 {
2401 s.fail()
2402 } else if imp.risk_score >= 0.4 {
2403 s.warn()
2404 } else {
2405 s.ok()
2406 };
2407 let test_badge = if imp.has_tests {
2408 s.green("tested")
2409 } else {
2410 s.red("untested")
2411 };
2412 let _ = writeln!(
2413 out,
2414 " {} {} {} risk:{:.2} {}",
2415 risk_sym,
2416 s.cyan(unit_name),
2417 s.dim(&format!("(depth {})", imp.depth)),
2418 imp.risk_score,
2419 test_badge,
2420 );
2421 }
2422 if !result.recommendations.is_empty() {
2423 let _ = writeln!(out);
2424 for rec in &result.recommendations {
2425 let _ = writeln!(out, " {} {}", s.info(), rec);
2426 }
2427 }
2428 let _ = writeln!(out);
2429 }
2430 OutputFormat::Json => {
2431 let entries: Vec<serde_json::Value> = result
2432 .impacted
2433 .iter()
2434 .map(|imp| {
2435 serde_json::json!({
2436 "unit_id": imp.unit_id,
2437 "depth": imp.depth,
2438 "risk_score": imp.risk_score,
2439 "has_tests": imp.has_tests,
2440 })
2441 })
2442 .collect();
2443 let obj = serde_json::json!({
2444 "query": "impact",
2445 "root_id": uid,
2446 "count": result.impacted.len(),
2447 "overall_risk": result.overall_risk,
2448 "results": entries,
2449 "recommendations": result.recommendations,
2450 });
2451 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2452 }
2453 }
2454 Ok(())
2455}
2456
2457fn query_calls(
2458 graph: &CodeGraph,
2459 engine: &QueryEngine,
2460 unit_id: Option<u64>,
2461 depth: u32,
2462 cli: &Cli,
2463 s: &Styled,
2464) -> Result<(), Box<dyn std::error::Error>> {
2465 let uid =
2466 unit_id.ok_or_else(|| format!("{} --unit-id is required for calls queries", s.fail()))?;
2467 let params = CallGraphParams {
2468 unit_id: uid,
2469 direction: CallDirection::Both,
2470 max_depth: depth,
2471 };
2472 let result = engine.call_graph(graph, params)?;
2473
2474 let stdout = std::io::stdout();
2475 let mut out = stdout.lock();
2476
2477 match cli.format {
2478 OutputFormat::Text => {
2479 let root_name = graph
2480 .get_unit(uid)
2481 .map(|u| u.qualified_name.as_str())
2482 .unwrap_or("?");
2483 let _ = writeln!(
2484 out,
2485 "\n Call graph for {} ({} nodes)\n",
2486 s.bold(root_name),
2487 result.nodes.len()
2488 );
2489 for (nid, d) in &result.nodes {
2490 let unit_name = graph
2491 .get_unit(*nid)
2492 .map(|u| u.qualified_name.as_str())
2493 .unwrap_or("?");
2494 let indent = " ".repeat(*d as usize);
2495 let _ = writeln!(out, " {}{} {}", indent, s.arrow(), s.cyan(unit_name),);
2496 }
2497 let _ = writeln!(out);
2498 }
2499 OutputFormat::Json => {
2500 let entries: Vec<serde_json::Value> = result
2501 .nodes
2502 .iter()
2503 .map(|(nid, d)| {
2504 let unit_name = graph
2505 .get_unit(*nid)
2506 .map(|u| u.qualified_name.clone())
2507 .unwrap_or_default();
2508 serde_json::json!({
2509 "unit_id": nid,
2510 "name": unit_name,
2511 "depth": d,
2512 })
2513 })
2514 .collect();
2515 let obj = serde_json::json!({
2516 "query": "calls",
2517 "root_id": uid,
2518 "count": result.nodes.len(),
2519 "results": entries,
2520 });
2521 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2522 }
2523 }
2524 Ok(())
2525}
2526
2527fn query_similar(
2528 graph: &CodeGraph,
2529 engine: &QueryEngine,
2530 unit_id: Option<u64>,
2531 limit: usize,
2532 cli: &Cli,
2533 s: &Styled,
2534) -> Result<(), Box<dyn std::error::Error>> {
2535 let uid =
2536 unit_id.ok_or_else(|| format!("{} --unit-id is required for similar queries", s.fail()))?;
2537 let params = SimilarityParams {
2538 unit_id: uid,
2539 top_k: limit,
2540 min_similarity: 0.0,
2541 };
2542 let results = engine.similarity(graph, params)?;
2543
2544 let stdout = std::io::stdout();
2545 let mut out = stdout.lock();
2546
2547 match cli.format {
2548 OutputFormat::Text => {
2549 let root_name = graph
2550 .get_unit(uid)
2551 .map(|u| u.qualified_name.as_str())
2552 .unwrap_or("?");
2553 let _ = writeln!(
2554 out,
2555 "\n Similar to {} ({} matches)\n",
2556 s.bold(root_name),
2557 results.len()
2558 );
2559 for (i, m) in results.iter().enumerate() {
2560 let unit_name = graph
2561 .get_unit(m.unit_id)
2562 .map(|u| u.qualified_name.as_str())
2563 .unwrap_or("?");
2564 let score_str = format!("{:.2}%", m.score * 100.0);
2565 let _ = writeln!(
2566 out,
2567 " {:>3}. {} {} {}",
2568 s.dim(&format!("#{}", i + 1)),
2569 s.cyan(unit_name),
2570 s.dim(&format!("[id:{}]", m.unit_id)),
2571 s.yellow(&score_str),
2572 );
2573 }
2574 let _ = writeln!(out);
2575 }
2576 OutputFormat::Json => {
2577 let entries: Vec<serde_json::Value> = results
2578 .iter()
2579 .map(|m| {
2580 serde_json::json!({
2581 "unit_id": m.unit_id,
2582 "score": m.score,
2583 })
2584 })
2585 .collect();
2586 let obj = serde_json::json!({
2587 "query": "similar",
2588 "root_id": uid,
2589 "count": results.len(),
2590 "results": entries,
2591 });
2592 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2593 }
2594 }
2595 Ok(())
2596}
2597
2598fn query_prophecy(
2599 graph: &CodeGraph,
2600 engine: &QueryEngine,
2601 limit: usize,
2602 cli: &Cli,
2603 s: &Styled,
2604) -> Result<(), Box<dyn std::error::Error>> {
2605 let params = ProphecyParams {
2606 top_k: limit,
2607 min_risk: 0.0,
2608 };
2609 let result = engine.prophecy(graph, params)?;
2610
2611 let stdout = std::io::stdout();
2612 let mut out = stdout.lock();
2613
2614 match cli.format {
2615 OutputFormat::Text => {
2616 let _ = writeln!(
2617 out,
2618 "\n {} Code prophecy ({} predictions)\n",
2619 s.info(),
2620 result.predictions.len()
2621 );
2622 if result.predictions.is_empty() {
2623 let _ = writeln!(
2624 out,
2625 " {} No high-risk predictions. Codebase looks stable!",
2626 s.ok()
2627 );
2628 }
2629 for pred in &result.predictions {
2630 let unit_name = graph
2631 .get_unit(pred.unit_id)
2632 .map(|u| u.qualified_name.as_str())
2633 .unwrap_or("?");
2634 let risk_sym = if pred.risk_score >= 0.7 {
2635 s.fail()
2636 } else if pred.risk_score >= 0.4 {
2637 s.warn()
2638 } else {
2639 s.ok()
2640 };
2641 let _ = writeln!(
2642 out,
2643 " {} {} {}: {}",
2644 risk_sym,
2645 s.cyan(unit_name),
2646 s.dim(&format!("(risk {:.2})", pred.risk_score)),
2647 pred.reason,
2648 );
2649 }
2650 let _ = writeln!(out);
2651 }
2652 OutputFormat::Json => {
2653 let entries: Vec<serde_json::Value> = result
2654 .predictions
2655 .iter()
2656 .map(|p| {
2657 serde_json::json!({
2658 "unit_id": p.unit_id,
2659 "risk_score": p.risk_score,
2660 "reason": p.reason,
2661 })
2662 })
2663 .collect();
2664 let obj = serde_json::json!({
2665 "query": "prophecy",
2666 "count": result.predictions.len(),
2667 "results": entries,
2668 });
2669 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2670 }
2671 }
2672 Ok(())
2673}
2674
2675fn query_stability(
2676 graph: &CodeGraph,
2677 engine: &QueryEngine,
2678 unit_id: Option<u64>,
2679 cli: &Cli,
2680 s: &Styled,
2681) -> Result<(), Box<dyn std::error::Error>> {
2682 let uid = unit_id
2683 .ok_or_else(|| format!("{} --unit-id is required for stability queries", s.fail()))?;
2684 let result: StabilityResult = engine.stability_analysis(graph, uid)?;
2685
2686 let stdout = std::io::stdout();
2687 let mut out = stdout.lock();
2688
2689 match cli.format {
2690 OutputFormat::Text => {
2691 let root_name = graph
2692 .get_unit(uid)
2693 .map(|u| u.qualified_name.as_str())
2694 .unwrap_or("?");
2695
2696 let score_color = if result.overall_score >= 0.7 {
2697 s.green(&format!("{:.2}", result.overall_score))
2698 } else if result.overall_score >= 0.4 {
2699 s.yellow(&format!("{:.2}", result.overall_score))
2700 } else {
2701 s.red(&format!("{:.2}", result.overall_score))
2702 };
2703
2704 let _ = writeln!(
2705 out,
2706 "\n Stability of {}: {}\n",
2707 s.bold(root_name),
2708 score_color,
2709 );
2710 for factor in &result.factors {
2711 let _ = writeln!(
2712 out,
2713 " {} {} = {:.2}: {}",
2714 s.arrow(),
2715 s.bold(&factor.name),
2716 factor.value,
2717 s.dim(&factor.description),
2718 );
2719 }
2720 let _ = writeln!(out, "\n {} {}", s.info(), result.recommendation);
2721 let _ = writeln!(out);
2722 }
2723 OutputFormat::Json => {
2724 let factors: Vec<serde_json::Value> = result
2725 .factors
2726 .iter()
2727 .map(|f| {
2728 serde_json::json!({
2729 "name": f.name,
2730 "value": f.value,
2731 "description": f.description,
2732 })
2733 })
2734 .collect();
2735 let obj = serde_json::json!({
2736 "query": "stability",
2737 "unit_id": uid,
2738 "overall_score": result.overall_score,
2739 "factors": factors,
2740 "recommendation": result.recommendation,
2741 });
2742 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2743 }
2744 }
2745 Ok(())
2746}
2747
2748fn query_coupling(
2749 graph: &CodeGraph,
2750 engine: &QueryEngine,
2751 unit_id: Option<u64>,
2752 cli: &Cli,
2753 s: &Styled,
2754) -> Result<(), Box<dyn std::error::Error>> {
2755 let params = CouplingParams {
2756 unit_id,
2757 min_strength: 0.0,
2758 };
2759 let results = engine.coupling_detection(graph, params)?;
2760
2761 let stdout = std::io::stdout();
2762 let mut out = stdout.lock();
2763
2764 match cli.format {
2765 OutputFormat::Text => {
2766 let _ = writeln!(
2767 out,
2768 "\n Coupling analysis ({} pairs detected)\n",
2769 results.len()
2770 );
2771 if results.is_empty() {
2772 let _ = writeln!(out, " {} No tightly coupled pairs detected.", s.ok());
2773 }
2774 for c in &results {
2775 let name_a = graph
2776 .get_unit(c.unit_a)
2777 .map(|u| u.qualified_name.as_str())
2778 .unwrap_or("?");
2779 let name_b = graph
2780 .get_unit(c.unit_b)
2781 .map(|u| u.qualified_name.as_str())
2782 .unwrap_or("?");
2783 let strength_str = format!("{:.0}%", c.strength * 100.0);
2784 let _ = writeln!(
2785 out,
2786 " {} {} {} {} {}",
2787 s.warn(),
2788 s.cyan(name_a),
2789 s.dim("<->"),
2790 s.cyan(name_b),
2791 s.yellow(&strength_str),
2792 );
2793 }
2794 let _ = writeln!(out);
2795 }
2796 OutputFormat::Json => {
2797 let entries: Vec<serde_json::Value> = results
2798 .iter()
2799 .map(|c| {
2800 serde_json::json!({
2801 "unit_a": c.unit_a,
2802 "unit_b": c.unit_b,
2803 "strength": c.strength,
2804 "kind": format!("{:?}", c.kind),
2805 })
2806 })
2807 .collect();
2808 let obj = serde_json::json!({
2809 "query": "coupling",
2810 "count": results.len(),
2811 "results": entries,
2812 });
2813 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2814 }
2815 }
2816 Ok(())
2817}
2818
2819fn query_test_gap(
2820 graph: &CodeGraph,
2821 engine: &QueryEngine,
2822 limit: usize,
2823 cli: &Cli,
2824 s: &Styled,
2825) -> Result<(), Box<dyn std::error::Error>> {
2826 let mut gaps = engine.test_gap(
2827 graph,
2828 TestGapParams {
2829 min_changes: 5,
2830 min_complexity: 10,
2831 unit_types: vec![],
2832 },
2833 )?;
2834 if limit > 0 {
2835 gaps.truncate(limit);
2836 }
2837
2838 let stdout = std::io::stdout();
2839 let mut out = stdout.lock();
2840 match cli.format {
2841 OutputFormat::Text => {
2842 let _ = writeln!(out, "\n Test gaps ({} results)\n", gaps.len());
2843 for g in &gaps {
2844 let name = graph
2845 .get_unit(g.unit_id)
2846 .map(|u| u.qualified_name.as_str())
2847 .unwrap_or("?");
2848 let _ = writeln!(
2849 out,
2850 " {} {} priority:{:.2} {}",
2851 s.arrow(),
2852 s.cyan(name),
2853 g.priority,
2854 s.dim(&g.reason)
2855 );
2856 }
2857 let _ = writeln!(out);
2858 }
2859 OutputFormat::Json => {
2860 let rows = gaps
2861 .iter()
2862 .map(|g| {
2863 serde_json::json!({
2864 "unit_id": g.unit_id,
2865 "name": graph.get_unit(g.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
2866 "priority": g.priority,
2867 "reason": g.reason,
2868 })
2869 })
2870 .collect::<Vec<_>>();
2871 let obj = serde_json::json!({
2872 "query": "test-gap",
2873 "count": rows.len(),
2874 "results": rows,
2875 });
2876 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2877 }
2878 }
2879 Ok(())
2880}
2881
2882fn query_hotspots(
2883 graph: &CodeGraph,
2884 engine: &QueryEngine,
2885 limit: usize,
2886 cli: &Cli,
2887 s: &Styled,
2888) -> Result<(), Box<dyn std::error::Error>> {
2889 let hotspots = engine.hotspot_detection(
2890 graph,
2891 HotspotParams {
2892 top_k: limit,
2893 min_score: 0.55,
2894 unit_types: vec![],
2895 },
2896 )?;
2897
2898 let stdout = std::io::stdout();
2899 let mut out = stdout.lock();
2900 match cli.format {
2901 OutputFormat::Text => {
2902 let _ = writeln!(out, "\n Hotspots ({} results)\n", hotspots.len());
2903 for h in &hotspots {
2904 let name = graph
2905 .get_unit(h.unit_id)
2906 .map(|u| u.qualified_name.as_str())
2907 .unwrap_or("?");
2908 let _ = writeln!(out, " {} {} score:{:.2}", s.arrow(), s.cyan(name), h.score);
2909 }
2910 let _ = writeln!(out);
2911 }
2912 OutputFormat::Json => {
2913 let rows = hotspots
2914 .iter()
2915 .map(|h| {
2916 serde_json::json!({
2917 "unit_id": h.unit_id,
2918 "name": graph.get_unit(h.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
2919 "score": h.score,
2920 "factors": h.factors,
2921 })
2922 })
2923 .collect::<Vec<_>>();
2924 let obj = serde_json::json!({
2925 "query": "hotspots",
2926 "count": rows.len(),
2927 "results": rows,
2928 });
2929 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2930 }
2931 }
2932 Ok(())
2933}
2934
2935fn query_dead_code(
2936 graph: &CodeGraph,
2937 engine: &QueryEngine,
2938 limit: usize,
2939 cli: &Cli,
2940 s: &Styled,
2941) -> Result<(), Box<dyn std::error::Error>> {
2942 let mut dead = engine.dead_code(
2943 graph,
2944 DeadCodeParams {
2945 unit_types: vec![],
2946 include_tests_as_roots: true,
2947 },
2948 )?;
2949 if limit > 0 {
2950 dead.truncate(limit);
2951 }
2952
2953 let stdout = std::io::stdout();
2954 let mut out = stdout.lock();
2955 match cli.format {
2956 OutputFormat::Text => {
2957 let _ = writeln!(out, "\n Dead code ({} results)\n", dead.len());
2958 for unit in &dead {
2959 let _ = writeln!(
2960 out,
2961 " {} {} {}",
2962 s.arrow(),
2963 s.cyan(&unit.qualified_name),
2964 s.dim(&format!("({})", unit.unit_type.label()))
2965 );
2966 }
2967 let _ = writeln!(out);
2968 }
2969 OutputFormat::Json => {
2970 let rows = dead
2971 .iter()
2972 .map(|u| {
2973 serde_json::json!({
2974 "unit_id": u.id,
2975 "name": u.qualified_name,
2976 "unit_type": u.unit_type.label(),
2977 "file": u.file_path.display().to_string(),
2978 "line": u.span.start_line,
2979 })
2980 })
2981 .collect::<Vec<_>>();
2982 let obj = serde_json::json!({
2983 "query": "dead-code",
2984 "count": rows.len(),
2985 "results": rows,
2986 });
2987 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2988 }
2989 }
2990 Ok(())
2991}
2992
2993fn cmd_get(file: &Path, unit_id: u64, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
2998 let s = styled(cli);
2999 validate_acb_path(file)?;
3000 let graph = AcbReader::read_from_file(file)?;
3001
3002 let unit = graph.get_unit(unit_id).ok_or_else(|| {
3003 format!(
3004 "{} Unit {} not found\n {} Use 'acb query ... symbol' to find valid unit IDs",
3005 s.fail(),
3006 unit_id,
3007 s.info()
3008 )
3009 })?;
3010
3011 let outgoing = graph.edges_from(unit_id);
3012 let incoming = graph.edges_to(unit_id);
3013
3014 let stdout = std::io::stdout();
3015 let mut out = stdout.lock();
3016
3017 match cli.format {
3018 OutputFormat::Text => {
3019 let _ = writeln!(
3020 out,
3021 "\n {} {}",
3022 s.info(),
3023 s.bold(&format!("Unit {}", unit.id))
3024 );
3025 let _ = writeln!(out, " Name: {}", s.cyan(&unit.name));
3026 let _ = writeln!(out, " Qualified name: {}", s.bold(&unit.qualified_name));
3027 let _ = writeln!(out, " Type: {}", unit.unit_type);
3028 let _ = writeln!(out, " Language: {}", unit.language);
3029 let _ = writeln!(
3030 out,
3031 " File: {}",
3032 s.cyan(&unit.file_path.display().to_string())
3033 );
3034 let _ = writeln!(out, " Span: {}", unit.span);
3035 let _ = writeln!(out, " Visibility: {}", unit.visibility);
3036 let _ = writeln!(out, " Complexity: {}", unit.complexity);
3037 if unit.is_async {
3038 let _ = writeln!(out, " Async: {}", s.green("yes"));
3039 }
3040 if unit.is_generator {
3041 let _ = writeln!(out, " Generator: {}", s.green("yes"));
3042 }
3043
3044 let stability_str = format!("{:.2}", unit.stability_score);
3045 let stability_color = if unit.stability_score >= 0.7 {
3046 s.green(&stability_str)
3047 } else if unit.stability_score >= 0.4 {
3048 s.yellow(&stability_str)
3049 } else {
3050 s.red(&stability_str)
3051 };
3052 let _ = writeln!(out, " Stability: {}", stability_color);
3053
3054 if let Some(sig) = &unit.signature {
3055 let _ = writeln!(out, " Signature: {}", s.dim(sig));
3056 }
3057 if let Some(doc) = &unit.doc_summary {
3058 let _ = writeln!(out, " Doc: {}", s.dim(doc));
3059 }
3060
3061 if !outgoing.is_empty() {
3062 let _ = writeln!(
3063 out,
3064 "\n {} Outgoing edges ({})",
3065 s.arrow(),
3066 outgoing.len()
3067 );
3068 for edge in &outgoing {
3069 let target_name = graph
3070 .get_unit(edge.target_id)
3071 .map(|u| u.qualified_name.as_str())
3072 .unwrap_or("?");
3073 let _ = writeln!(
3074 out,
3075 " {} {} {}",
3076 s.arrow(),
3077 s.cyan(target_name),
3078 s.dim(&format!("({})", edge.edge_type))
3079 );
3080 }
3081 }
3082 if !incoming.is_empty() {
3083 let _ = writeln!(
3084 out,
3085 "\n {} Incoming edges ({})",
3086 s.arrow(),
3087 incoming.len()
3088 );
3089 for edge in &incoming {
3090 let source_name = graph
3091 .get_unit(edge.source_id)
3092 .map(|u| u.qualified_name.as_str())
3093 .unwrap_or("?");
3094 let _ = writeln!(
3095 out,
3096 " {} {} {}",
3097 s.arrow(),
3098 s.cyan(source_name),
3099 s.dim(&format!("({})", edge.edge_type))
3100 );
3101 }
3102 }
3103 let _ = writeln!(out);
3104 }
3105 OutputFormat::Json => {
3106 let out_edges: Vec<serde_json::Value> = outgoing
3107 .iter()
3108 .map(|e| {
3109 serde_json::json!({
3110 "target_id": e.target_id,
3111 "edge_type": e.edge_type.label(),
3112 "weight": e.weight,
3113 })
3114 })
3115 .collect();
3116 let in_edges: Vec<serde_json::Value> = incoming
3117 .iter()
3118 .map(|e| {
3119 serde_json::json!({
3120 "source_id": e.source_id,
3121 "edge_type": e.edge_type.label(),
3122 "weight": e.weight,
3123 })
3124 })
3125 .collect();
3126 let obj = serde_json::json!({
3127 "id": unit.id,
3128 "name": unit.name,
3129 "qualified_name": unit.qualified_name,
3130 "unit_type": unit.unit_type.label(),
3131 "language": unit.language.name(),
3132 "file": unit.file_path.display().to_string(),
3133 "span": unit.span.to_string(),
3134 "visibility": unit.visibility.to_string(),
3135 "complexity": unit.complexity,
3136 "is_async": unit.is_async,
3137 "is_generator": unit.is_generator,
3138 "stability_score": unit.stability_score,
3139 "signature": unit.signature,
3140 "doc_summary": unit.doc_summary,
3141 "outgoing_edges": out_edges,
3142 "incoming_edges": in_edges,
3143 });
3144 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
3145 }
3146 }
3147
3148 Ok(())
3149}