diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1c31409df..de0cdadb05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,9 @@ jobs: - name: Validate the minimal analysis and test pipeline if: steps.scope.outputs.run_code == 'true' run: python3 scripts/check_minimal.py + - name: Check mixed library and binary targets + if: steps.scope.outputs.run_code == 'true' + run: python3 scripts/check_mixed.py - name: Check generation and repair task limits if: steps.scope.outputs.run_code == 'true' run: python3 scripts/check_tasks.py diff --git a/Cargo.lock b/Cargo.lock index 59d7907854..a1b447f197 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,6 +499,7 @@ version = "0.1.0" dependencies = [ "build-utils", "call_chain", + "cargo_metadata 0.18.1", "clap", "prettyplease", "proc-macro2", diff --git a/brinfo/README.md b/brinfo/README.md index 02c7a75b7f..33dc7424b2 100644 --- a/brinfo/README.md +++ b/brinfo/README.md @@ -25,7 +25,7 @@ cargo clean cargo brinfo ``` -The wrapper analyzes the crate's `lib` and `bin` targets through `cargo check`. Clearing the build cache ensures the compiler analysis runs even if the crate was previously checked. Use a fresh working copy without previous analysis output when repeating an experiment; `cargo clean` does not remove PALM's output files. +The wrapper analyzes a single ordinary `lib` or `bin` target through `cargo check`. For packages with both or with multiple binaries, use `utgen analyze`; it runs each target separately and combines the results with library preference for shared source definitions. Clearing the build cache ensures the compiler analysis runs even if the crate was previously checked. Use a fresh working copy without previous analysis output when repeating an experiment; `cargo clean` does not remove PALM's output files. ## Output diff --git a/brinfo/src/bin/cargo-brinfo.rs b/brinfo/src/bin/cargo-brinfo.rs index 063365207d..d2422795b5 100644 --- a/brinfo/src/bin/cargo-brinfo.rs +++ b/brinfo/src/bin/cargo-brinfo.rs @@ -177,12 +177,28 @@ fn in_cargo_brinfo() { let verbose = has_arg_flag("-v"); let current_crate = current_crate(); + if std::env::var_os("PALM_TARGET_NAME").is_none() + && current_crate + .targets + .iter() + .filter(|t| t.kind.iter().any(|k| k == "lib" || k == "bin")) + .count() + > 1 + { + show_error( + "Multiple Cargo targets: use utgen analyze to preserve target ownership".to_owned(), + ); + } let path = Path::new(¤t_crate.manifest_path).parent().unwrap(); let path_str = path.to_str().unwrap(); // Now run the command. for target in current_crate.targets.into_iter() { + if let Ok(selected) = std::env::var("PALM_TARGET_NAME") { + let kind = std::env::var("PALM_TARGET_KIND").unwrap(); + if target.name != selected || !target.kind.contains(&kind) { continue; } + } let mut args = std::env::args().skip(2); let kind = target .kind @@ -197,7 +213,7 @@ fn in_cargo_brinfo() { info!("Kind of target {:?} is: {}.", target.name, kind); match kind.as_str() { "bin" => { - cmd.arg("--bin").arg(target.name); + cmd.arg("--bin").arg(&target.name); } "lib" => { cmd.arg("--lib"); @@ -226,7 +242,9 @@ fn in_cargo_brinfo() { "BRINFO_ARGS", serde_json::to_string(&args_vec).expect("failed to serialize args"), ); - cmd.env("BRINFO_TOP_CRATE_NAME", current_crate.name.clone()); + cmd.env("BRINFO_TOP_CRATE_NAME", &target.name); + cmd.env("BRINFO_TOP_CRATE_KIND", kind); + cmd.env("BRINFO_TOP_CRATE_SRC", target.src_path.as_str()); // Replace the rustc executable through RUSTC_WRAPPER environment variable let path = std::env::current_exe().expect("current executable path invalid"); @@ -237,7 +255,7 @@ fn in_cargo_brinfo() { eprintln!("+ {:?}", cmd); } - cmd.env("BRINFO_CRATE_DIR", path_str); + cmd.env("BRINFO_CRATE_DIR", std::env::var_os("PALM_OUTPUT_DIR").unwrap_or_else(|| path_str.into())); // Execute cmd let exit_status = cmd @@ -269,7 +287,15 @@ fn inside_cargo_rustc() { std::env::var("BRINFO_TOP_CRATE_NAME").expect("missing BRINFO_TOP_CRATE_NAME"); let top_crate_name = top_crate_name.replace("-", "_"); // Cargo seems to rename hyphens to underscores - if get_arg_flag_value("--crate-name").as_deref() == Some(&top_crate_name) { + let kind = std::env::var("BRINFO_TOP_CRATE_KIND").unwrap(); + let entry = std::fs::canonicalize(std::env::var_os("BRINFO_TOP_CRATE_SRC").unwrap()).unwrap(); + let is_entry = std::env::args() + .skip(2) + .any(|arg| std::fs::canonicalize(arg).is_ok_and(|path| path == entry)); + if get_arg_flag_value("--crate-name").as_deref() == Some(&top_crate_name) + && get_arg_flag_value("--crate-type").as_deref() == Some(&kind) + && is_entry + { // If we are analyzing the crate that we want to analyze, add args for `brinfo` let magic = std::env::var("BRINFO_ARGS").expect("missing BRINFO_ARGS"); let brinfo_args: Vec = diff --git a/docs/minimal-pipeline.md b/docs/minimal-pipeline.md index db11634433..0422d6181e 100644 --- a/docs/minimal-pipeline.md +++ b/docs/minimal-pipeline.md @@ -19,15 +19,17 @@ Run the related checks when changing coverage/preprocessing or task scheduling: python3 scripts/check_coverage.py cargo test -p utgen --locked coverage_baselines_match_test_build_modes -- --ignored python3 scripts/check_tasks.py +python3 scripts/check_mixed.py ``` -All three scripts are included in the full Linux CI suite. Their options differ: +All four scripts are included in the full Linux CI suite. Their options differ: | Script | Options | | --- | --- | | `check_minimal.py` | `--bin-dir `; `--work-dir `; `--analysis-only` to stop after analysis checks. | | `check_coverage.py` | `--bin-dir `. | | `check_tasks.py` | `--bin-dir `. | +| `check_mixed.py` | `--bin-dir `. | The default binary directory is `target/debug`. The coverage tool must be available in `PATH` at the version specified in the prerequisites. `--work-dir` must name a new directory. @@ -45,6 +47,7 @@ The default binary directory is `target/debug`. The coverage tool must be availa | Statistics | Oracle groups count once across multiple candidates; pass results match complete test names; all-compilation-failure results retain a zero-hit denominator without a synthetic candidate. A passing integration candidate that does not call the focal function has zero focal coverage. | | Coverage baselines | No-test unit/library and binary harnesses; ordinary-library integration maps; conditional compilation, generics, no-map targets, per-target reuse, separation from real profiles, and source/helper restoration after build failure. | | Integration repair | Repairs alias/trait imports, helpers, and test bodies in integration targets; keeps sibling candidates independent; checks cached repair, mode mismatch, unselected targets, and cleanup after request failure. | +| Mixed targets | Shared definitions prefer lib; same-name distinct definitions and bin-only functions remain separate. Checks cover dependency context, custom target names/entries, generic/trait methods, multiple binaries, generation, repair with warnings, cached results, automatic integration selection, and legacy mixed-cache rejection. | | Integration directories | Original and generated tests coexist after normal runs and cached reruns; original-test compilation failure restores both directories; an existing staging directory is preserved and reported. | The integration-directory cases retain the original `tests.bak/` while generating `tests/`. They use one cached candidate and make no additional model requests. Separate timeout cases also check integration execution without an original-test backup. diff --git a/docs/palm-rust-unit-test-generation.md b/docs/palm-rust-unit-test-generation.md index 82b13ed2a4..39752d7f63 100644 --- a/docs/palm-rust-unit-test-generation.md +++ b/docs/palm-rust-unit-test-generation.md @@ -424,7 +424,9 @@ export PALM_CONFIG="$(pwd)/utgen/res/api.json" 最终统计以 `utgen/result/` 和 `utgen/fixed_result/` 中的 JSON 为准;当前实现不生成 HTML 报告。`coverage_available=false` 表示没有取得被测函数的覆盖映射,覆盖数值仅为占位,不应参与汇总,也不能直接解释为条件编译排除了函数。旧结果缺少该字段时,其可用性未确认。`tests_*` 按候选计数,`oracles_*` 按原有 TestInfo 分组计数;一组中有候选通过,该组即计为通过。运行数保留包含 ignored 条目的既有口径。 -候选覆盖率缺少被测函数映射时,统计会补采集零命中基线。单元测试使用 `--lib` 或 `--bin` 的测试编译产物,过滤全部测试,不再插入空测试;集成测试从普通库产物取得映射,因此能够保留 `#[cfg(not(test))]` 下的函数和分支。基线不增加测试或 oracle 计数,不生成候选覆盖记录,也不混入真实命中数据;同一轮统计按目标复用基线,编译或工具错误仍然报错。函数级集成测试需要库目标;同名库与二进制目标共享模块、无法唯一确定函数归属时,会在模型请求前拒绝执行,完整支持需要进一步完善分析索引中的目标身份。 +候选覆盖率缺少被测函数映射时,统计会补采集零命中基线。单元测试使用 `--lib` 或 `--bin` 的测试编译产物,过滤全部测试,不再插入空测试;集成测试从普通库产物取得映射,因此能够保留 `#[cfg(not(test))]` 下的函数和分支。基线不增加测试或 oracle 计数,不生成候选覆盖记录,也不混入真实命中数据;同一轮统计按目标复用基线,编译或工具错误仍然报错。分析会分别保存 lib/bin 产物,再汇总被测函数:共享源码定义优先归 lib,独有定义保留原目标,同名但不同位置的定义不会合并。候选与统计保存目标身份,单元测试编译、修复和覆盖率始终使用相应 `--lib` 或 `--bin`。bin 的库依赖上下文单独保留,选择 lib 版本不代表已验证全部 bin 版本。 + +混合目标的函数索引包含目标限定,如 `lib:demo::demo::shared::parse`;`--functions-file` 也接受能唯一定位的原 Rust 路径。统计中的 `function_name` 是索引标识,`rust_name` 是原 Rust 路径,`target` 记录目标。函数级集成模式自动选择库函数并报告未参与的 bin 函数;显式指定 bin 函数会在模型请求前报错。Cargo 仍可能为集成测试构建 bin,其构建错误需要正常处理。旧单目标缓存继续兼容,旧混合目标产物需要在新工作副本中重新分析和生成。 ### 8.7 LLM 修复流程 diff --git a/examples/mixed-targets/Cargo.toml b/examples/mixed-targets/Cargo.toml new file mode 100644 index 0000000000..3dfc140ad1 --- /dev/null +++ b/examples/mixed-targets/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "mixed_fixture" +version = "0.1.0" +edition = "2021" +publish = false + +[workspace] diff --git a/examples/mixed-targets/README.md b/examples/mixed-targets/README.md new file mode 100644 index 0000000000..7cb90b1e31 --- /dev/null +++ b/examples/mixed-targets/README.md @@ -0,0 +1,11 @@ +# Mixed-target fixture + +This standalone package is used by `scripts/check_mixed.py`. The library and binary both include `shared.rs`, while each defines its own `helper`. Their different results expose incorrect target selection. The binary also calls the library and uses a library type. + +From the PALM repository root, after building the tools and installing the pinned coverage tool: + +```sh +python3 scripts/check_mixed.py +``` + +The script works in temporary copies with fixed local model responses. It checks analysis ownership, dependency context, unit and integration statistics, compilation repair, and source restoration. Additional temporary variants cover custom Cargo targets and definitions shared between binaries. The fixture's `main` panics so accidental execution is visible; the generated unit-test pipeline must use the test harness. diff --git a/examples/mixed-targets/rust-toolchain.toml b/examples/mixed-targets/rust-toolchain.toml new file mode 100644 index 0000000000..36437477b3 --- /dev/null +++ b/examples/mixed-targets/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2025-03-19" +components = ["rust-src", "rustc-dev", "llvm-tools-preview", "rust-analyzer"] diff --git a/examples/mixed-targets/src/lib.rs b/examples/mixed-targets/src/lib.rs new file mode 100644 index 0000000000..454452e852 --- /dev/null +++ b/examples/mixed-targets/src/lib.rs @@ -0,0 +1,13 @@ +pub mod shared; + +pub fn helper(value: i32) -> i32 { + value + 10 +} + +pub fn lib_only(value: i32) -> i32 { + shared::classify(value) +} + +pub struct Subject { + pub value: i32, +} diff --git a/examples/mixed-targets/src/main.rs b/examples/mixed-targets/src/main.rs new file mode 100644 index 0000000000..1f0d119198 --- /dev/null +++ b/examples/mixed-targets/src/main.rs @@ -0,0 +1,17 @@ +mod shared; + +fn helper(value: i32) -> i32 { + value - 10 +} + +fn bin_only(value: i32) -> i32 { + shared::classify(value) + mixed_fixture::helper(value) +} + +fn library_type(subject: mixed_fixture::Subject) -> i32 { + subject.value +} + +fn main() { + panic!("the unit-test pipeline must not execute main"); +} diff --git a/examples/mixed-targets/src/shared.rs b/examples/mixed-targets/src/shared.rs new file mode 100644 index 0000000000..de852b28f9 --- /dev/null +++ b/examples/mixed-targets/src/shared.rs @@ -0,0 +1,7 @@ +pub fn classify(value: i32) -> i32 { + if value > 0 { + crate::helper(value) + } else { + 0 + } +} diff --git a/focxt/Cargo.toml b/focxt/Cargo.toml index 1c698b7761..c7b07d6936 100644 --- a/focxt/Cargo.toml +++ b/focxt/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true # members = ["call_chain"] [dependencies] +cargo_metadata = "0.18" call_chain = { path = "call_chain" } clap = { version = "4.5", features = ["derive"] } prettyplease = "0.2" diff --git a/focxt/README.md b/focxt/README.md index 4e1bf1eeeb..e1f043d122 100644 --- a/focxt/README.md +++ b/focxt/README.md @@ -18,7 +18,7 @@ focxt invokes `cargo call-chain` at runtime, so install [call_chain](call_chain/ Use a working copy of a standalone crate with the same pinned toolchain. For the test-generation workflow, [preprocess and analyze with utgen](../utgen/README.md#analysis): it runs both brinfo and focxt and validates their outputs. -To construct context directly from prepared source: +For a single-target package, construct context directly from prepared source: ```sh focxt --crate /absolute/path/to/target-crate @@ -26,6 +26,8 @@ focxt --crate /absolute/path/to/target-crate `-c` is the short form of `--crate`. focxt runs `cargo clean` and `cargo call-chain` inside the target before building context; running call-chain separately is unnecessary for this command. Start with a fresh working copy when repeating analysis, since build-cache cleanup does not remove previous context artifacts. +Mixed packages use `utgen analyze`, which selects each Cargo entry and preserves separate context data. Binary contexts keep local definitions separate from the package library; referenced library functions and types are included in a labeled dependency section. Library preference applies to the final focal list, without discarding binary dependency information. + ## Output Paths are relative to the target crate: diff --git a/focxt/call_chain/README.md b/focxt/call_chain/README.md index dacc87979d..21d3a88496 100644 --- a/focxt/call_chain/README.md +++ b/focxt/call_chain/README.md @@ -25,7 +25,7 @@ cargo clean cargo call-chain ``` -The wrapper analyzes `lib` and `bin` targets through `cargo check`. Clearing the build cache ensures analysis executes after an earlier check; it does not remove previous `focxt/` output. +The wrapper analyzes a single ordinary `lib` or `bin` target through `cargo check`. Use `utgen analyze` for mixed packages so each target keeps separate artifacts. Calls and types from the package library retain their dependency origin when analyzing a binary. Clearing the build cache ensures analysis executes after an earlier check; it does not remove previous `focxt/` output. ## Output diff --git a/focxt/call_chain/src/analysis/callback.rs b/focxt/call_chain/src/analysis/callback.rs index b91141f3e0..c4400a5176 100644 --- a/focxt/call_chain/src/analysis/callback.rs +++ b/focxt/call_chain/src/analysis/callback.rs @@ -111,6 +111,11 @@ impl CallChainCallbacks { // println!("{}", mod_info.name); impl_informations.push(impl_information.clone()); let mut calls: HashSet = HashSet::new(); + let mut library_calls = HashSet::new(); + let mut library_types = HashSet::new(); + let library = std::env::var("PALM_LIBRARY_NAME").ok(); + let from_library = |id: rustc_hir::def_id::DefId| !id.is_local() + && library.as_deref() == Some(tcx.crate_name(id.krate).as_str()); let mut tys: HashSet> = HashSet::new(); let mut types: HashSet = HashSet::new(); for basic_block in basic_blocks.iter() { @@ -129,7 +134,17 @@ impl CallChainCallbacks { let kind_strings: Vec<&str> = kind_string.splitn(3, ' ').into_iter().collect(); let kind_string = kind_strings[2]; let call_string = &kind_string[..kind_string.find("(").unwrap()]; - calls.insert(call_string.to_string()); + let dependency = match func { + Operand::Constant(constant) => match constant.ty().kind() { + TyKind::FnDef(id, _) if from_library(*id) => { + library_calls.insert(super::hirvisitor::function_name(tcx, *id)); + true + } + _ => false, + }, + _ => false, + }; + if !dependency { calls.insert(call_string.to_string()); } for arg in args.iter() { if let Operand::Constant(constant) = &arg.node { @@ -152,6 +167,12 @@ impl CallChainCallbacks { collect_subtypes(local_decl.ty, tcx, &mut tys); } for ty in tys.iter() { + if let TyKind::Adt(adt, _) = ty.kind() { + if from_library(adt.did()) { + library_types.insert(tcx.def_path_str(adt.did())); + continue; + } + } types.insert(ty.to_string()); } // println!("Types:"); @@ -178,7 +199,11 @@ impl CallChainCallbacks { // for new_call in new_calls.iter() { // calls.insert(new_call.clone()); // } - let calls_and_types = CallsAndTypes::new(&mod_info.name, &calls, &types); + let mut calls_and_types = CallsAndTypes::new(&mod_info.name, &calls, &types); + calls_and_types.library_calls = library_calls.into_iter().collect(); + calls_and_types.library_types = library_types.into_iter().collect(); + calls_and_types.library_calls.sort(); + calls_and_types.library_types.sort(); let directory_path = self.crate_path.join("focxt/callsandtypes"); create_dir_all(&directory_path).unwrap(); let file_path = PathBuf::from(&directory_path) diff --git a/focxt/call_chain/src/analysis/exporter.rs b/focxt/call_chain/src/analysis/exporter.rs index 348b304ae1..14a66fa7f8 100644 --- a/focxt/call_chain/src/analysis/exporter.rs +++ b/focxt/call_chain/src/analysis/exporter.rs @@ -179,6 +179,10 @@ impl Cond { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CallsAndTypes { + #[serde(default)] + pub library_calls: Vec, + #[serde(default)] + pub library_types: Vec, pub mod_name: String, pub calls: Vec, pub types: Vec, @@ -187,6 +191,8 @@ pub struct CallsAndTypes { impl CallsAndTypes { pub fn new_empty() -> Self { CallsAndTypes { + library_calls: Vec::new(), + library_types: Vec::new(), mod_name: String::new(), calls: Vec::new(), types: Vec::new(), @@ -203,6 +209,8 @@ impl CallsAndTypes { types_vec.push(a_type.clone()); } CallsAndTypes { + library_calls: Vec::new(), + library_types: Vec::new(), mod_name: mod_name.clone(), calls: calls_vec, types: types_vec, @@ -210,6 +218,16 @@ impl CallsAndTypes { } pub fn add_data(&mut self, data: &CallsAndTypes) { + for name in &data.library_calls { + if !self.library_calls.contains(name) { + self.library_calls.push(name.clone()); + } + } + for name in &data.library_types { + if !self.library_types.contains(name) { + self.library_types.push(name.clone()); + } + } for other_call in data.calls.iter() { if !self.calls.contains(other_call) { self.calls.push(other_call.clone()); diff --git a/focxt/call_chain/src/analysis/hirvisitor.rs b/focxt/call_chain/src/analysis/hirvisitor.rs index 213f776fb4..32bcf61c13 100644 --- a/focxt/call_chain/src/analysis/hirvisitor.rs +++ b/focxt/call_chain/src/analysis/hirvisitor.rs @@ -23,6 +23,28 @@ fn encoded_name(s: &str) -> String { base62::encode(XxHash3_64::oneshot(s.as_bytes())) } +pub fn function_name(tcx: TyCtxt<'_>, def_id: rustc_hir::def_id::DefId) -> String { + let mut path = tcx.def_path(def_id).to_string_no_crate_verbose(); + if let Some(impl_id) = tcx.impl_of_method(def_id) { + let mut implementation = tcx.def_path_str(impl_id); + if !def_id.is_local() { + // rustc omits this crate's own prefix in its local impl index, + // but prints it when another crate references the same method. + let prefix = Regex::new(&format!( + r"(^|[^\w:]){}::", + regex::escape(tcx.crate_name(def_id.krate).as_str()) + )) + .unwrap(); + implementation = prefix.replace_all(&implementation, "$1").into_owned(); + } + path = Regex::new(r"\{impl#\d+\}") + .unwrap() + .replace(&path, implementation) + .into_owned(); + } + tcx.crate_name(def_id.krate).to_string() + &path +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImplInformation { @@ -54,7 +76,6 @@ pub struct HirVisitor<'tcx> { tcx: TyCtxt<'tcx>, mod_infos: Vec, result: Vec>, - re: Regex, } impl<'tcx> HirVisitor<'tcx> { @@ -63,7 +84,6 @@ impl<'tcx> HirVisitor<'tcx> { tcx, mod_infos: Vec::new(), result: Vec::new(), - re: Regex::new(r"\{impl#\d+\}").unwrap(), } } @@ -80,11 +100,6 @@ impl<'tcx> HirVisitor<'tcx> { visibility.is_accessible_from(CRATE_DEF_ID.to_def_id(), self.tcx) && !source.get_file().contains("main.rs") } - - fn replace_impl_name(&self, impl_id: rustc_hir::def_id::DefId, def_str: &String) -> String { - let impl_name = self.tcx.def_path_str(impl_id); - self.re.replace(def_str, impl_name).to_string() - } } impl<'tcx> Visitor<'tcx> for HirVisitor<'tcx> { @@ -228,15 +243,7 @@ impl<'tcx> Visitor<'tcx> for HirVisitor<'tcx> { _ => {} } - let crate_name = self.tcx.crate_name(def_id.krate).to_string(); - let mut def_str = self.tcx.def_path(def_id).to_string_no_crate_verbose(); - let name_with_impl = crate_name.clone() + &def_str; - if let intravisit::FnKind::Method(_, _) = fk { - if let Some(impl_id) = self.tcx.impl_of_method(def_id) { - def_str = self.replace_impl_name(impl_id, &def_str); - } - } - let full_name = crate_name + &def_str; + let full_name = function_name(self.tcx, def_id); let encoded_name = encoded_name(&full_name); let impl_information = ImplInformation { diff --git a/focxt/call_chain/src/bin/cargo-call-chain.rs b/focxt/call_chain/src/bin/cargo-call-chain.rs index bcae287a47..971fd8cb9e 100644 --- a/focxt/call_chain/src/bin/cargo-call-chain.rs +++ b/focxt/call_chain/src/bin/cargo-call-chain.rs @@ -163,11 +163,27 @@ fn in_cargo_call_chain() { let verbose = has_arg_flag("-v"); let current_crate = current_crate(); + if std::env::var_os("PALM_TARGET_NAME").is_none() + && current_crate + .targets + .iter() + .filter(|t| t.kind.iter().any(|k| k == "lib" || k == "bin")) + .count() + > 1 + { + show_error( + "Multiple Cargo targets: use utgen analyze to preserve target ownership".to_owned(), + ); + } let path = Path::new(¤t_crate.manifest_path).parent().unwrap(); let path_str = path.to_str().unwrap(); // Now run the command. for target in current_crate.targets.into_iter() { + if let Ok(selected) = std::env::var("PALM_TARGET_NAME") { + let kind = std::env::var("PALM_TARGET_KIND").unwrap(); + if target.name != selected || !target.kind.contains(&kind) { continue; } + } let mut args = std::env::args().skip(2); let kind = target .kind @@ -181,7 +197,7 @@ fn in_cargo_call_chain() { cmd.arg("check"); // using `check` may speed up the analysis than using `rustc` match kind.as_str() { "bin" => { - cmd.arg("--bin").arg(target.name); + cmd.arg("--bin").arg(&target.name); } "lib" => { cmd.arg("--lib"); @@ -210,7 +226,9 @@ fn in_cargo_call_chain() { "CALL_CHAIN_ARGS", serde_json::to_string(&args_vec).expect("failed to serialize args"), ); - cmd.env("CALL_CHAIN_TOP_CRATE_NAME", current_crate.name.clone()); + cmd.env("CALL_CHAIN_TOP_CRATE_NAME", &target.name); + cmd.env("CALL_CHAIN_TOP_CRATE_KIND", kind); + cmd.env("CALL_CHAIN_TOP_CRATE_SRC", target.src_path.as_str()); // Replace the rustc executable through RUSTC_WRAPPER environment variable let path = std::env::current_exe().expect("current executable path invalid"); @@ -221,7 +239,7 @@ fn in_cargo_call_chain() { eprintln!("+ {:?}", cmd); } - cmd.env("FOCXT_CRATE_DIR", path_str); + cmd.env("FOCXT_CRATE_DIR", std::env::var_os("PALM_OUTPUT_DIR").unwrap_or_else(|| path_str.into())); // Execute cmd let exit_status = cmd @@ -253,7 +271,16 @@ fn inside_cargo_rustc() { std::env::var("CALL_CHAIN_TOP_CRATE_NAME").expect("missing CALL_CHAIN_TOP_CRATE_NAME"); let top_crate_name = top_crate_name.replace("-", "_"); // Cargo seems to rename hyphens to underscores - if get_arg_flag_value("--crate-name").as_deref() == Some(&top_crate_name) { + let kind = std::env::var("CALL_CHAIN_TOP_CRATE_KIND").unwrap(); + let entry = + std::fs::canonicalize(std::env::var_os("CALL_CHAIN_TOP_CRATE_SRC").unwrap()).unwrap(); + let is_entry = std::env::args() + .skip(2) + .any(|arg| std::fs::canonicalize(arg).is_ok_and(|path| path == entry)); + if get_arg_flag_value("--crate-name").as_deref() == Some(&top_crate_name) + && get_arg_flag_value("--crate-type").as_deref() == Some(&kind) + && is_entry + { // If we are analyzing the crate that we want to analyze, add args for `call-chain` let magic = std::env::var("CALL_CHAIN_ARGS").expect("missing CALL_CHAIN_ARGS"); let call_chain_args: Vec = diff --git a/focxt/src/collect_context/crate_context.rs b/focxt/src/collect_context/crate_context.rs index 5527fbd0fc..a485fd4bbc 100644 --- a/focxt/src/collect_context/crate_context.rs +++ b/focxt/src/collect_context/crate_context.rs @@ -4,13 +4,11 @@ use std::{ fs::{self, read_to_string, File}, io::Write, path::PathBuf, - process, rc::Rc, }; use call_chain::analysis::hirvisitor::ImplInformation; use syn::parse_file; -use toml::Value; use super::{ items_context::MyVisibility, @@ -28,52 +26,13 @@ pub struct CrateContext { } impl CrateContext { - pub fn new(crate_path: &PathBuf) -> Self { - let mut crate_context = CrateContext { - crate_name: String::new(), - crate_path: PathBuf::new(), - entry_file_paths: Vec::new(), + pub fn new(crate_name: String, entry: PathBuf, output_path: PathBuf) -> Self { + Self { + crate_name, + crate_path: output_path, + entry_file_paths: vec![entry], main_mod_contexts: Vec::new(), - }; - let toml_path = crate_path.join("Cargo.toml"); - if fs::exists(&toml_path).unwrap() { - let toml_content = - read_to_string(toml_path).expect("Can not read the Cargo.toml file of the crate!"); - let toml_value: Value = toml_content - .parse() - .expect("Failed to parse the Cargo.toml file of the crate!"); - if let Some(package) = toml_value.get("package") { - if let Some(name) = package.get("name") { - crate_context.crate_name = name.as_str().unwrap().to_string().replace("-", "_"); - } else { - eprintln!("Can not get the crate name of the crate!"); - process::exit(2); - } - } else { - eprintln!("Can not get the package infomation of the crate!"); - process::exit(3); - } - } else { - eprintln!("Can not find the Cargo.toml file of the crate!"); - process::exit(4); - } - crate_context.crate_path = crate_path.clone(); - let main_path = crate_path.join("src/main.rs"); - let lib_path = crate_path.join("src/lib.rs"); - let mut has_entry = false; - if fs::exists(&main_path).unwrap() { - crate_context.entry_file_paths.push(main_path); - has_entry = true; - } - if fs::exists(&lib_path).unwrap() { - crate_context.entry_file_paths.push(lib_path); - has_entry = true; } - if has_entry == false { - eprintln!("Can not find the entry file of the crate!"); - process::exit(5); - } - crate_context } pub fn parse_crate(&mut self) { @@ -97,11 +56,6 @@ impl CrateContext { ); self.main_mod_contexts.push(mod_context); } - if self.entry_file_paths.len() == 2 { - self.main_mod_contexts[0] - .borrow_mut() - .add_use_mod(&self.main_mod_contexts[1]); - } } pub fn change_all_names(&mut self) { diff --git a/focxt/src/collect_context/mod.rs b/focxt/src/collect_context/mod.rs index 741ea04da8..b638a553c8 100644 --- a/focxt/src/collect_context/mod.rs +++ b/focxt/src/collect_context/mod.rs @@ -2,4 +2,4 @@ pub mod crate_context; mod items_context; mod mod_context; pub mod result; -mod syntax_context; +pub(crate) mod syntax_context; diff --git a/focxt/src/collect_context/syntax_context.rs b/focxt/src/collect_context/syntax_context.rs index eba26e3f8c..f80b7ad3a0 100644 --- a/focxt/src/collect_context/syntax_context.rs +++ b/focxt/src/collect_context/syntax_context.rs @@ -504,14 +504,11 @@ fn get_syntax( } } -fn get_syntax_recursively( +pub(crate) fn collect_calls( output_path: &PathBuf, impl_informations: &[ImplInformation], data: &CallsAndTypes, - syntax_context: &mut SyntaxContext, - fns: &HashMap, - structs: &HashMap, -) { +) -> CallsAndTypes { let mut remain_calls = data.calls.clone(); let mut already_calls = HashSet::new(); let mut end_data = data.clone(); @@ -532,6 +529,26 @@ fn get_syntax_recursively( end_data.calls.dedup(); end_data.types.sort(); end_data.types.dedup(); + end_data +} + +pub(crate) fn type_context(name: &str, structs: &HashMap) -> String { + let mut data = CallsAndTypes::new_empty(); + data.types.push(name.to_owned()); + let mut context = SyntaxContext::new(); + get_syntax(&data, &mut context, &HashMap::new(), structs); + context.to_string() +} + +fn get_syntax_recursively( + output_path: &PathBuf, + impl_informations: &[ImplInformation], + data: &CallsAndTypes, + syntax_context: &mut SyntaxContext, + fns: &HashMap, + structs: &HashMap, +) { + let end_data = collect_calls(output_path, impl_informations, data); let data = &end_data; for call in data.calls.iter() { let fn_data = fns.get(call); diff --git a/focxt/src/main.rs b/focxt/src/main.rs index 6a0d1644b3..45eb6b0942 100644 --- a/focxt/src/main.rs +++ b/focxt/src/main.rs @@ -35,11 +35,44 @@ fn main() { eprintln!("The crate path {:?} doesn't exisit!", &input_crate_path); process::exit(1) }); + let metadata = cargo_metadata::MetadataCommand::new() + .manifest_path(crate_path.join("Cargo.toml")) + .no_deps() + .exec() + .unwrap(); + let package = metadata.root_package().unwrap(); + let targets: Vec<_> = package + .targets + .iter() + .filter(|t| t.kind.iter().any(|k| k == "lib" || k == "bin")) + .collect(); + let selected = std::env::var("PALM_TARGET_NAME").ok(); + let kind = std::env::var("PALM_TARGET_KIND").ok(); + let target = if let (Some(name), Some(kind)) = (&selected, &kind) { + targets + .iter() + .copied() + .find(|t| t.name == *name && t.kind.contains(kind)) + .expect("selected Cargo target not found") + } else if targets.len() == 1 { + targets[0] + } else { + eprintln!( + "Multiple Cargo targets: use utgen analyze to collect and combine target contexts" + ); + process::exit(2); + }; run_call_chain(&crate_path); - - let impl_informations = read_impl_informations_from_json(&crate_path); - - let mut crate_context = CrateContext::new(&crate_path); + let output_root = std::env::var_os("PALM_OUTPUT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| crate_path.clone()); + let impl_informations = read_impl_informations_from_json(&output_root); + let mut crate_context = CrateContext::new( + target.name.replace('-', "_"), + target.src_path.clone().into_std_path_buf(), + output_root.clone(), + ); + let crate_path = output_root; crate_context.parse_crate(); crate_context.change_all_names(); @@ -70,4 +103,71 @@ fn main() { crate_context.parse_all_context(&impl_informations, &mod_trees, &fns, &structs); crate_context.cout_in_one_file_for_test(); crate_context.cout_complete_function_name_in_on_file_for_test(); + use call_chain::analysis::exporter::CallsAndTypes; + use collect_context::syntax_context::{collect_calls, type_context}; + if target.kind.iter().any(|k| k == "lib") && targets.len() > 1 { + let types: std::collections::BTreeMap<_, _> = structs + .keys() + .map(|name| (name.clone(), type_context(name, &structs))) + .collect(); + fs::write( + crate_path.join("focxt/type_contexts.json"), + serde_json::to_vec(&types).unwrap(), + ) + .unwrap(); + } + if target.kind.iter().any(|k| k == "bin") { + if let Some(library_root) = std::env::var_os("PALM_LIBRARY_OUTPUT").map(PathBuf::from) { + let library_infos = read_impl_informations_from_json(&library_root); + let library_types: std::collections::BTreeMap = serde_json::from_slice( + &fs::read(library_root.join("focxt/type_contexts.json")).unwrap(), + ) + .unwrap(); + for info in &impl_informations { + let path = crate_path + .join("focxt/new_callsandtypes") + .join(format!("{}.json", info.encoded_name)); + let Ok(bytes) = fs::read(path) else { continue }; + let calls: CallsAndTypes = serde_json::from_slice(&bytes).unwrap(); + let calls = collect_calls(&crate_path.join("focxt"), &impl_informations, &calls); + let mut snippets = std::collections::BTreeSet::new(); + for name in &calls.library_calls { + if let Some(library_info) = library_infos.iter().find(|i| &i.full_name == name) + { + let code = fs::read_to_string( + library_root + .join("focxt") + .join(format!("{}.rs", library_info.encoded_name)), + ) + .unwrap(); + snippets.insert(format!("// Function {name}\n{code}")); + } + } + for name in &calls.library_types { + if let Some(code) = library_types.get(name) { + snippets.insert(format!("// Type {name}\n{code}")); + } + } + if !snippets.is_empty() { + let mut file = fs::OpenOptions::new() + .append(true) + .open( + crate_path + .join("focxt") + .join(format!("{}.rs", info.encoded_name)), + ) + .unwrap(); + writeln!( + file, + "\n// Dependency context from library crate {}", + std::env::var("PALM_LIBRARY_NAME").unwrap() + ) + .unwrap(); + for snippet in snippets { + writeln!(file, "{snippet}").unwrap(); + } + } + } + } + } } diff --git a/scripts/check_mixed.py b/scripts/check_mixed.py new file mode 100644 index 0000000000..5b5912f2b7 --- /dev/null +++ b/scripts/check_mixed.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Validate mixed lib/bin ownership with small crates and local model responses.""" +import argparse +import http.server +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import threading + +REPO = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bin-dir', type=Path, default=REPO / 'target/debug') + args = parser.parse_args() + work = Path(tempfile.mkdtemp(prefix='palm-mixed-')).resolve() + root = work / 'crate' + shutil.copytree(REPO / 'examples/mixed-targets', root, ignore=shutil.ignore_patterns('target', 'Cargo.lock')) + print(f'Validation artifacts: {work}', flush=True) + env = os.environ.copy() + for key in ['CARGO_TARGET_DIR', 'PALM_CONFIG', 'PALM_API_BASE', 'PALM_API_KEY', 'PALM_MODEL']: + env.pop(key, None) + env['PATH'] = str(args.bin_dir.resolve()) + os.pathsep + env['PATH'] + for key in ['NO_PROXY', 'no_proxy']: + env[key] = ','.join(filter(None, [env.get(key), '127.0.0.1', 'localhost'])) + + def run(label, command, expected=0, directory=root): + print(label, flush=True) + result = subprocess.run(command, cwd=directory, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180) + (work / f'{label}.log').write_text(result.stdout) + assert (result.returncode == 0) == (expected == 0), f'{label}: exit {result.returncode}\n{result.stdout[-7000:]}' + return result.stdout + + run('preprocess', ['utgen', 'pre-process', '-p', str(root)]) + run('analyze', ['utgen', 'analyze', '-p', str(root)]) + names = json.loads((root / 'brinfo/name_map.json').read_text()) + lib = 'lib:mixed_fixture::mixed_fixture::' + binary = 'bin:mixed_fixture::mixed_fixture::' + assert set(names) == {lib + 'helper', lib + 'lib_only', lib + 'shared::classify', + binary + 'helper', binary + 'bin_only', binary + 'library_type', binary + 'main'}, names + assert names[lib + 'helper'] != names[binary + 'helper'] + contexts = {key: (root / f'focxt/{encoded}.rs').read_text() for key, encoded in names.items()} + assert 'value + 10' in contexts[lib + 'shared::classify'] + assert 'value - 10' not in contexts[lib + 'shared::classify'] + assert 'value - 10' in contexts[binary + 'helper'] and 'value + 10' not in contexts[binary + 'helper'] + local, dependency = contexts[binary + 'bin_only'].split('// Dependency context from library crate mixed_fixture') + assert 'fn classify' in local and 'value + 10' not in local + assert 'value + 10' in dependency + assert 'pub struct Subject' in contexts[binary + 'library_type'] + raw = root / 'brinfo/targets/bin/mixed_fixture/focxt' + raw_infos = json.loads((raw / 'impl_informations.json').read_text()) + shared = next(info for info in raw_infos if info['full_name'] == 'mixed_fixture::shared::classify') + assert 'value - 10' in (raw / f"{shared['encoded_name']}.rs").read_text() + + selection = work / 'functions.txt' + selection.write_text('mixed_fixture::helper\n') + env.update(PALM_API_BASE='http://127.0.0.1:9/v1', PALM_API_KEY='unused', PALM_MODEL='fixture') + output = run('ambiguous-selection', ['utgen', 'gen', '-p', str(root), '--functions-file', str(selection)], expected=1) + assert 'Ambiguous function' in output and lib + 'helper' in output and binary + 'helper' in output + selection.write_text(binary + 'bin_only\n') + output = run('reject-binary-integration', ['utgen', 'gen', '-p', str(root), '--functions-file', str(selection), '--integration'], expected=1) + assert 'belongs to a binary' in output and not (root / 'utgen').exists() + + state = {'generation': 0, 'repair': 0, 'errors': []} + + class Model(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + try: + request = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + user = request['messages'][-1]['content'] + if 'The function to be tested is presented as follows:' in user: + focal = user.split('The function to be tested is presented as follows:', 1)[1] + function = re.search(r'\bfn (\w+)\(', focal).group(1) + if function == 'classify': + body = 'assert_eq!(classify(1), 11); assert_eq!(classify(-1), 0);' + elif function == 'bin_only': + body = 'assert_eq!(bin_only("wrong-type"), 2);' + elif function == 'helper': + expected = '11' if 'value + 10' in focal else '-9' + body = f'assert_eq!(helper(1), {expected});' + else: + raise AssertionError(function) + answer = '```rust\n#[test]\nfn generated() {\n' + body + '\n}\n```' + state['generation'] += 1 + else: + file = re.search(r'You can only modify lines \d+ to \d+ in file (.+?)\. For your answer', user).group(1) + changes = [(number, old, old.replace('"wrong-type"', '1')) + for number, old in re.findall(r'^\[(\d+)\](.*)$', user, re.M) + if '"wrong-type"' in old] + assert changes, user + answer = f'ChangeLog:1@{file}\nFixDescription: Use an integer argument.\n' + for number, old, new in changes: + answer += f'OriginalCode@{number}-{number}:\n[{number}]{old}\nFixedCode@{number}-{number}:\n[{number}]{new}\n' + state['repair'] += 1 + (work / f"model-{state['generation']}-{state['repair']}.json").write_text(json.dumps({'prompt': user, 'answer': answer}, indent=2)) + response = {'id': 'fixture', 'object': 'chat.completion', 'created': 0, 'model': 'fixture', + 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': answer}, 'finish_reason': 'stop'}], + 'usage': {'prompt_tokens': 1, 'completion_tokens': 1, 'total_tokens': 2}} + status = 200 + except Exception as error: + state['errors'].append(str(error)) + response, status = {'error': {'message': str(error)}}, 400 + data = json.dumps(response).encode() + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Model) + threading.Thread(target=server.serve_forever, daemon=True).start() + env.update(PALM_API_BASE=f'http://127.0.0.1:{server.server_port}/v1', PALM_API_KEY='local-fixture-key') + selected = [lib + 'shared::classify', lib + 'helper', binary + 'helper', binary + 'bin_only'] + selection.write_text('\n'.join(selected) + '\n') + sources = {p: p.read_bytes() for p in (root / 'src').rglob('*.rs')} + try: + run('generate', ['utgen', 'gen', '-p', str(root), '--functions-file', str(selection), '--context']) + assert state['generation'] == 4 and state['repair'] == 0 and not state['errors'], state + assert sources == {p: p.read_bytes() for p in sources} + def result(name, directory='result'): + return json.loads((root / f'utgen/{directory}/{names[name]}.json').read_text()) + for name in selected[:-1]: + row = result(name) + assert row['tests_run'] == row['tests_passed'] == 1 and row['coverage_available'], row + assert row['target']['kind'] == ('lib' if name.startswith('lib:') else 'bin'), row + row = result(selected[-1]) + assert row['tests_compiled'] == row['tests_run'] == 0 and row['coverage_available'], row + assert row['codes_lines_covered'] == [], row + run('repair', ['utgen', 'fix', '-p', str(root), '--functions-file', str(selection)]) + assert state['repair'] >= 1 and not state['errors'], state + for name in selected: + row = result(name, 'fixed_result') + assert row['tests_run'] == row['tests_passed'] == 1, row + before = state.copy() + run('cached-repair', ['utgen', 'fix', '-p', str(root), '--functions-file', str(selection)]) + assert state == before + assert sources == {p: p.read_bytes() for p in sources} + assert not list((root / 'src').rglob('*.bak')) + # A broken unselected binary must not enter a library unit-test build. + main_file = root / 'src/main.rs' + main_file.write_bytes(sources[main_file] + b'\ncompile_error!("unselected binary");\n') + selection.write_text(lib + 'shared::classify\n') + run('library-with-broken-bin', ['utgen', 'gen', '-p', str(root), '--functions-file', str(selection)]) + assert result(lib + 'shared::classify')['tests_passed'] == 1 + main_file.write_bytes(sources[main_file]) + # Automatic integration mode uses all library representatives, and + # leaves binary caches alone. Replay fixed candidates without requests. + template = json.loads((root / f"utgen/generation/pre_fix/{names[lib + 'helper']}.json").read_text()) + for name in [lib + 'helper', lib + 'shared::classify', lib + 'lib_only']: + data = json.loads(json.dumps(template)) + branch = json.loads((root / f'brinfo/brdata/{names[name]}.json').read_text()) + for field in ['id', 'name', 'name_with_impl', 'mod_info', 'visible', 'loc', 'target']: + data[field] = branch[field] + data['integration'] = True + function = branch['name'].split('::')[-1] + test = data['fn_tests'][0]['answers'][0]['chain_tests'][0] + test['codes'] = [['{', f'assert_eq!({function}(1), 11);', '}']] + (root / f'utgen/generation/pre_fix/{names[name]}.json').write_text(json.dumps(data)) + shutil.rmtree(root / 'utgen/generation/llm_fix') + requests = (state['generation'], state['repair']) + output = run('integration-auto', ['utgen', 'gen', '-p', str(root), '--integration']) + assert 'omits 4 binary focal functions' in output + run('integration-repair', ['utgen', 'fix', '-p', str(root), '--integration']) + assert (state['generation'], state['repair']) == requests, state + for name in [lib + 'helper', lib + 'shared::classify', lib + 'lib_only']: + for directory in ['result', 'fixed_result']: + row = result(name, directory) + assert row['target']['kind'] == 'lib' and row['tests_run'] == row['tests_passed'] == 1, row + assert sources == {p: p.read_bytes() for p in sources} + # Missing ownership in a legacy mixed cache is an error, not a guess. + cached = root / f"utgen/generation/pre_fix/{names[lib + 'helper']}.json" + saved = cached.read_bytes() + data = json.loads(saved) + data.pop('target') + cached.write_text(json.dumps(data)) + output = run('reject-legacy-mixed-cache', ['utgen', 'gen', '-p', str(root), '--integration'], expected=1) + assert 'lack target ownership' in output + assert (state['generation'], state['repair']) == requests + cached.write_bytes(saved) + finally: + server.shutdown() + server.server_close() + # Real Cargo names and entry files can differ from the package and defaults. + custom = work / 'custom' + shutil.copytree(REPO / 'examples/mixed-targets', custom, ignore=shutil.ignore_patterns('target', 'Cargo.lock')) + (custom / 'src/lib.rs').rename(custom / 'src/core.rs') + (custom / 'tools').mkdir() + original_main = (custom / 'src/main.rs').read_text() + (custom / 'src/main.rs').unlink() + (custom / 'tools/cli.rs').write_text(original_main.replace('mod shared;', '#[path = "../src/shared.rs"] mod shared;').replace('mixed_fixture::', 'mixed_core::') + '\n#[test] fn existing() { panic!("must be preprocessed"); }\n') + with (custom / 'Cargo.toml').open('a') as manifest: + manifest.write('\n[lib]\nname="mixed_core"\npath="src/core.rs"\n[[bin]]\nname="mixed_tool"\npath="tools/cli.rs"\n') + with (custom / 'src/shared.rs').open('a') as source: + source.write('\npub struct Generic(pub T);\nimpl Generic { pub fn get(&self) -> T { self.0 } }\nimpl From for Generic { fn from(value: i32) -> Self { Self(value) } }\n') + with (custom / 'tools/cli.rs').open('a') as source: + source.write('\nfn library_method(subject: &mixed_core::shared::Generic) -> i32 { subject.get() }\n') + run('custom-preprocess', ['utgen', 'pre-process', '-p', str(custom)], directory=custom) + run('custom-analyze', ['utgen', 'analyze', '-p', str(custom)], directory=custom) + custom_names = json.loads((custom / 'brinfo/name_map.json').read_text()) + assert 'lib:mixed_core::mixed_core::shared::classify' in custom_names + assert 'bin:mixed_tool::mixed_tool::shared::classify' not in custom_names + custom_infos = json.loads((custom / 'focxt/impl_informations.json').read_text()) + for method in ['get', 'from']: + matches = [info for info in custom_infos if info['fn_name'] == method] + assert len(matches) == 1 and matches[0]['target']['kind'] == 'lib', matches + method_context = (custom / f"focxt/{custom_names['bin:mixed_tool::mixed_tool::library_method']}.rs").read_text() + assert 'fn get' in method_context and 'self.0' in method_context, method_context + custom_bin = 'bin:mixed_tool::mixed_tool::bin_only' + data = json.loads((custom / f"brinfo/brdata/{custom_names[custom_bin]}.json").read_text()) + assert data['target']['src_path'] == 'tools/cli.rs', data + assert 'Dependency context from library crate mixed_core' in (custom / f"focxt/{custom_names[custom_bin]}.rs").read_text() + + custom_candidate = json.loads(json.dumps(template)) + for field in ['id', 'name', 'name_with_impl', 'mod_info', 'visible', 'loc', 'target']: + custom_candidate[field] = data[field] + custom_candidate['integration'] = False + test = custom_candidate['fn_tests'][0]['answers'][0]['chain_tests'][0] + test['codes'] = [['{', 'assert_eq!(bin_only(1), 2);', '}']] + cache = custom / f"utgen/generation/pre_fix/{custom_names[custom_bin]}.json" + cache.parent.mkdir(parents=True) + cache.write_text(json.dumps(custom_candidate)) + selection.write_text(custom_bin + '\n') + run('custom-unit-statistics', ['utgen', 'gen', '-p', str(custom), '--functions-file', str(selection)], directory=custom) + row = json.loads((custom / f"utgen/result/{custom_names[custom_bin]}.json").read_text()) + assert row['target']['src_path'] == 'tools/cli.rs' and row['tests_run'] == row['tests_passed'] == 1, row + + # With no library representative, retain the definition in each binary. + bins = work / 'bins' + (bins / 'src').mkdir(parents=True) + (bins / 'Cargo.toml').write_text('[package]\nname="bin_fixture"\nversion="0.1.0"\nedition="2021"\n[workspace]\n[[bin]]\nname="one"\npath="src/one.rs"\n[[bin]]\nname="two"\npath="src/two.rs"\n') + (bins / 'rust-toolchain.toml').write_bytes((REPO / 'rust-toolchain.toml').read_bytes()) + (bins / 'src/shared.rs').write_bytes((root / 'src/shared.rs').read_bytes()) + for name, value in [('one', 1), ('two', 2)]: + (bins / f'src/{name}.rs').write_text(f'mod shared;\nfn helper(value: i32) -> i32 {{ value + {value} }}\nfn main() {{}}\n') + run('multiple-bins-analyze', ['utgen', 'analyze', '-p', str(bins)], directory=bins) + bin_names = json.loads((bins / 'brinfo/name_map.json').read_text()) + for name, value in [('one', 1), ('two', 2)]: + key = f'bin:{name}::{name}::shared::classify' + assert key in bin_names, bin_names + assert f'value + {value}' in (bins / f'focxt/{bin_names[key]}.rs').read_text() + # Cargo metadata can include sibling workspace members. Preprocessing + # a selected package must not change a sibling's declared entry file. + workspace = work / 'workspace' + workspace.mkdir() + (workspace / 'Cargo.toml').write_text('[workspace]\nmembers=["first", "second"]\nresolver="2"\n') + for name in ['first', 'second']: + package = workspace / name + (package / 'src').mkdir(parents=True) + (package / 'Cargo.toml').write_text(f'[package]\nname="{name}"\nversion="0.1.0"\n[lib]\npath="entry.rs"\n') + (package / 'entry.rs').write_text('pub fn production() {}\n#[test] fn original() {}\n') + sibling = (workspace / 'second/entry.rs').read_bytes() + run('preprocess-selected-package', ['utgen', 'pre-process', '-p', str(workspace / 'first')]) + assert b'fn original' not in (workspace / 'first/entry.rs').read_bytes() + assert (workspace / 'second/entry.rs').read_bytes() == sibling + (work / 'summary.json').write_text(json.dumps(state, indent=2)) + print('Mixed-target analysis, generation, repair and coverage checks passed.', flush=True) + + +if __name__ == '__main__': + main() diff --git a/utgen/README.md b/utgen/README.md index 89ebea3864..eb854e0f29 100644 --- a/utgen/README.md +++ b/utgen/README.md @@ -110,7 +110,7 @@ For multiple crates, use explicit work-directory paths: utgen pre-process -p -w -w ``` -The preprocessor removes modules whose `cfg` predicates are provably disabled without `test`, functions with `test` or namespaced `test` attributes, test-only impls/methods, and the contents of files with an inner `#![cfg(test)]` attribute. It preserves production predicates such as `cfg(not(test))` and treats unknown feature/target predicates conservatively. Source files retain their line breaks and byte offsets. UTF-8 character columns can change inside blanked ranges; perform analysis after preprocessing. Existing `tests.bak` is never overwritten. Non-entry files that parse as expressions (for example, `include!("value.rs")` fragments) are left unchanged; other parse failures remain errors. Cargo metadata identifies target entry files, which must parse as complete Rust files. There is no general reverse-preprocessing command. +The preprocessor visits `src/` and declared lib/bin entry files. It removes modules whose `cfg` predicates are provably disabled without `test`, functions with `test` or namespaced `test` attributes, test-only impls/methods, and the contents of files with an inner `#![cfg(test)]` attribute. It preserves production predicates such as `cfg(not(test))` and treats unknown feature/target predicates conservatively. Source files retain their line breaks and byte offsets. UTF-8 character columns can change inside blanked ranges; perform analysis after preprocessing. Existing `tests.bak` is never overwritten. Non-entry files that parse as expressions (for example, `include!("value.rs")` fragments) are left unchanged; other parse failures remain errors. Cargo metadata identifies target entry files, which must parse as complete Rust files. There is no general reverse-preprocessing command. ### Analysis @@ -120,7 +120,11 @@ utgen analyze -p The target must have `Cargo.toml` and `src/`, with no existing `brinfo/` or `focxt/` directory. Use a fresh prepared copy when repeating analysis. Tool failures return a nonzero exit status. No model configuration is needed. -For manual analysis, run `cargo clean`, `cargo brinfo`, then `focxt -c ` in the prepared crate. A prior `cargo check` can otherwise prevent the compiler wrapper from running. +Analysis enumerates the package's ordinary library and binary targets using Cargo metadata, including custom target names and entry paths. Raw results stay separate under `brinfo/targets///` for mixed packages. The final branch/context indices retain every target's own definitions and prefer the library when the same source definition also occurs in a binary. Same-name definitions in different locations remain separate; definitions shared only between binaries remain associated with each binary. The selected library version does not imply that every binary variant was tested. + +Mixed-target index keys include the target, for example `lib:demo::demo::shared::parse`. `--functions-file` accepts these keys, or the original Rust path when it identifies exactly one selected function. Ambiguous paths fail with the available qualified keys. Single-target keys and encoded filenames retain their existing form. Dependency contexts keep their actual origin even when a shared binary definition is omitted from the focal list. + +For manual single-target analysis, run `cargo clean`, `cargo brinfo`, then `focxt -c ` in the prepared crate. A prior `cargo check` can otherwise prevent the compiler wrapper from running. Use `utgen analyze` for mixed packages; unscoped low-level invocations reject multiple targets rather than overwrite their artifacts. ### Coverage of existing tests @@ -149,10 +153,10 @@ utgen gen -p --requirement --context | `-r, --requirement` | Generate for each representative condition chain. Default: off. | | `-c, --context` | Include the focal function's context in the prompt. Default: off. | | `-o, --oracle` | Use separate input-range, test-prefix, and oracle generation. Default: off; otherwise generate complete tests directly. | -| `-i, --integration` | Generate integration tests under `tests/`, using the analysis visibility flag to select functions and compilation checks to filter candidates. Default: off. | +| `-i, --integration` | Generate integration tests under `tests/`, selecting library functions with the analysis visibility flag and checking candidate compilation. Default: off. | | `-t, --tasks` | Default: 4. Maximum active focal-function generation jobs; must be positive. | | `--request-timeout` | Default: 180 seconds per model request attempt; also available for `fix`. Must be positive. | -| `--functions-file` | Optional path to exact full analysis-index names, one per line; also available for `fix`. | +| `--functions-file` | Optional path to analysis-index keys or unambiguous Rust paths, one per line; also available for `fix`. | | `--max-requests` | Optional positive request-attempt limit for the whole command, including retries; also available for `fix`. Default: no overall limit. | Generation validates the branch index, context index, context files, and source paths before modifying the target. Missing or inconsistent artifacts are errors. A failed generation task is reported instead of being silently lost before statistics. `--tasks 0` is rejected. @@ -220,7 +224,11 @@ Per-function results include `coverage_available`. When it is false, the numeric When candidate coverage has no focal-function mapping, statistics collect a zero-hit baseline for the selected Cargo target. Unit mode builds the `--lib` or `--bin` test harness with every test filtered out; it does not insert an empty test. Integration mode obtains mappings from the ordinary library artifact, using a temporary integration harness without executing tests. Thus `cfg(not(test))` code can contribute to the integration denominator while being absent in unit mode. Compilation and tool failures remain errors. Baselines add no candidate/oracle counts or `codes_*_covered` entries; their profiles stay separate from real test hits. Parsed baselines are reused only within the current statistics invocation. -Function-level integration mode requires a library target. Target selection currently uses the analyzed crate name and, when needed, the source entry file. Ambiguous shared modules in same-name library/binary targets are rejected before model requests. Full mixed-target analysis requires retaining target identity in the analysis index; use a standalone library or binary working copy for those cases. +Generated candidates and statistics carry the recorded target (`kind`, `name`, and entry `src_path`). Statistics use `function_name` as the analysis-index key and `rust_name` for the original Rust path. Unit compilation checks use `cargo test --lib/--bin --no-run`; execution and coverage use that same target. Repair considers compiler errors, not ordinary warnings. The standalone `coverage` command and original-test comparison retain their package-wide scope. + +Function-level integration mode requires a library. Automatic selection reports omitted binary functions; explicitly selecting a binary function with `--integration` fails before model requests. Cargo may still build the package's binaries for an integration test, so binary build errors can prevent integration checks. PALM does not temporarily remove binaries from the manifest. + +Old single-target candidates without recorded ownership remain usable. Old mixed-target artifacts may have overwritten one another; analyze and generate in a fresh working copy instead of migrating those caches. Keep the same generation mode for repair. `tests_*` count candidates; `oracles_*` count the existing `TestInfo` groups. A group counts once if any of its candidates compiles, is registered to run, or passes, respectively. Run counts retain the existing libtest convention, including ignored tests. Integration pass outcomes match complete test names. diff --git a/utgen/src/analysis.rs b/utgen/src/analysis.rs index 99a935b421..4224ecfb3d 100644 --- a/utgen/src/analysis.rs +++ b/utgen/src/analysis.rs @@ -1,3 +1,4 @@ +use crate::target::{TargetInfo, targets}; use crate::types::{BrData, RfocxtNameInformation, TestGenInfo}; use std::{collections::HashMap, fs, io, path::Path, process::Command, time::Instant}; @@ -20,14 +21,14 @@ pub fn validate_analysis(project_dir: &Path, work_dir: &Path) -> io::Result<()> .join("brinfo/brdata") .join(format!("{encoded}.json")), )?; - if data.name != *name { + if (if data.id.is_empty() { &data.name } else { &data.id }) != name { return Err(io::Error::other(format!( "Branch index mismatch for {name}" ))); } let info = context .iter() - .find(|info| info.full_name == *name) + .find(|info| (if info.id.is_empty() { &info.full_name } else { &info.id }) == name) .ok_or_else(|| io::Error::other(format!("No focal context index for {name}")))?; let file = work_dir .join("focxt") @@ -53,7 +54,7 @@ pub fn validate_analysis(project_dir: &Path, work_dir: &Path) -> io::Result<()> let path = entry?.path(); if path.is_file() { let data: BrData = read_json(&path)?; - if !map.get(&data.name).is_some_and(|encoded| { + if !map.get(if data.id.is_empty() { &data.name } else { &data.id }).is_some_and(|encoded| { path.file_stem() .is_some_and(|stem| stem == encoded.as_str()) }) { @@ -108,12 +109,282 @@ pub fn analyze_project(work_dir: &Path) -> io::Result<()> { ))); } } - // RUSTC_WRAPPER does not by itself invalidate Cargo's existing check cache. - run("clean", Command::new("cargo").arg("clean").current_dir(work_dir))?; - run("brinfo", Command::new("cargo").arg("brinfo").current_dir(work_dir))?; - run("focxt", Command::new("focxt") - .arg("-c") - .arg(work_dir) - .current_dir(work_dir))?; + let targets = targets(work_dir)?; + let mixed = targets.len() > 1; + let mut collected = Vec::new(); + for target in &targets { + let output = if mixed { + work_dir + .join("brinfo/targets") + .join(&target.kind) + .join(&target.name) + } else { + work_dir.to_owned() + }; + // RUSTC_WRAPPER changes do not invalidate Cargo's existing check cache. + run( + "clean", + Command::new("cargo").arg("clean").current_dir(work_dir), + )?; + let mut command = Command::new("cargo"); + command.arg("brinfo").current_dir(work_dir); + target.configure_analysis(&mut command, &output); + run("brinfo", &mut command)?; + let mut command = Command::new("focxt"); + command.arg("-c").arg(work_dir).current_dir(work_dir); + target.configure_analysis(&mut command, &output); + if let Some(library) = targets.iter().find(|t| t.kind == "lib") { + command.env("PALM_LIBRARY_NAME", library.name.replace('-', "_")); + command.env( + "PALM_LIBRARY_OUTPUT", + if mixed { + work_dir.join("brinfo/targets/lib").join(&library.name) + } else { + work_dir.to_owned() + }, + ); + } + run("focxt", &mut command)?; + collected.push((target.clone(), output)); + } + combine_targets(work_dir, &collected, mixed)?; validate_analysis(work_dir, work_dir) } + +fn normalized_location(loc: &str, work_dir: &Path) -> io::Result { + let parts: Vec<_> = loc.rsplitn(5, ':').collect(); + let file = work_dir.join(parts[4]).canonicalize()?; + let file = file.strip_prefix(work_dir).unwrap_or(&file); + Ok(format!( + "{}:{}:{}:{}:{}", + file.display(), + parts[3], + parts[2], + parts[1], + parts[0] + )) +} + +fn definition(info: &serde_json::Value, work_dir: &Path) -> io::Result { + let loc = normalized_location(info["loc"].as_str().unwrap(), work_dir)?; + let implementation = info["impl_loc"] + .as_str() + .map(|loc| normalized_location(loc, work_dir)) + .transpose()? + .unwrap_or_default(); + Ok(format!("{loc}|{}|{implementation}", info["fn_name"])) +} + +/// Keep all raw contexts for dependencies, but select library representatives for generation. +fn combine_targets( + work_dir: &Path, + collected: &[(TargetInfo, std::path::PathBuf)], + mixed: bool, +) -> io::Result<()> { + use serde_json::Value; + use std::collections::{BTreeMap, BTreeSet}; + let mut libraries = BTreeSet::new(); + let mut library_branches = BTreeSet::new(); + for (target, output) in collected { + if target.kind == "lib" { + let infos: Vec = read_json(&output.join("focxt/impl_informations.json"))?; + let map: BTreeMap = read_json(&output.join("brinfo/name_map.json"))?; + for info in infos { + let key = definition(&info, work_dir)?; + if map.contains_key(info["full_name"].as_str().unwrap()) { + library_branches.insert(key.clone()); + } + libraries.insert(key); + } + } + } + let mut final_map = BTreeMap::new(); + let mut final_infos = Vec::new(); + let mut shared = 0; + fs::create_dir_all(work_dir.join("brinfo/brdata"))?; + fs::create_dir_all(work_dir.join("focxt"))?; + for (target, output) in collected { + let map: BTreeMap = read_json(&output.join("brinfo/name_map.json"))?; + let infos: Vec = read_json(&output.join("focxt/impl_informations.json"))?; + for (name, encoded) in map { + let mut info = infos + .iter() + .find(|info| info["full_name"].as_str() == Some(&name)) + .ok_or_else(|| { + io::Error::other(format!( + "Missing context index for {} in {}", + name, + target.key() + )) + })? + .clone(); + if target.kind == "bin" && libraries.contains(&definition(&info, work_dir)?) { + if !library_branches.contains(&definition(&info, work_dir)?) { + return Err(io::Error::other(format!( + "Shared function {name} has no library branch analysis; cannot substitute its binary version" + ))); + } + shared += 1; + continue; + } + let id = if mixed { + format!("{}::{name}", target.key()) + } else { + name.clone() + }; + let final_encoded = if mixed { + format!("{}_{}_{}", target.kind, target.name, encoded) + } else { + encoded.clone() + }; + let mut data: Value = + read_json(&output.join("brinfo/brdata").join(format!("{encoded}.json")))?; + data["id"] = id.clone().into(); + data["target"] = serde_json::to_value(target)?; + info["id"] = id.clone().into(); + info["target"] = serde_json::to_value(target)?; + info["encoded_name"] = final_encoded.clone().into(); + fs::write( + work_dir + .join("brinfo/brdata") + .join(format!("{final_encoded}.json")), + serde_json::to_vec_pretty(&data)?, + )?; + if mixed { + fs::copy( + output.join("focxt").join(format!("{encoded}.rs")), + work_dir.join("focxt").join(format!("{final_encoded}.rs")), + )?; + } + final_map.insert(id, final_encoded); + final_infos.push(info); + } + } + final_infos.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str())); + log::info!( + "Selected {} focal functions; {shared} shared binary definitions use their library representative", + final_map.len() + ); + fs::write( + work_dir.join("brinfo/name_map.json"), + serde_json::to_vec_pretty(&final_map)?, + )?; + fs::write( + work_dir.join("focxt/impl_informations.json"), + serde_json::to_vec_pretty(&final_infos)?, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn aggregation_preserves_distinct_definitions_and_prefers_library_in_either_order() { + let root = std::env::temp_dir().join(format!( + "palm-ownership-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join("src")).unwrap(); + let root = root.canonicalize().unwrap(); + for name in ["shared", "lib", "main"] { + fs::write(root.join(format!("src/{name}.rs")), "fn function() {}\n").unwrap(); + } + let mut collected = Vec::new(); + for (kind, entry) in [("lib", "lib"), ("bin", "main")] { + let output = root.join("raw").join(kind); + fs::create_dir_all(output.join("brinfo/brdata")).unwrap(); + fs::create_dir_all(output.join("focxt")).unwrap(); + let mut infos = Vec::new(); + let mut map = std::collections::BTreeMap::new(); + for (name, file) in [("shared", "shared"), ("same_name", entry)] { + let full_name = format!("demo::{name}"); + map.insert(full_name.clone(), name); + infos.push( + json!({"fn_name": name, "full_name": full_name, "encoded_name": name, + "loc": format!("src/{file}.rs:1:1:1:17"), "impl_loc": null}), + ); + fs::write( + output.join(format!("brinfo/brdata/{name}.json")), + json!({"name": full_name}).to_string(), + ) + .unwrap(); + fs::write( + output.join(format!("focxt/{name}.rs")), + format!("{kind} {name}"), + ) + .unwrap(); + } + fs::write( + output.join("brinfo/name_map.json"), + serde_json::to_vec(&map).unwrap(), + ) + .unwrap(); + fs::write( + output.join("focxt/impl_informations.json"), + serde_json::to_vec(&infos).unwrap(), + ) + .unwrap(); + collected.push(( + TargetInfo { + kind: kind.into(), + name: "demo".into(), + src_path: format!("src/{entry}.rs"), + }, + output, + )); + } + combine_targets(&root, &collected, true).unwrap(); + let first_map = fs::read(root.join("brinfo/name_map.json")).unwrap(); + let first_infos = fs::read(root.join("focxt/impl_informations.json")).unwrap(); + collected.reverse(); + combine_targets(&root, &collected, true).unwrap(); + assert_eq!( + first_map, + fs::read(root.join("brinfo/name_map.json")).unwrap() + ); + assert_eq!( + first_infos, + fs::read(root.join("focxt/impl_informations.json")).unwrap() + ); + let map: std::collections::BTreeMap = + serde_json::from_slice(&first_map).unwrap(); + assert_eq!(map.len(), 3); + assert!(!map.contains_key("bin:demo::demo::shared")); + for (id, text) in [ + ("lib:demo::demo::shared", "lib shared"), + ("lib:demo::demo::same_name", "lib same_name"), + ("bin:demo::demo::same_name", "bin same_name"), + ] { + assert_eq!( + fs::read_to_string(root.join("focxt").join(format!("{}.rs", map[id]))).unwrap(), + text + ); + } + // An unavailable library analysis must not silently select a binary variant. + let library = &collected + .iter() + .find(|(target, _)| target.kind == "lib") + .unwrap() + .1; + fs::write( + library.join("brinfo/name_map.json"), + json!({"demo::same_name": "same_name"}).to_string(), + ) + .unwrap(); + assert!( + combine_targets(&root, &collected, true) + .unwrap_err() + .to_string() + .contains("cannot substitute") + ); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/utgen/src/gene/generation.rs b/utgen/src/gene/generation.rs index e4ae6d1b07..8626760394 100644 --- a/utgen/src/gene/generation.rs +++ b/utgen/src/gene/generation.rs @@ -406,6 +406,7 @@ async fn gen_full_tests( } pub fn check_unit(test_gen: &mut TestGenInfo, project_dir: &Path, work_dir: &Path) { + let target = crate::target::resolve_target(work_dir, test_gen.target.as_ref()).unwrap(); let path = project_dir.join(test_gen.get_file()); let name = test_gen.get_name().to_string(); let fn_name = name.split("::").last().unwrap(); @@ -440,7 +441,7 @@ pub fn check_unit(test_gen: &mut TestGenInfo, project_dir: &Path, work_dir: &Pat mod_code.splice(pos..pos, insert_code); insert_test(insert_kind, &path, &mod_code); // let _ = target_clean(&work_dir); - let result = cargo_check(&work_dir); + let result = cargo_check(&work_dir, &target); test_info.can_compile[num] = result; // test.can_compile = false; id += 1; diff --git a/utgen/src/gene/mod.rs b/utgen/src/gene/mod.rs index 08ca26e5c2..bf063423c3 100644 --- a/utgen/src/gene/mod.rs +++ b/utgen/src/gene/mod.rs @@ -34,6 +34,8 @@ async fn generation_task( tx: mpsc::Sender, ) -> io::Result<()> { let name = brdata.name.clone(); + let id = brdata.id.clone(); + let target = brdata.target.clone(); info!("Generating tests for {}", brdata.name); let test_gen_info = generation_tests( &llm, @@ -48,8 +50,10 @@ async fn generation_task( oracle, ) .await; - let test_gen_info = + let mut test_gen_info = test_gen_info.ok_or_else(|| io::Error::other(format!("Generation failed for {name}")))?; + test_gen_info.id = id; + test_gen_info.target = target; tx.send(test_gen_info) .await .map_err(|_| io::Error::other("Generation result channel closed"))?; @@ -74,14 +78,16 @@ pub async fn gen_tests_project( } validate_analysis(project_dir, work_dir)?; crate::run::validate_targets(project_dir, work_dir, functions, integration)?; + let available = crate::target::targets(work_dir)?; let saved_dir = project_dir.join("utgen/generation/pre_fix"); if saved_dir.is_dir() { for entry in fs::read_dir(saved_dir)? { let path = entry?.path(); if path.is_file() { let info = TestGenInfo::from_json(&path); + let target = crate::target::resolve_from(&available, info.target.as_ref())?; if functions.contains(info.get_name()) { - info.check_mode(integration)?; + if !integration || target.kind == "lib" { info.check_mode(integration)?; } } } } @@ -103,23 +109,25 @@ pub async fn gen_tests_project( let entry = entry.unwrap(); let brdata_path = entry.path(); if brdata_path.is_file() { - let brdata: BrData = + let mut brdata: BrData = serde_json::from_str(&fs::read_to_string(&brdata_path).unwrap()).unwrap(); - if !functions.contains(&brdata.name) { + let id = if brdata.id.is_empty() { brdata.name.clone() } else { brdata.id.clone() }; + if !functions.contains(&id) { continue; } // if brdata.size.min_set < 2 { // info!("{} has less than 2 condition chains in min_set", brdata.name); // continue; // } - if integration && !brdata.visible { + brdata.target = Some(crate::target::resolve_from(&available, brdata.target.as_ref())?); + if integration && (brdata.target.as_ref().unwrap().kind != "lib" || !brdata.visible) { // info!("{} is not public", brdata.name); continue; } - let encoded_name = nmap.get(&brdata.name).cloned().unwrap(); + let encoded_name = nmap.get(&id).cloned().unwrap(); let mut focxt_encoded_name = String::new(); for focxt_name_information in focxt_name_informations.iter() { - if focxt_name_information.full_name == brdata.name { + if (if focxt_name_information.id.is_empty() { &focxt_name_information.full_name } else { &focxt_name_information.id }) == &id { focxt_encoded_name = focxt_name_information.encoded_name.clone(); break; } diff --git a/utgen/src/lib.rs b/utgen/src/lib.rs index 2cc1d799a8..a122e00a34 100644 --- a/utgen/src/lib.rs +++ b/utgen/src/lib.rs @@ -3,6 +3,7 @@ mod config; mod gene; mod run; mod selection; +mod target; mod types; mod utils; diff --git a/utgen/src/run/coverage.rs b/utgen/src/run/coverage.rs index 6020a638c5..2338e68828 100644 --- a/utgen/src/run/coverage.rs +++ b/utgen/src/run/coverage.rs @@ -262,21 +262,28 @@ pub(crate) fn collect_coverage_for_tests( work_dir: &Path, json: bool, targets: &[String], +) -> io::Result { + let mut args = Vec::new(); + if targets.is_empty() { + args.push("--tests"); + } else { + for target in targets { + args.extend(["--test", target.as_str()]); + } + } + collect_coverage_args(work_dir, json, &args) +} + +pub(crate) fn collect_coverage_args( + work_dir: &Path, + json: bool, + target_args: &[&str], ) -> io::Result { let canonical = work_dir.canonicalize()?; let work_dir = canonical.as_path(); let metadata = project_metadata(work_dir)?; with_test_exclusions(work_dir, &metadata, || { - // The first command uses llvm-cov's default cleanup. --no-report would - // imply --no-clean and mix profiles from different generated candidates. - let mut args = Vec::new(); - if targets.is_empty() { - args.push("--tests"); - } else { - for target in targets { - args.extend(["--test", target.as_str()]); - } - } + let mut args = target_args.to_vec(); args.extend([ "--ignore-run-fail", "--branch", diff --git a/utgen/src/run/coverage_baseline.rs b/utgen/src/run/coverage_baseline.rs index 4239585b63..b27ef0ed8b 100644 --- a/utgen/src/run/coverage_baseline.rs +++ b/utgen/src/run/coverage_baseline.rs @@ -21,34 +21,31 @@ pub(crate) fn validate_targets( functions: &FunctionSelection, integration: bool, ) -> io::Result<()> { - let metadata = project_metadata(work_dir)?; - if integration - && !metadata - .root_package() - .unwrap() - .targets - .iter() - .any(|target| target.is_kind(TargetKind::Lib)) - { + let available = crate::target::targets(work_dir)?; + if integration && !available.iter().any(|target| target.kind == "lib") { return Err(io::Error::other( "Function-level integration tests require a library target", )); } + let mut omitted = 0; for entry in fs::read_dir(work_dir.join("brinfo/brdata"))? { let path = entry?.path(); - if !path.is_file() { - continue; - } + if !path.is_file() { continue; } let data: BrData = serde_json::from_slice(&fs::read(path)?)?; - if functions.contains(&data.name) && (!integration || data.visible) { - select_target( - &metadata, - &data.name, - &project_dir.join(data.loc.get_file()), - integration, - )?; + let id = if data.id.is_empty() { &data.name } else { &data.id }; + if !functions.contains(id) { continue; } + let target = crate::target::resolve_from(&available, data.target.as_ref())?; + if integration && target.kind != "lib" { + if functions.names().is_some() { + return Err(io::Error::other(format!("Function {id} belongs to a binary; integration mode requires library functions"))); + } + omitted += 1; } } + if omitted > 0 { + log::info!("Integration mode omits {omitted} binary focal functions"); + } + Ok(()) } @@ -122,7 +119,34 @@ impl BaselineCache { begin: i32, end: i32, ) -> io::Result { - let target = select_target(&self.metadata, name, file, self.integration)?.clone(); + self.mapping_for_target(work_dir, name, file, begin, end, None) + } + + pub fn mapping_for_target( + &mut self, + work_dir: &Path, + name: &str, + file: &Path, + begin: i32, + end: i32, + recorded: Option<&crate::target::TargetInfo>, + ) -> io::Result { + let target = if let Some(recorded) = recorded { + self.metadata + .root_package() + .unwrap() + .targets + .iter() + .find(|t| { + t.name == recorded.name + && t.kind.iter().any(|kind| kind.to_string() == recorded.kind) + }) + .ok_or_else(|| io::Error::other("Recorded coverage target is missing"))? + .clone() + } else { + select_target(&self.metadata, name, file, self.integration)?.clone() + }; + let key = format!("{:?}:{}", target.kind, target.name); if !self.reports.contains_key(&key) { log::info!( diff --git a/utgen/src/run/integration.rs b/utgen/src/run/integration.rs index 5adcad6ed1..d34cac40a5 100644 --- a/utgen/src/run/integration.rs +++ b/utgen/src/run/integration.rs @@ -230,7 +230,7 @@ pub fn gen_integration(test_gen_infos: &Vec, work_dir: &Path) -> Ve let file = test_gen.get_file(); let stem = Path::new(&file).file_stem().unwrap().to_str().unwrap(); let name = test_gen.get_name(); - let rename = name + let rename = test_gen.rust_name() .replace("::", "_") .replace(['{', '}'], "") .replace('#', "_"); diff --git a/utgen/src/run/llm_fix.rs b/utgen/src/run/llm_fix.rs index 88ef44323b..6d3e11e684 100644 --- a/utgen/src/run/llm_fix.rs +++ b/utgen/src/run/llm_fix.rs @@ -80,7 +80,7 @@ fn compiler_error_parser_from_json(work_path: &Path) -> Vec { .filter_map(|s| { let one_json: Result = serde_json::from_str(s); if let Ok(one_json) = one_json { - if one_json.has_spans() { + if one_json.is_error() && one_json.has_spans() { return Some(one_json); } else { return None; @@ -138,6 +138,7 @@ async fn compilation_fix_assistant_for_an_error( insert_kind: InsertKind, common: &Vec, integration_attrs: Option<&[String]>, + target: Option<&crate::target::TargetInfo>, ) -> io::Result<(Vec, TestCode, u32, u32)> { let mut iterative_time = 0; let mut min_error_set = compile_error_set.clone(); @@ -148,12 +149,15 @@ async fn compilation_fix_assistant_for_an_error( let current_error = error_message; let rust_assistant_prompt_json_str = include_str!("../../res/rustassistant_prompt.json"); let rust_assistant_prompt = RustAssistantPrompt::from_json(&rust_assistant_prompt_json_str); + let unit_command = target + .map(|t| format!("cargo test {} --no-run", t.unit_args().join(" "))) + .unwrap_or_default(); let preamble_prompt = rust_assistant_prompt.rustassistant_preamble.replace( "{cmd}", if integration_attrs.is_some() { "cargo test --test palm_candidate --no-run" } else { - "cargo test --tests" + &unit_command }, ) + "\n"; let error_snippet_prompt = current_error.code_snippets.clone(); @@ -236,7 +240,7 @@ async fn compilation_fix_assistant_for_an_error( // let _ = target_clean(&work_path); let test_type = TestType::Error; - run_test(project_path, work_path, test_type, false, false); + run_test(project_path, work_path, test_type, false, false, target); let compiler_message_set = compiler_error_parser_from_json(work_path); @@ -284,6 +288,7 @@ async fn compilation_fix_assistant_for_one_fn( let timeout = Duration::from_secs(FIX_TIMEOUT); // Existing between-round limit; not a subprocess deadline. let mut test_gen_info = test_gen_info; + let target = crate::target::resolve_target(&work_path, test_gen_info.target.as_ref())?; let file_rela = test_gen_info.get_file(); let file_path = project_dir.join(file_rela); let name = test_gen_info.get_name().to_string(); @@ -328,7 +333,7 @@ async fn compilation_fix_assistant_for_one_fn( // let _ = target_clean(&work_path); let test_type = TestType::Error; - run_test(&project_dir, &work_path, test_type, false, false); + run_test(&project_dir, &work_path, test_type, false, false, Some(&target)); // restore_file(&file_path); let mut test_file_content = TestCode::new(&file_path, &test_code); @@ -360,7 +365,7 @@ async fn compilation_fix_assistant_for_one_fn( compilation_fix_assistant_for_an_error( llm, random_error, &compile_error_set, &project_dir, &work_path, &file_path, &test_file_content, sig.clone(), - insert_kind, &common, None, + insert_kind, &common, None, Some(&target), ).await.map_err(|error| io::Error::other(format!( "Repair model request failed for {name}: {error}" )))?; @@ -403,7 +408,7 @@ async fn compilation_fix_assistant_for_one_fn( chain_test.repaired[num] = true; // let _ = target_clean(&work_path); - chain_test.can_compile[num] = cargo_check(&work_path); + chain_test.can_compile[num] = cargo_check(&work_path, &target); id += 1; restore_file(Path::new(&file_path)); drop(restore); @@ -528,6 +533,7 @@ async fn fix_integration_function( InsertKind::EOF, &Vec::new(), Some(&test.attrs), + None, ) .await?; errors = next_errors; @@ -595,6 +601,7 @@ pub async fn llm_fix( )); } super::validate_targets(&project_path, &work_path, functions, integration)?; + let available = crate::target::targets(&work_path)?; let parent_dir = project_path.join("utgen/generation/llm_fix"); let map_path = work_path.join("brinfo/name_map.json"); let nmap: HashMap = @@ -606,7 +613,8 @@ pub async fn llm_fix( .iter() .filter(|info| functions.contains(info.get_name())) { - info.check_mode(integration)?; + let target = crate::target::resolve_from(&available, info.target.as_ref())?; + if !integration || target.kind == "lib" { info.check_mode(integration)?; } } let mut test_gen_infos = get_test_gen_infos(&project_path, false); let saved: BTreeSet<_> = test_gen_infos.iter() @@ -617,11 +625,7 @@ pub async fn llm_fix( ); test_gen_infos.retain(|info| { let relative = info.get_file(); - let in_work_dir = if project_path == work_path { - relative.starts_with("src") - } else { - project_path.join(relative).starts_with(&work_path) - }; + let in_work_dir = project_path.join(relative).starts_with(&work_path); in_work_dir && functions.contains(info.get_name()) }); if let Some(names) = functions.names() { @@ -633,8 +637,17 @@ pub async fn llm_fix( } } } - for info in &test_gen_infos { - info.check_mode(integration)?; + for info in &mut test_gen_infos { + info.target = Some(crate::target::resolve_from( + &available, + info.target.as_ref(), + )?); + if !integration || info.target.as_ref().unwrap().kind == "lib" { + info.check_mode(integration)?; + } + } + if integration { + test_gen_infos.retain(|info| info.target.as_ref().unwrap().kind == "lib"); } if !integration { merge_common_in_code(&mut test_gen_infos); diff --git a/utgen/src/run/llm_fix_type.rs b/utgen/src/run/llm_fix_type.rs index 31a64797ac..e68c693e9f 100644 --- a/utgen/src/run/llm_fix_type.rs +++ b/utgen/src/run/llm_fix_type.rs @@ -24,6 +24,7 @@ struct Children { #[derive(Debug, Clone, Deserialize)] struct Message { + level: String, spans: Vec, children: Vec, rendered: String, @@ -41,6 +42,9 @@ pub struct CompilerMessage { } impl CompilerMessage { + pub fn is_error(&self) -> bool { + self.message.level == "error" + } pub fn has_spans(&self) -> bool { return self.message.has_spans(); } diff --git a/utgen/src/run/run.rs b/utgen/src/run/run.rs index 9831203075..f04c8b6002 100644 --- a/utgen/src/run/run.rs +++ b/utgen/src/run/run.rs @@ -78,6 +78,7 @@ pub fn run_test( test_type: TestType, is_json: bool, is_integration: bool, + target: Option<&crate::target::TargetInfo>, ) -> Vec { let mut return_value: Vec = Vec::new(); @@ -85,13 +86,16 @@ pub fn run_test( // println!("Running tests..."); let output_file = File::create(work_path.join("error_output.json")).unwrap(); Command::new("cargo") - .args(["test", "--tests", "--message-format", "json"]) + .arg("test") + .args(target.map(|t| t.unit_args()).unwrap_or_else(|| vec!["--tests"])) + .args(["--no-run", "--message-format", "json"]) .stdout(output_file) .current_dir(work_path) .output() .expect("Failed to run tests"); } else { - let coverage_output = super::coverage::collect_coverage(work_path, is_json) + let args = target.map(|t| t.unit_args()).unwrap_or_else(|| vec!["--tests"]); + let coverage_output = super::coverage::collect_coverage_args(work_path, is_json, &args) .expect("Failed to collect coverage"); if !is_integration { return_value = String::from_utf8_lossy(&coverage_output.stdout) @@ -148,6 +152,9 @@ impl PartialEq for BranchCoverageInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestRateInfo { pub function_name: String, + #[serde(default)] + pub rust_name: String, + pub target: Option, pub file_path: String, pub work_dir: String, pub tests: i32, @@ -370,6 +377,16 @@ pub fn gen_test_rate( ) { let mut test_gen_infos = get_test_gen_infos(project_dir, is_pre); test_gen_infos.retain(|info| functions.contains(info.get_name())); + let available = crate::target::targets(work_dir).expect("Cannot read test targets"); + for info in &mut test_gen_infos { + info.target = Some( + crate::target::resolve_from(&available, info.target.as_ref()) + .expect("Cannot resolve test target"), + ); + } + if integration { + test_gen_infos.retain(|info| info.target.as_ref().unwrap().kind == "lib"); + } if !integration { let mut test_rate_infos: Vec = Vec::new(); gen_coverage_and_pass_rate(project_dir, work_dir, &test_gen_infos, &mut test_rate_infos); @@ -419,6 +436,8 @@ pub fn gen_test_rate( let file_path = project_dir.join(file_rela); let mut test_rate_info = TestRateInfo { function_name: function_name.clone(), + rust_name: test_gen_info.rust_name().to_owned(), + target: test_gen_info.target.clone(), file_path: file_path.to_string_lossy().to_string(), work_dir: work_dir.to_string_lossy().to_string(), tests: integration_info.tests, @@ -474,7 +493,7 @@ pub fn gen_test_rate( } if !test_rate_info.coverage_available { let mapping = baselines - .mapping(work_dir, &function_name, &file_path, begin, end) + .mapping_for_target(work_dir, &function_name, &file_path, begin, end, test_gen_info.target.as_ref()) .expect("Cannot collect integration coverage baseline"); test_rate_info.apply_baseline(mapping); } @@ -815,9 +834,7 @@ fn gen_coverage_and_pass_rate( for test_gen_info in test_gen_infos.iter() { let file_rela = test_gen_info.get_file(); let file_path = project_dir.join(&file_rela); - if (project_dir == work_dir && !file_rela.starts_with("src")) - || (project_dir != work_dir && !file_path.starts_with(work_dir)) - { + if !file_path.starts_with(work_dir) { continue; } progress += 1; @@ -837,6 +854,8 @@ fn gen_coverage_and_pass_rate( let mut test_rate_info = TestRateInfo { function_name: name.clone(), + rust_name: test_gen_info.rust_name().to_owned(), + target: test_gen_info.target.clone(), file_path: file_path.to_string_lossy().to_string(), work_dir: work_dir.to_string_lossy().to_string(), tests: 0, @@ -908,7 +927,7 @@ fn gen_coverage_and_pass_rate( let test_type = TestType::CoverageRate; let test_name = format!("test_{}", fn_name); let run_test_output = - run_test(project_dir, work_dir, test_type, true, false); + run_test(project_dir, work_dir, test_type, true, false, test_gen_info.target.as_ref()); let loc = test_gen_info.get_loc(); let begin = loc.get_startline() as i32; let end = loc.get_endline() as i32; @@ -1020,12 +1039,13 @@ fn gen_coverage_and_pass_rate( test_rate_info.parse_codes_lines_and_branches(); if !test_rate_info.coverage_available { let loc = test_gen_info.get_loc(); - let mapping = baselines.mapping( + let mapping = baselines.mapping_for_target( work_dir, &name, &file_path, loc.get_startline() as i32, loc.get_endline() as i32, + test_gen_info.target.as_ref(), ) .expect("Cannot collect unit coverage baseline"); test_rate_info.apply_baseline(mapping); @@ -1480,7 +1500,7 @@ fn gen_coverage_rate_for_original_tests( .expect("cannot restore original tests for coverage"); // let _ = target_clean(work_dir); let test_type = TestType::CoverageRate; - run_test(project_dir, work_dir, test_type, true, false); + run_test(project_dir, work_dir, test_type, true, false, None); for test_gen_info in test_gen_infos.iter() { let function_name = test_gen_info.get_name().to_string(); let file_rela = test_gen_info.get_file(); diff --git a/utgen/src/selection.rs b/utgen/src/selection.rs index 865a13a5b5..e324bce76a 100644 --- a/utgen/src/selection.rs +++ b/utgen/src/selection.rs @@ -27,18 +27,41 @@ impl FunctionSelection { )); } let mut available = BTreeSet::new(); + let mut aliases: HashMap> = HashMap::new(); for work_dir in work_dirs { - let map: HashMap = - serde_json::from_slice(&fs::read(work_dir.join("brinfo/name_map.json"))?)?; - available.extend(map.into_keys()); + let map: HashMap = serde_json::from_slice(&fs::read(work_dir.join("brinfo/name_map.json"))?)?; + for (id, _) in map { + let name = if id.starts_with("lib:") || id.starts_with("bin:") { + id.split_once("::").map_or(id.as_str(), |(_, name)| name) + } else { &id }; + aliases.entry(name.to_owned()).or_default().insert(id.clone()); + available.insert(id); + } } - let unknown: Vec<_> = names.difference(&available).cloned().collect(); - if !unknown.is_empty() { - return Err(io::Error::other(format!( - "Unknown functions in --functions-file: {}", - unknown.join(", ") - ))); + let mut resolved = BTreeSet::new(); + for name in names { + if available.contains(&name) { + resolved.insert(name); + continue; + } + match aliases.get(&name) { + Some(ids) if ids.len() == 1 => { + resolved.insert(ids.iter().next().unwrap().clone()); + } + Some(ids) => { + return Err(io::Error::other(format!( + "Ambiguous function {name}; select one of: {}", + ids.iter().cloned().collect::>().join(", ") + ))); + } + None => { + return Err(io::Error::other(format!( + "Unknown functions in --functions-file: {name}" + ))); + } + } } + let names = resolved; Ok(Self { names: Some(names) }) } diff --git a/utgen/src/target.rs b/utgen/src/target.rs new file mode 100644 index 0000000000..62384a3275 --- /dev/null +++ b/utgen/src/target.rs @@ -0,0 +1,108 @@ +use cargo_metadata::{MetadataCommand, TargetKind}; +use serde::{Deserialize, Serialize}; +use std::{io, path::Path, process::Command}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TargetInfo { + pub kind: String, + pub name: String, + pub src_path: String, +} + +impl TargetInfo { + pub fn key(&self) -> String { + format!("{}:{}", self.kind, self.name) + } + + pub fn unit_args(&self) -> Vec<&str> { + if self.kind == "lib" { + vec!["--lib"] + } else { + vec!["--bin", &self.name] + } + } + + pub fn configure_analysis(&self, command: &mut Command, output: &Path) { + command + .env("PALM_TARGET_KIND", &self.kind) + .env("PALM_TARGET_NAME", &self.name) + .env("PALM_OUTPUT_DIR", output); + } +} + +pub fn targets(work_dir: &Path) -> io::Result> { + let metadata = MetadataCommand::new() + .manifest_path(work_dir.join("Cargo.toml")) + .current_dir(work_dir) + .no_deps() + .exec() + .map_err(io::Error::other)?; + let package = metadata + .root_package() + .ok_or_else(|| io::Error::other("A standalone Cargo package is required"))?; + if metadata.workspace_members.len() != 1 { + return Err(io::Error::other( + "Analysis currently supports one standalone Cargo package", + )); + } + let mut targets: Vec<_> = package + .targets + .iter() + .filter_map(|target| { + let kind = if target.is_kind(TargetKind::Lib) { + "lib" + } else if target.is_kind(TargetKind::Bin) { + "bin" + } else { + return None; + }; + Some(TargetInfo { + kind: kind.into(), + name: target.name.clone(), + src_path: target + .src_path + .as_std_path() + .strip_prefix(work_dir) + .unwrap_or(target.src_path.as_std_path()) + .to_string_lossy() + .into_owned(), + }) + }) + .collect(); + targets.sort_by_key(|target| (target.kind != "lib", target.name.clone())); + if targets.is_empty() { + return Err(io::Error::other( + "No ordinary library or binary target found", + )); + } + Ok(targets) +} + +/// Old single-target caches can still be used; mixed caches need recorded ownership. +pub fn resolve_target(work_dir: &Path, recorded: Option<&TargetInfo>) -> io::Result { + resolve_from(&targets(work_dir)?, recorded) +} + +pub fn resolve_from( + available: &[TargetInfo], + recorded: Option<&TargetInfo>, +) -> io::Result { + if let Some(recorded) = recorded { + return available + .iter() + .find(|target| *target == recorded) + .cloned() + .ok_or_else(|| { + io::Error::other(format!( + "Recorded target {} changed; analyze a fresh working copy", + recorded.key() + )) + }); + } + if available.len() == 1 { + return Ok(available[0].clone()); + } + Err(io::Error::other( + "Mixed-target results lack target ownership; analyze and generate in a fresh working copy", + )) +} diff --git a/utgen/src/types/brdata.rs b/utgen/src/types/brdata.rs index 76bd29735b..aeec2c8d3e 100644 --- a/utgen/src/types/brdata.rs +++ b/utgen/src/types/brdata.rs @@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize)] pub struct BrData { + #[serde(default)] + pub id: String, + #[serde(default)] + pub target: Option, pub name: String, pub name_with_impl: String, pub mod_info: ModInfo, diff --git a/utgen/src/types/testgen.rs b/utgen/src/types/testgen.rs index c968867eee..38a4068de9 100644 --- a/utgen/src/types/testgen.rs +++ b/utgen/src/types/testgen.rs @@ -10,6 +10,8 @@ use std::{ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RfocxtNameInformation { + #[serde(default)] + pub id: String, pub mod_name: String, pub fn_name: String, pub struct_name: String, @@ -26,6 +28,10 @@ pub enum InsertKind { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestGenInfo { + #[serde(default)] + pub id: String, + #[serde(default)] + pub target: Option, #[serde(default)] pub integration: bool, name: String, @@ -46,6 +52,8 @@ impl TestGenInfo { fn_tests: Vec, ) -> TestGenInfo { TestGenInfo { + id: String::new(), + target: None, integration: false, name, name_with_impl, @@ -81,6 +89,14 @@ impl TestGenInfo { } pub fn get_name(&self) -> &str { + if self.id.is_empty() { + &self.name + } else { + &self.id + } + } + + pub fn rust_name(&self) -> &str { &self.name } diff --git a/utgen/src/utils/check.rs b/utgen/src/utils/check.rs index bb587dc55a..ff54ee9830 100644 --- a/utgen/src/utils/check.rs +++ b/utgen/src/utils/check.rs @@ -4,8 +4,19 @@ use std::fs; use std::path::Path; use std::process::Command; -pub fn cargo_check(work_dir: &Path) -> Result<(), String> { - cargo_build(work_dir, &["--tests"]) +pub fn cargo_check(work_dir: &Path, target: &crate::target::TargetInfo) -> Result<(), String> { + let output = Command::new("cargo") + .arg("test") + .args(target.unit_args()) + .arg("--no-run") + .current_dir(work_dir) + .output() + .expect("failed to compile unit tests"); + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).into_owned()) + } } pub(crate) fn cargo_check_test(work_dir: &Path, target: &str) -> Result<(), String> { diff --git a/utgen/src/utils/preprocess.rs b/utgen/src/utils/preprocess.rs index aacc8a0d71..f57d3e6e52 100644 --- a/utgen/src/utils/preprocess.rs +++ b/utgen/src/utils/preprocess.rs @@ -1,4 +1,4 @@ -use cargo_metadata::MetadataCommand; +use cargo_metadata::{MetadataCommand, TargetKind}; use std::{collections::BTreeSet, fs, io, ops::Range, path::Path}; use syn::{Meta, Token, punctuated::Punctuated, spanned::Spanned, visit::Visit}; use walkdir::WalkDir; @@ -32,39 +32,45 @@ pub fn comment_out_tests(dir: &Path) -> io::Result<()> { // used by include! can be left alone. Directory-only preprocessing remains // usable without requiring a manifest. let roots: BTreeSet<_> = if dir.join("Cargo.toml").is_file() { + let manifest = dir.join("Cargo.toml").canonicalize()?; MetadataCommand::new() - .manifest_path(dir.join("Cargo.toml").canonicalize()?) + .manifest_path(&manifest) .current_dir(dir) .no_deps() .exec() .map_err(io::Error::other)? .packages .into_iter() + .filter(|package| package.manifest_path.as_std_path() == manifest) .flat_map(|p| { p.targets .into_iter() + .filter(|t| t.is_kind(TargetKind::Lib) || t.is_kind(TargetKind::Bin)) .map(|t| t.src_path.into_std_path_buf()) }) .collect() } else { BTreeSet::new() }; - // Parse every file before writing any of them. - let mut changes = Vec::new(); + let mut files = roots.clone(); for entry in WalkDir::new(src) { let entry = entry.map_err(io::Error::other)?; - let path = entry.path(); - if entry.file_type().is_file() && path.extension().is_some_and(|e| e == "rs") { - let original = fs::read_to_string(path)?; - let prepared = prepare_source(&original, roots.contains(path)).map_err(|err| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("{}: {err}", path.display()), - ) - })?; - if prepared != original { - changes.push((path.to_owned(), prepared)); - } + if entry.file_type().is_file() && entry.path().extension().is_some_and(|e| e == "rs") { + files.insert(entry.into_path()); + } + } + // Parse every file before writing any of them, including non-default Cargo entries. + let mut changes = Vec::new(); + for path in files { + let original = fs::read_to_string(&path)?; + let prepared = prepare_source(&original, roots.contains(&path)).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{}: {err}", path.display()), + ) + })?; + if prepared != original { + changes.push((path, prepared)); } } for (path, prepared) in changes { diff --git a/utgen/tests/cli_config.rs b/utgen/tests/cli_config.rs index 04bdff25d6..7be53aa87a 100644 --- a/utgen/tests/cli_config.rs +++ b/utgen/tests/cli_config.rs @@ -270,13 +270,13 @@ fn analysis_stops_after_a_failed_tool_command() { let output = fixture .command() .args(["analyze", "-p", "."]) + .env("CARGO", &cargo) .env("PATH", fixture.0.join("bin")) .output() .unwrap(); let error = String::from_utf8_lossy(&output.stderr); assert!(!output.status.success()); assert_eq!(error.matches("fixture-cargo-failure").count(), 1, "{error}"); - assert!(error.contains("42"), "{error}"); assert!(!fixture.0.join("brinfo").exists()); assert!(!fixture.0.join("focxt").exists()); }