From 83ed7a0769d29e8012838ad4dde3cfabd58a4a96 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Wed, 6 May 2026 00:16:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(scan):=20no-write=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan에 --no-write 경로를 추가해 기본 리포트 파일을 생성하지 않고 human summary를 출력하게 했습니다. JSON stdout 순수성, --output 병용 거부, 기본 .kratos 쓰기 안내와 npx next step 안내를 테스트로 고정했습니다. Closes #75 --- crates/kratos-cli/src/commands/mod.rs | 13 +++- crates/kratos-cli/src/commands/scan.rs | 83 ++++++++++++++++++++++--- crates/kratos-cli/tests/cli_smoke.rs | 86 +++++++++++++++++++++++++- 3 files changed, 172 insertions(+), 10 deletions(-) diff --git a/crates/kratos-cli/src/commands/mod.rs b/crates/kratos-cli/src/commands/mod.rs index f8e20d2..5e6d70a 100644 --- a/crates/kratos-cli/src/commands/mod.rs +++ b/crates/kratos-cli/src/commands/mod.rs @@ -210,13 +210,20 @@ pub fn parse_cli_options( } pub fn canonicalize_scan_args(raw_args: &[String]) -> KratosResult> { - let parsed = parse_cli_options(raw_args, &["output"], &["json"]); + let parsed = parse_cli_options(raw_args, &["output"], &["json", "no-write"]); let mut args = Vec::new(); + let no_write = is_enabled_boolean_flag(parsed.flags.get("no-write")); if let Some(root) = parsed.positionals.first() { args.push(root.clone()); } + if no_write && parsed.flags.contains_key("output") { + return Err(KratosError::Config( + "--output and --no-write cannot be used together".to_string(), + )); + } + if let Some(value) = parsed.flags.get("output").and_then(flag_value_as_string) { if !value.is_empty() { args.push("--output".to_string()); @@ -232,6 +239,10 @@ pub fn canonicalize_scan_args(raw_args: &[String]) -> KratosResult> args.push("--json".to_string()); } + if no_write { + args.push("--no-write".to_string()); + } + Ok(args) } diff --git a/crates/kratos-cli/src/commands/scan.rs b/crates/kratos-cli/src/commands/scan.rs index 827351a..0f84fab 100644 --- a/crates/kratos-cli/src/commands/scan.rs +++ b/crates/kratos-cli/src/commands/scan.rs @@ -17,7 +17,7 @@ pub const NAME: &str = "scan"; pub const SPEC: CommandSpec = CommandSpec { name: NAME, summary: "Analyze a codebase and save the latest report.", - usage: &["kratos scan [root] [--output path] [--json]"], + usage: &["kratos scan [root] [--output path] [--no-write] [--json]"], }; #[derive(Debug, Parser)] @@ -28,6 +28,8 @@ struct ScanArgs { #[arg(long, allow_hyphen_values = true)] output: Option, #[arg(long)] + no_write: bool, + #[arg(long)] json: bool, } @@ -38,24 +40,30 @@ pub fn run(args: &[String], stdout: &mut dyn Write) -> KratosResult { Some(raw) => resolve_input_path(&cwd, raw), None => cwd, }; + let uses_default_output_path = args.output.is_none(); let output_path = resolve_output_path(&root, args.output.as_deref()); let report = analyze_project(&root)?; let serialized = serialize_report_pretty(&report)?; - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent)?; + if !args.no_write { + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&output_path, format!("{serialized}\n"))?; } - fs::write(&output_path, format!("{serialized}\n"))?; if args.json { write_output(stdout, &serialized)?; return Ok(0); } - write_output( - stdout, - &format_summary_report(&report, &output_path, "Kratos scan complete.")?, + let summary = format_scan_summary( + &report, + &output_path, + args.no_write, + uses_default_output_path, )?; + write_output(stdout, &summary)?; Ok(0) } @@ -65,6 +73,67 @@ fn parse_args(args: &[String]) -> KratosResult { .map_err(|error| KratosError::Config(error.to_string().trim().to_string())) } +fn format_scan_summary( + report: &kratos_core::model::ReportV2, + output_path: &Path, + no_write: bool, + uses_default_output_path: bool, +) -> KratosResult { + let summary = format_summary_report(report, output_path, "Kratos scan complete.")?; + let mut lines = Vec::new(); + + for line in summary.lines() { + if no_write { + if line.starts_with("저장된 리포트: ") { + lines + .push("리포트 저장: --no-write 때문에 파일을 생성하지 않았습니다.".to_string()); + continue; + } + + if line.starts_with("- 정리 미리보기: ") || line.starts_with("- 공유용 Markdown: ") + { + continue; + } + } + + lines.push(line.to_string()); + + if no_write && line == "다음 단계:" { + lines.push( + "- 리포트가 필요한 clean/report 작업은 기본 쓰기 모드로 다시 실행하거나 --output path를 지정하세요." + .to_string(), + ); + } + + if !no_write && uses_default_output_path && line.starts_with("저장된 리포트: ") { + lines.push( + "쓰기 안내: 기본 리포트 경로 .kratos/latest-report.json는 체크아웃을 dirty하게 만들 수 있습니다. .gitignore에 .kratos/를 추가하거나 --output 또는 --no-write를 사용하세요." + .to_string(), + ); + } + + if !no_write && line.starts_with("- 정리 미리보기: kratos clean ") { + let report_arg = line + .strip_prefix("- 정리 미리보기: kratos clean ") + .unwrap_or_default(); + lines.push(format!( + "- npx로 실행 중이라면: npx @jeremyfellaz/kratos clean {report_arg}" + )); + } + + if !no_write && line.starts_with("- 공유용 Markdown: kratos report ") { + let report_args = line + .strip_prefix("- 공유용 Markdown: kratos report ") + .unwrap_or_default(); + lines.push(format!( + "- npx로 실행 중이라면: npx @jeremyfellaz/kratos report {report_args}" + )); + } + } + + Ok(lines.join("\n")) +} + fn resolve_output_path(root: &Path, output_flag: Option<&str>) -> PathBuf { match output_flag { Some(raw) => resolve_input_path(root, raw), diff --git a/crates/kratos-cli/tests/cli_smoke.rs b/crates/kratos-cli/tests/cli_smoke.rs index a8077f0..602499c 100644 --- a/crates/kratos-cli/tests/cli_smoke.rs +++ b/crates/kratos-cli/tests/cli_smoke.rs @@ -10,7 +10,7 @@ fn root_help_matches_expected_shape() { assert!(output.status.success()); assert_eq!( String::from_utf8_lossy(&output.stdout), - "Kratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n" + "Kratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--no-write] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n" ); } @@ -32,7 +32,7 @@ fn unknown_command_returns_help_and_exit_code_one() { assert_eq!(output.status.code(), Some(1)); assert_eq!( String::from_utf8_lossy(&output.stderr), - "알 수 없는 명령: nope\n\nKratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n" + "알 수 없는 명령: nope\n\nKratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--no-write] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n" ); } @@ -56,6 +56,9 @@ fn scan_report_and_clean_work_for_demo_fixture() { assert!(scan_stdout.contains("라우트 진입점: 1")); assert!(scan_stdout.contains("삭제 후보: 2")); assert!(scan_stdout.contains("다음 단계:")); + assert!(scan_stdout.contains("쓰기 안내: 기본 리포트 경로 .kratos/latest-report.json는 체크아웃을 dirty하게 만들 수 있습니다. .gitignore에 .kratos/를 추가하거나 --output 또는 --no-write를 사용하세요.")); + assert!(scan_stdout.contains("npx로 실행 중이라면: npx @jeremyfellaz/kratos clean")); + assert!(scan_stdout.contains("npx로 실행 중이라면: npx @jeremyfellaz/kratos report")); assert!(scan_stdout.contains("상위 정리 후보:")); assert!(scan_stdout.contains("- src/components/DeadWidget.tsx")); assert!(scan_stdout.contains( @@ -105,6 +108,85 @@ fn scan_report_and_clean_work_for_demo_fixture() { assert!(clean_stdout.contains("삭제하려면 --apply로 다시 실행하세요.")); } +#[test] +fn scan_no_write_prints_human_summary_without_creating_default_report() { + let project_root = copy_demo_app("cli-no-write"); + let report_path = project_root.join(".kratos/latest-report.json"); + + let scan = run_cli_in_dir(&project_root, &["scan", "--no-write"]); + + assert!(scan.status.success()); + let scan_stdout = String::from_utf8_lossy(&scan.stdout); + assert!(scan_stdout.contains("Kratos 스캔 완료.")); + assert!(scan_stdout.contains( + "영향: 즉시 수정 대상 1개: 깨진 import 1개. 자동 정리 후보 2개: 삭제 후보 2개. 수동 검토 대상 3개: 사용되지 않는 export 3개." + )); + assert!(scan_stdout.contains("리포트 저장: --no-write 때문에 파일을 생성하지 않았습니다.")); + assert!(scan_stdout.contains( + "리포트가 필요한 clean/report 작업은 기본 쓰기 모드로 다시 실행하거나 --output path를 지정하세요." + )); + assert!(!scan_stdout.contains("정리 미리보기: kratos clean")); + assert!(!scan_stdout.contains("공유용 Markdown: kratos report")); + assert!(!report_path.exists()); + assert!(!project_root.join(".kratos").exists()); +} + +#[test] +fn scan_no_write_rejects_output_flag() { + let project_root = copy_demo_app("cli-no-write-output-conflict"); + + let scan = run_cli_in_dir( + &project_root, + &["scan", "--no-write", "--output", "report.json"], + ); + + assert_eq!(scan.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&scan.stderr).contains( + "Kratos 실행 실패: Config error: --output and --no-write cannot be used together" + )); + assert!(!project_root.join("report.json").exists()); + assert!(!project_root.join(".kratos").exists()); + + let empty_output = run_cli_in_dir(&project_root, &["scan", "--no-write", "--output="]); + assert_eq!(empty_output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&empty_output.stderr).contains( + "Kratos 실행 실패: Config error: --output and --no-write cannot be used together" + )); +} + +#[test] +fn scan_json_stdout_stays_json_only() { + let project_root = copy_demo_app("cli-json-only"); + + let scan = run_cli_in_dir(&project_root, &["scan", "--json"]); + + assert!(scan.status.success()); + let report: serde_json::Value = + serde_json::from_slice(&scan.stdout).expect("scan json should parse"); + assert_eq!(report["summary"]["filesScanned"], 5); + let scan_stdout = String::from_utf8_lossy(&scan.stdout); + assert!(!scan_stdout.contains("쓰기 안내")); + assert!(!scan_stdout.contains("npx로 실행 중이라면")); +} + +#[test] +fn scan_json_no_write_stays_json_only_without_creating_default_report() { + let project_root = copy_demo_app("cli-json-no-write"); + let report_path = project_root.join(".kratos/latest-report.json"); + + let scan = run_cli_in_dir(&project_root, &["scan", "--json", "--no-write"]); + + assert!(scan.status.success()); + let report: serde_json::Value = + serde_json::from_slice(&scan.stdout).expect("scan json should parse"); + assert_eq!(report["summary"]["filesScanned"], 5); + let scan_stdout = String::from_utf8_lossy(&scan.stdout); + assert!(!scan_stdout.contains("쓰기 안내")); + assert!(!scan_stdout.contains("npx로 실행 중이라면")); + assert!(!report_path.exists()); + assert!(!project_root.join(".kratos").exists()); +} + #[test] fn scan_does_not_reopen_node_modules_for_broad_negated_ignore_patterns() { let project_root = copy_demo_app("cli-node-modules-ignore");