1use crate::{
8 cmd::test::RerunFailure,
9 result::{TestResult, TestStatus},
10 workspace,
11};
12use alloy_primitives::{U256, keccak256};
13use clap::Parser;
14use eyre::{Context, Result, ensure, eyre};
15use foundry_cli::{opts::BuildOpts, utils::LoadConfig};
16use foundry_common::{fs::canonicalize_path, sh_println, sh_warn};
17use foundry_compilers::{
18 Graph,
19 cache::SOLIDITY_FILES_CACHE_FILENAME,
20 compilers::{
21 Language,
22 multi::{MultiCompilerLanguage, MultiCompilerParser},
23 solc::Solc,
24 },
25 utils::source_files_iter,
26};
27use foundry_config::{Config, DenyLevel, impl_figment_convert};
28use foundry_evm::fuzz::CounterExample;
29use regex::Regex;
30use serde::{Deserialize, Serialize};
31use std::{
32 cmp::Reverse,
33 collections::{BTreeMap, BTreeSet, HashSet},
34 ffi::OsString,
35 fs,
36 io::{self, Write},
37 ops::Add,
38 path::{Path, PathBuf},
39 process::{Command, ExitStatus, Output, Stdio},
40 sync::{
41 LazyLock,
42 atomic::{AtomicUsize, Ordering},
43 },
44 thread,
45 time::{Duration, Instant, SystemTime},
46};
47use tempfile::TempDir;
48use toml_edit::DocumentMut;
49
50#[cfg(unix)]
51use rustix::process::{Pid, Signal, kill_process_group};
52#[cfg(unix)]
53use std::{os::unix::process::CommandExt, sync::Mutex};
54
55mod matcher;
56
57const DEFAULT_AGENTS: usize = 4;
62const DEFAULT_QUORUM: usize = 2;
63const SESSION_POLL: Duration = Duration::from_millis(100);
66const RUN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
68const AGENT_CONFIG: [&str; 14] = [
73 ".agents",
74 ".claude",
75 ".clinerules",
76 ".codex",
77 ".cursor",
78 ".cursorrules",
79 ".gemini",
80 ".windsurf",
81 ".windsurfrules",
82 "AGENT.md",
83 "AGENTS.md",
84 "CLAUDE.local.md",
85 "CLAUDE.md",
86 "GEMINI.md",
87];
88const PASSED_ENV: [&str; 7] = ["LANG", "LC_ALL", "PATH", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR"];
92pub(crate) const SELECT_ALL_TESTS: [&str; 10] = [
95 "--match-contract",
96 ".*",
97 "--match-test",
98 ".*",
99 "--no-match-contract",
100 "$^",
101 "--no-match-test",
102 "$^",
103 "--no-match-path",
104 "/forge-properties-none/**",
105];
106
107const VERIFY_PROMPT: &str = include_str!("properties/verify_prompt.md");
110
111static BLOCKED: LazyLock<Regex> = LazyLock::new(|| {
115 Regex::new(r#"FFI is disabled|network access is disabled|environment variable "[^"]*" not found|failed to resolve env var|inline config cannot"#)
116 .unwrap()
117});
118static COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)//[^\n]*|/\*.*?\*/").unwrap());
120static CONTRACT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\bcontract\s+(\w+)").unwrap());
122#[cfg(unix)]
124static SESSIONS: Mutex<Vec<Pid>> = Mutex::new(Vec::new());
125
126#[derive(Clone, Debug, Parser)]
128pub struct PropertiesArgs {
129 #[arg(long, value_name = "PATH")]
133 target: Vec<PathBuf>,
134
135 #[arg(long, value_name = "PATH")]
139 context: Vec<PathBuf>,
140
141 #[arg(long, required = true, value_name = "PATH", verbatim_doc_comment)]
164 agent_command: PathBuf,
165
166 #[arg(long, allow_hyphen_values = true, value_name = "ARG")]
168 agent_arg: Vec<OsString>,
169
170 #[arg(long, value_name = "PATH", verbatim_doc_comment)]
193 verifier_command: Option<PathBuf>,
194
195 #[arg(long, allow_hyphen_values = true, requires = "verifier_command", value_name = "ARG")]
197 verifier_arg: Vec<OsString>,
198
199 #[arg(long, required = true, action = clap::ArgAction::Append, value_name = "SEED")]
201 seed: Vec<U256>,
202
203 #[arg(long, default_value_t = DEFAULT_AGENTS, value_name = "N")]
206 agents: usize,
207
208 #[arg(long, value_name = "K")]
218 quorum: Option<usize>,
219
220 #[arg(long, value_name = "SECS")]
223 timeout: Option<u64>,
224
225 #[arg(long)]
230 allow_ffi: bool,
231
232 #[arg(long)]
237 allow_network: bool,
238
239 #[command(flatten)]
240 build: BuildOpts,
241}
242
243impl_figment_convert!(PropertiesArgs, build);
244
245#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
247struct Usage {
248 #[serde(default)]
249 input_tokens: u64,
250 #[serde(default)]
251 output_tokens: u64,
252 #[serde(default)]
253 cost_usd: f64,
254}
255
256impl Add for Usage {
257 type Output = Self;
258
259 fn add(self, other: Self) -> Self {
260 Self {
261 input_tokens: self.input_tokens + other.input_tokens,
262 output_tokens: self.output_tokens + other.output_tokens,
263 cost_usd: self.cost_usd + other.cost_usd,
264 }
265 }
266}
267
268#[derive(Clone, Debug)]
270pub(crate) struct TestFile {
271 pub(crate) path: PathBuf,
272 pub(crate) content: String,
273}
274
275#[derive(Clone, Debug, Serialize)]
277struct Failure {
278 test: String,
280 path: PathBuf,
281 reason: String,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 claim: Option<String>,
285}
286
287#[derive(Debug, Serialize)]
289struct AgentFailure {
290 agent: usize,
291 #[serde(flatten)]
292 failure: Failure,
293 file: PathBuf,
295}
296
297impl std::fmt::Display for AgentFailure {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 let failure = &self.failure;
300 write!(f, " agent {}: {} ({})", self.agent, failure.test, failure.reason)?;
301 if let Some(claim) = &failure.claim {
302 write!(f, "\n claim: {claim}")?;
303 }
304 write!(f, "\n file: {}", self.file.display())
305 }
306}
307
308#[derive(Debug, Serialize)]
310struct Rejected {
311 agent: usize,
312 paths: Vec<PathBuf>,
313 reason: String,
314}
315
316#[derive(Debug, Serialize)]
318struct BugGroup {
319 agents: Vec<usize>,
321 failures: Vec<AgentFailure>,
323 #[serde(skip_serializing_if = "Option::is_none")]
325 verification: Option<Verification>,
326}
327
328#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
332#[serde(rename_all = "snake_case")]
333enum Verdict {
334 Confirmed,
336 #[default]
338 NeedsValidation,
339 Rejected,
341}
342
343#[derive(Debug, Deserialize, Serialize)]
345struct Evidence {
346 #[serde(default)]
347 file: String,
348 #[serde(default)]
349 line: u64,
350 #[serde(default)]
351 quote: String,
352}
353
354#[derive(Debug, Default, Deserialize, Serialize)]
356struct Judgment {
357 verdict: Verdict,
358 #[serde(default)]
359 reason: String,
360 #[serde(default)]
361 evidence: Vec<Evidence>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 unresolved: Option<String>,
365}
366
367#[derive(Debug, Serialize)]
369struct Verification {
370 #[serde(flatten)]
371 judgment: Judgment,
372 #[serde(skip_serializing_if = "Option::is_none")]
375 error: Option<String>,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 usage: Option<Usage>,
378}
379
380impl Verification {
381 fn failed(error: String) -> Self {
383 Self { judgment: Judgment::default(), error: Some(error), usage: None }
384 }
385}
386
387impl std::fmt::Display for Verification {
388 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389 let judgment = &self.judgment;
390 let mut lines = Vec::new();
391 if !judgment.reason.is_empty() {
392 lines.push(format!(" verifier: {}", judgment.reason));
393 }
394 if let Some(error) = &self.error {
395 lines.push(format!(" verifier error: {error}"));
396 }
397 if let Some(unresolved) = judgment.unresolved.as_deref().filter(|fact| !fact.is_empty()) {
398 lines.push(format!(" unresolved: {unresolved}"));
399 }
400 for evidence in &judgment.evidence {
401 let quote = evidence.quote.split_whitespace().collect::<Vec<_>>().join(" ");
402 lines.push(format!(" evidence: {}:{}: {quote}", evidence.file, evidence.line));
403 }
404 write!(f, "{}", lines.join("\n"))
405 }
406}
407
408#[derive(Default)]
410struct TestRun {
411 possible_bugs: Vec<Failure>,
413 passing: Vec<RerunFailure>,
415 failing_files: HashSet<PathBuf>,
417 errors: Vec<String>,
419}
420
421struct Session {
423 id: usize,
424 error: Option<String>,
426 timed_out: bool,
427 usage: Option<Usage>,
428 possible_bugs: Vec<AgentFailure>,
429 rejected: Vec<Rejected>,
430 kept: Vec<TestFile>,
432 files: Vec<TestFile>,
434 imports: BTreeMap<PathBuf, BTreeSet<PathBuf>>,
437}
438
439struct RunDirs(Vec<PathBuf>);
441
442impl Drop for RunDirs {
443 fn drop(&mut self) {
444 for dir in &self.0 {
445 let _ = fs::remove_dir(dir);
446 }
447 }
448}
449
450impl PropertiesArgs {
451 pub fn run(mut self) -> Result<()> {
452 ensure!(self.seed.len() >= 2, "at least two --seed values are required");
453 ensure!(
454 self.seed.iter().collect::<HashSet<_>>().len() == self.seed.len(),
455 "--seed values must be distinct"
456 );
457 ensure!(self.agents > 0, "--agents must be greater than zero");
458 let quorum = self.quorum.unwrap_or(self.agents.min(DEFAULT_QUORUM));
459 ensure!((1..=self.agents).contains(&quorum), "--quorum must be between 1 and --agents");
460 ensure!(
461 self.target.iter().all(|path| workspace::is_safe_relative_path(path)),
462 "--target paths must be project-relative paths"
463 );
464 let paths = &mut self.build.project_paths;
467 if paths.config_path.is_none() {
468 let root = paths.try_project_root()?;
469 paths.root = Some(root.canonicalize().wrap_err("failed to resolve project root")?);
470 }
471 let mut config = self.load_config()?;
472 restrict_config(&mut config, self.allow_ffi, self.allow_network);
473 let generated_tests =
474 workspace::relative_to_root(&config.root, &config.test).join("generated");
475 workspace::ensure_safe_relative_path(&generated_tests, "generated test", &config.test)?;
476 let generated_dir = config.root.join(&generated_tests);
479 ensure_inside(&config.root, &generated_dir, &generated_tests)?;
480 fs::create_dir_all(&generated_dir)
481 .wrap_err_with(|| format!("failed to create {}", generated_dir.display()))?;
482 let mut run = 1;
483 let run_dirs = loop {
484 let mut dirs = RunDirs(Vec::new());
485 for id in 1..=self.agents {
486 let dir = generated_dir.join(format!("run-{run}-agent-{id}"));
487 match fs::create_dir(&dir) {
488 Ok(()) => dirs.0.push(dir),
489 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => break,
490 Err(error) => {
491 return Err(error)
492 .wrap_err_with(|| format!("failed to create {}", dir.display()));
493 }
494 }
495 }
496 if dirs.0.len() == self.agents {
497 break dirs;
498 }
499 run += 1;
500 };
501 let cache_root = config.cache_path.join("properties");
502 let verifier_command = self
503 .verifier_command
504 .as_ref()
505 .map(|command| command.canonicalize().wrap_err("failed to resolve verifier command"))
506 .transpose()?;
507 let mut sandbox = Sandbox::new(&config, vec![config.root.join(&generated_tests)])?;
510 sandbox.end_on_interrupt(run_dirs.0.clone())?;
511 if let Some(errors) = sandbox.build()? {
512 sh_warn!("the project does not build, so every test run builds it: {errors}")?;
513 }
514 let campaign = Campaign {
515 args: &self,
516 config: &config,
517 sandbox,
518 verifier_command,
519 generated_tests,
520 run,
521 cache_root,
522 quorum,
523 };
524 campaign.run()
525 }
526}
527
528type Fingerprint = BTreeMap<PathBuf, (u64, Option<SystemTime>)>;
530
531struct Campaign<'a> {
533 args: &'a PropertiesArgs,
534 config: &'a Config,
535 sandbox: Sandbox<'a>,
536 verifier_command: Option<PathBuf>,
538 generated_tests: PathBuf,
540 run: usize,
542 cache_root: PathBuf,
543 quorum: usize,
544}
545
546pub(crate) fn restrict_config(config: &mut Config, allow_ffi: bool, allow_network: bool) {
551 config.ffi &= allow_ffi;
552 if !allow_network {
554 config.eth_rpc_url = None;
555 config.eth_rpc_jwt = None;
556 config.eth_rpc_headers = None;
557 config.etherscan_api_key = None;
558 config.etherscan = Default::default();
559 config.rpc_endpoints = Default::default();
560 }
561}
562
563pub(crate) struct Sandbox<'a> {
567 config: &'a Config,
568 forge: PathBuf,
569 skipped: Vec<PathBuf>,
571 build_cache: Option<TempDir>,
573 home: PathBuf,
575 root: TempDir,
578}
579
580struct AgentInputs {
582 agent_command: PathBuf,
583 targets: Vec<PathBuf>,
585 context: Vec<PathBuf>,
587}
588
589impl Campaign<'_> {
590 fn run(&self) -> Result<()> {
591 let agent_command =
592 self.args.agent_command.canonicalize().wrap_err("failed to resolve agent command")?;
593 let context = self
594 .args
595 .context
596 .iter()
597 .map(|path| {
598 canonicalize_path(path)
599 .wrap_err_with(|| format!("failed to resolve context {}", path.display()))
600 })
601 .collect::<Result<Vec<_>>>()?;
602 let targets = if self.args.target.is_empty() {
603 source_files_iter(&self.config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
604 .map(|path| workspace::relative_to_root(&self.config.root, &path))
605 .collect()
606 } else {
607 self.args.target.clone()
608 };
609 let inputs = AgentInputs { agent_command, targets, context };
610 if self.cache_root.exists() {
612 fs::remove_dir_all(&self.cache_root)?;
613 }
614 let fingerprint = project_fingerprint(self.config, &self.sandbox.dependencies());
617 let results = thread::scope(|scope| {
620 let inputs = &inputs;
621 let agents = (1..=self.args.agents)
622 .map(|id| scope.spawn(move || self.run_agent(id, inputs)))
623 .collect::<Vec<_>>();
624 agents
625 .into_iter()
626 .map(|agent| agent.join().expect("agent thread panicked"))
627 .collect::<Vec<_>>()
628 });
629 let mut sessions = Vec::new();
630 let mut errors = Vec::new();
631 for (id, result) in (1..).zip(results) {
632 match result {
633 Ok(session) => {
634 errors
635 .extend(session.error.as_ref().map(|error| format!("agent {id}: {error}")));
636 sessions.push(session);
637 }
638 Err(error) => errors.push(format!("agent {id}: {error:#}")),
639 }
640 }
641 self.report(&sessions, &inputs, &fingerprint)?;
642 ensure!(errors.is_empty(), "{}", errors.join("\n"));
643 Ok(())
644 }
645
646 fn report(
649 &self,
650 sessions: &[Session],
651 inputs: &AgentInputs,
652 fingerprint: &Fingerprint,
653 ) -> Result<()> {
654 let failures = sessions
655 .iter()
656 .flat_map(|session| &session.possible_bugs)
657 .map(|failure| AgentFailure {
658 agent: failure.agent,
659 failure: failure.failure.clone(),
660 file: failure.file.clone(),
661 })
662 .collect::<Vec<_>>();
663 let (blocked, failures) = failures
666 .into_iter()
667 .partition::<Vec<_>, _>(|failure| BLOCKED.is_match(&failure.failure.reason));
668 let (mut possible_bugs, below_quorum) = group_failures(failures)
669 .into_iter()
670 .partition::<Vec<_>, _>(|group| group.agents.len() >= self.quorum);
671 if let Some(command) = &self.verifier_command {
672 self.verify_groups(command, &mut possible_bugs, sessions, inputs);
673 }
674 let after = project_fingerprint(self.config, &self.sandbox.dependencies());
675 let tainted = after
676 .iter()
677 .filter(|(path, metadata)| fingerprint.get(*path) != Some(*metadata))
678 .map(|(path, _)| path)
679 .chain(fingerprint.keys().filter(|path| !after.contains_key(*path)))
680 .cloned()
681 .collect::<Vec<_>>();
682 let print_group = |label: &str, group: &BugGroup| -> Result<()> {
683 let lead = &group.failures[0].failure;
684 let summary = lead.claim.as_deref().unwrap_or(&lead.test);
685 sh_println!("{label} ({}/{} agents): {summary}", group.agents.len(), self.args.agents)?;
686 for failure in &group.failures {
687 sh_println!("{failure}")?;
688 }
689 if let Some(verification) = &group.verification {
690 sh_println!("{verification}")?;
691 }
692 Ok(())
693 };
694 for group in &possible_bugs {
695 let label = match group.verification.as_ref().map(|v| v.judgment.verdict) {
696 None => "possible bug",
697 Some(Verdict::Confirmed) => "confirmed",
698 Some(Verdict::NeedsValidation) => "needs validation",
699 Some(Verdict::Rejected) => "rejected",
700 };
701 print_group(label, group)?;
702 }
703 let report_path = self.cache_root.join("report.json");
704 let report_display = report_path.strip_prefix(&self.config.root).unwrap_or(&report_path);
705 if !below_quorum.is_empty() {
708 sh_println!(
709 "{} possible bug(s) below the quorum of {}:",
710 below_quorum.len(),
711 self.quorum
712 )?;
713 for group in &below_quorum {
714 let label = if group.agents.len() == 1 { "single-agent" } else { "below quorum" };
715 print_group(label, group)?;
716 }
717 }
718 let normalize =
725 |content: &str| COMMENT.replace_all(content, "").split_whitespace().collect::<String>();
726 let mut seen = collect_tests(&self.config.root, &self.generated_tests, 0, &mut vec![])
727 .unwrap_or_default()
728 .iter()
729 .map(|file| normalize(&file.content))
730 .collect::<HashSet<_>>();
731 let mut held_back = Vec::new();
732 let mut kept = Vec::new();
733 let mut duplicates = Vec::new();
734 for session in sessions.iter().filter(|_| tainted.is_empty()) {
735 let conflict = session.kept.iter().find_map(|file| {
736 let path = self.config.root.join(&file.path);
737 if let Err(error) = ensure_inside(&self.config.root, &path, &file.path) {
739 return Some(format!("{error:#}"));
740 }
741 path.exists().then(|| format!("{} exists already", file.path.display()))
742 });
743 if let Some(reason) = conflict {
744 let paths = session.kept.iter().map(|file| file.path.clone()).collect();
745 held_back.push(Rejected { agent: session.id, paths, reason });
746 continue;
747 }
748 let files = &session.kept;
749 let imports = |file: &TestFile, other: &TestFile| {
750 session.imports.get(&file.path).is_some_and(|i| i.contains(&other.path))
751 };
752 let mut write =
753 files.iter().map(|file| seen.insert(normalize(&file.content))).collect::<Vec<_>>();
754 while let Some(index) = (0..files.len()).find(|&i| {
755 !write[i]
756 && files.iter().zip(&write).any(|(other, written)| {
757 *written && (imports(other, &files[i]) || imports(&files[i], other))
758 })
759 }) {
760 write[index] = true;
761 }
762 for (file, write) in files.iter().zip(write) {
763 if write {
764 kept.push(file);
765 } else {
766 duplicates.push(&file.path);
767 }
768 }
769 }
770 let rejected = sessions
771 .iter()
772 .flat_map(|session| &session.rejected)
773 .chain(&held_back)
774 .collect::<Vec<_>>();
775 if !rejected.is_empty() {
776 sh_println!(
777 "{} generated test file(s) did not compile or were not allowed: see {}",
778 rejected.iter().map(|rejected| rejected.paths.len()).sum::<usize>(),
779 report_display.display()
780 )?;
781 }
782 if !blocked.is_empty() {
783 sh_warn!(
784 "{} failing test(s) used something that Forge blocks (ffi, environment, network, or inline config), so they are not possible bugs: see {}",
785 blocked.len(),
786 report_display.display()
787 )?;
788 }
789 let timed_out = sessions
790 .iter()
791 .filter(|session| session.timed_out)
792 .map(|session| session.id)
793 .collect::<Vec<_>>();
794 if !timed_out.is_empty() {
795 sh_warn!(
796 "{} agent(s) reached the timeout, so Forge ended them and ran the tests that they wrote",
797 timed_out.len()
798 )?;
799 }
800 let usage = sessions
801 .iter()
802 .filter_map(|session| {
803 Some(serde_json::json!({ "agent": session.id, "usage": session.usage? }))
804 })
805 .collect::<Vec<_>>();
806 if let Some(total) = sessions.iter().filter_map(|session| session.usage).reduce(Usage::add)
807 {
808 sh_println!(
809 "agent usage: {} input tokens, {} output tokens, {:.2} USD",
810 total.input_tokens,
811 total.output_tokens,
812 total.cost_usd
813 )?;
814 }
815 if let Some(total) = possible_bugs
816 .iter()
817 .filter_map(|group| group.verification.as_ref()?.usage)
818 .reduce(Usage::add)
819 {
820 sh_println!(
821 "verifier usage: {} input tokens, {} output tokens, {:.2} USD",
822 total.input_tokens,
823 total.output_tokens,
824 total.cost_usd
825 )?;
826 }
827
828 fs::create_dir_all(&self.cache_root)?;
829 let report = serde_json::json!({
830 "agents": self.args.agents,
831 "quorum": self.quorum,
832 "possible_bugs": possible_bugs,
833 "below_quorum": below_quorum,
834 "blocked": blocked,
835 "rejected": rejected,
836 "timed_out": timed_out,
837 "tainted": tainted,
838 "kept": kept.iter().map(|file| &file.path).collect::<Vec<_>>(),
839 "duplicates": duplicates,
840 "usage": usage,
841 });
842 fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?;
843
844 ensure!(
847 tainted.is_empty(),
848 "{} project file(s) changed while the agents ran, so Forge kept no generated test: see {}",
849 tainted.len(),
850 report_display.display()
851 );
852 if kept.is_empty() {
853 sh_println!("no generated test file was kept")?;
854 return Ok(());
855 }
856 for file in &kept {
857 let path = self.config.root.join(&file.path);
858 fs::create_dir_all(path.parent().expect("test path has a parent"))?;
859 fs::OpenOptions::new()
860 .write(true)
861 .create_new(true)
862 .open(&path)
863 .and_then(|mut target| target.write_all(file.content.as_bytes()))
864 .wrap_err_with(|| format!("failed to write {}", file.path.display()))?;
865 }
866 sh_println!("added {} generated test file(s):", kept.len())?;
867 for file in &kept {
868 sh_println!(" {}", file.path.display())?;
869 }
870 if !duplicates.is_empty() {
871 sh_println!(
872 "skipped {} generated test file(s) that repeat a file of another agent or the project",
873 duplicates.len()
874 )?;
875 }
876 Ok(())
877 }
878
879 fn run_agent(&self, id: usize, inputs: &AgentInputs) -> Result<Session> {
881 let args = self.args;
882 let cache = self.cache_root.join(format!("agent-{id}"));
883 fs::create_dir_all(&cache)?;
884 let workspace = self.sandbox.workspace(&[])?;
885 let out = self.generated_tests.join(format!("run-{}-agent-{id}", self.run));
886 let out_dir = workspace.path().join(&out);
887 ensure_inside(workspace.path(), &out_dir, &out)?;
888 fs::create_dir_all(&out_dir)?;
889 let prompt = cache.join("prompt.md");
890 fs::write(&prompt, session_prompt(&inputs.targets, &inputs.context, &out_dir))?;
891 let usage_path = cache.join("usage.json");
892 let log_path = cache.join("agent.log");
893 let log = fs::File::create(&log_path)?;
894 let seed: U256 =
896 keccak256([args.seed[0].to_be_bytes::<32>(), U256::from(id).to_be_bytes()].concat())
897 .into();
898 let lines = |paths: &[PathBuf]| {
899 paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join("\n")
900 };
901
902 let mut command = Command::new(&inputs.agent_command);
903 command
904 .args(&args.agent_arg)
905 .current_dir(workspace.path())
906 .env("FORGE_PROPERTIES_PROMPT", &prompt)
907 .env("FORGE_PROPERTIES_TARGETS", lines(&inputs.targets))
908 .env("FORGE_PROPERTIES_CONTEXT", lines(&inputs.context))
909 .env("FORGE_PROPERTIES_OUT", &out_dir)
910 .env("FORGE_PROPERTIES_SEED", format!("{seed:#x}"))
911 .env("FORGE_PROPERTIES_USAGE", &usage_path)
912 .stdin(Stdio::null())
913 .stdout(log.try_clone()?)
914 .stderr(log);
915 let status = run_session(&mut command, args.timeout, "agent")?;
916 let log_display = log_path.strip_prefix(&self.config.root).unwrap_or(&log_path);
917 let error = status.filter(|status| !status.success()).map(|status| {
918 format!("agent command failed ({status}): see {}", log_display.display())
919 });
920 let usage = read_usage(&usage_path, &format!("agent {id}"))?;
921 let mut session = Session {
922 id,
923 error,
924 timed_out: status.is_none(),
925 usage,
926 possible_bugs: vec![],
927 rejected: vec![],
928 kept: vec![],
929 files: vec![],
930 imports: BTreeMap::new(),
931 };
932 session.files = collect_tests(workspace.path(), &out, id, &mut session.rejected)?;
933 let files = &session.files;
934 let copy = |path: &Path| cache.join("tests").join(path.strip_prefix(&out).unwrap_or(path));
936 for file in files {
937 let path = copy(&file.path);
938 fs::create_dir_all(path.parent().expect("test path has a parent"))?;
939 fs::write(&path, &file.content)?;
940 }
941 if files.is_empty() {
942 return Ok(session);
943 }
944
945 let workspace = self.sandbox.workspace(files)?;
946 let imports = collected_imports(self.config, workspace.path(), files)?;
947 let run = match self.sandbox.build_errors(workspace.path())? {
948 None => self.run_tests(workspace.path(), files)?,
949 Some(_) => {
956 let no_imports = BTreeSet::new();
957 let (roots, others) = files.iter().partition::<Vec<_>, _>(|file| {
958 !imports.values().any(|imported| imported.contains(&file.path))
959 });
960 let mut grouped = HashSet::new();
961 let mut ran = HashSet::new();
964 let mut run = TestRun::default();
965 for root in roots.into_iter().chain(others) {
966 if grouped.contains(&root.path) {
967 continue;
968 }
969 let imported = imports.get(&root.path).unwrap_or(&no_imports);
970 let group = files
971 .iter()
972 .filter(|file| file.path == root.path || imported.contains(&file.path))
973 .cloned()
974 .collect::<Vec<_>>();
975 let workspace = self.sandbox.workspace(&group)?;
976 if let Some(errors) = self.sandbox.build_errors(workspace.path())? {
977 grouped.insert(root.path.clone());
978 let paths = vec![root.path.clone()];
979 session.rejected.push(Rejected { agent: id, paths, reason: errors });
980 continue;
981 }
982 grouped.extend(group.iter().map(|file| file.path.clone()));
983 let group_run = self.run_tests(workspace.path(), &group)?;
984 run.possible_bugs.extend(
985 group_run.possible_bugs.into_iter().filter(|bug| !ran.contains(&bug.path)),
986 );
987 run.passing.extend(
988 group_run
989 .passing
990 .into_iter()
991 .filter(|test| !ran.contains(&test_path(test))),
992 );
993 run.failing_files.extend(group_run.failing_files);
994 run.errors.extend(group_run.errors);
995 ran.extend(group.into_iter().map(|file| file.path));
996 }
997 run
998 }
999 };
1000 if !run.errors.is_empty() {
1001 let paths = files.iter().map(|file| file.path.clone()).collect();
1002 session.rejected.push(Rejected { agent: id, paths, reason: run.errors.join("; ") });
1003 }
1004 let kept = kept_files(files, &imports, &run)?;
1005 session.kept = files.iter().filter(|file| kept.contains(&file.path)).cloned().collect();
1006 session.imports = imports;
1007 let root = &self.config.root;
1008 session.possible_bugs = run
1009 .possible_bugs
1010 .into_iter()
1011 .map(|failure| {
1012 let file = copy(&failure.path);
1013 let file = file.strip_prefix(root).map(Path::to_path_buf).unwrap_or(file);
1014 AgentFailure { agent: id, failure, file }
1015 })
1016 .collect();
1017 Ok(session)
1018 }
1019
1020 fn run_tests(&self, workspace: &Path, files: &[TestFile]) -> Result<TestRun> {
1022 let paths =
1023 files.iter().map(|file| file.path.display().to_string()).collect::<Vec<_>>().join(",");
1024 let mut run = TestRun::default();
1025 let mut outcomes = BTreeMap::<(String, String), (usize, Vec<String>)>::new();
1027 for seed in &self.args.seed {
1028 let mut command = self.forge_command(workspace, seed);
1029 command.args(["test", "--json", "--match-path", &format!("{{{paths}}}")]);
1030 command.args(SELECT_ALL_TESTS);
1031 command.arg("--restrict-inline-config");
1032 if !self.args.allow_network {
1033 command.arg("--no-network");
1034 }
1035 let Some(output) = self.sandbox.output(&mut command)? else {
1036 run.errors
1037 .push(format!("tests reached the timeout of {RUN_TIMEOUT:?} on seed {seed}"));
1038 continue;
1039 };
1040 if !output.status.success() && output.stdout.is_empty() {
1041 run.errors.push(format!("tests failed on seed {seed}: {}", stderr(&output)));
1042 continue;
1043 }
1044 for (test, status, reason) in test_results(&output)? {
1045 let (passes, failures) = outcomes.entry((test.contract, test.test)).or_default();
1046 match status {
1047 TestStatus::Success => *passes += 1,
1048 TestStatus::Failure => {
1049 let reason = reason
1050 .filter(|reason| !reason.is_empty())
1051 .unwrap_or_else(|| "the test failed without a reason".to_string());
1052 failures.push(format!("seed {seed}: {reason}"));
1053 }
1054 TestStatus::Skipped => {}
1055 }
1056 }
1057 }
1058 let seeds = self.args.seed.len();
1059 for ((contract, test), (passes, failures)) in outcomes {
1060 let test = RerunFailure { contract, test };
1061 if passes == seeds {
1062 run.passing.push(test);
1063 continue;
1064 }
1065 let path = test_path(&test);
1066 run.failing_files.insert(path.clone());
1067 if failures.len() == seeds {
1068 let contract = test.contract.rsplit_once(':').map_or("", |(_, name)| name);
1069 let function =
1070 test.test.split_once('(').map_or(test.test.as_str(), |(name, _)| name);
1071 let claim = files
1072 .iter()
1073 .find(|file| file.path == path)
1074 .and_then(|file| test_claim(&file.content, contract, function));
1075 run.possible_bugs.push(Failure {
1076 test: test_display_name(&test),
1077 path,
1078 reason: failures.join("; "),
1079 claim,
1080 });
1081 }
1082 }
1083 run.possible_bugs
1085 .sort_by(|a, b| (a.claim.is_none(), &a.test).cmp(&(b.claim.is_none(), &b.test)));
1086 Ok(run)
1087 }
1088
1089 fn forge_command(&self, workspace: &Path, seed: &U256) -> Command {
1092 let seed_dir = seed_dir(self.config, workspace, seed);
1093 let mut command = self.sandbox.command(workspace);
1094 command
1095 .env("FOUNDRY_FUZZ_SEED", format!("{seed:#x}"))
1096 .env("FOUNDRY_FUZZ_FAILURE_PERSIST_DIR", seed_dir.join("fuzz"))
1097 .env("FOUNDRY_INVARIANT_FAILURE_PERSIST_DIR", seed_dir.join("invariant"))
1098 .env("FOUNDRY_TEST_FAILURES_FILE", seed_dir.join("test-failures"));
1100 command
1101 }
1102
1103 fn verify_groups(
1106 &self,
1107 command: &Path,
1108 groups: &mut [BugGroup],
1109 sessions: &[Session],
1110 inputs: &AgentInputs,
1111 ) {
1112 let next = AtomicUsize::new(0);
1113 let verifications = {
1114 let groups = &*groups;
1115 thread::scope(|scope| {
1116 let workers = (0..self.args.agents.min(groups.len()))
1117 .map(|_| {
1118 scope.spawn(|| {
1119 let mut done = Vec::new();
1120 loop {
1121 let index = next.fetch_add(1, Ordering::Relaxed);
1122 let Some(group) = groups.get(index) else { break };
1123 let verification = self
1124 .verify(command, index + 1, group, sessions, inputs)
1125 .unwrap_or_else(|error| {
1126 Verification::failed(format!("{error:#}"))
1127 });
1128 done.push((index, verification));
1129 }
1130 done
1131 })
1132 })
1133 .collect::<Vec<_>>();
1134 workers
1135 .into_iter()
1136 .flat_map(|worker| worker.join().expect("verifier thread panicked"))
1137 .collect::<Vec<_>>()
1138 })
1139 };
1140 for (index, verification) in verifications {
1141 groups[index].verification = Some(verification);
1142 }
1143 groups.sort_by_key(|group| group.verification.as_ref().map(|v| v.judgment.verdict));
1145 }
1146
1147 fn verify(
1150 &self,
1151 command: &Path,
1152 id: usize,
1153 group: &BugGroup,
1154 sessions: &[Session],
1155 inputs: &AgentInputs,
1156 ) -> Result<Verification> {
1157 let cache = self.cache_root.join(format!("verify-{id}"));
1158 fs::create_dir_all(&cache)?;
1159 let workspace = self.sandbox.workspace(&group_files(sessions, group))?;
1160 let prompt_path = cache.join("prompt.md");
1161 let group_path = cache.join("group.json");
1162 let verdict_path = cache.join("verdict.json");
1163 let usage_path = cache.join("usage.json");
1164 let log_path = cache.join("verifier.log");
1165 fs::write(&prompt_path, verify_prompt(group, &inputs.targets, &group_path, &verdict_path))?;
1166 let failures = group.failures.iter().map(|failure| {
1167 serde_json::json!({
1168 "agent": failure.agent,
1169 "test": failure.failure.test,
1170 "path": failure.failure.path,
1171 "claim": failure.failure.claim,
1172 "failure": failure.failure.reason,
1173 })
1174 });
1175 let group_json = serde_json::json!({
1176 "targets": inputs.targets,
1177 "agents": group.agents,
1178 "failures": failures.collect::<Vec<_>>(),
1179 });
1180 fs::write(&group_path, serde_json::to_vec_pretty(&group_json)?)?;
1181 let log = fs::File::create(&log_path)?;
1182
1183 let mut command = Command::new(command);
1184 command
1185 .args(&self.args.verifier_arg)
1186 .current_dir(workspace.path())
1187 .env("FORGE_PROPERTIES_VERIFY_PROMPT", &prompt_path)
1188 .env("FORGE_PROPERTIES_GROUP", &group_path)
1189 .env("FORGE_PROPERTIES_VERDICT", &verdict_path)
1190 .env("FORGE_PROPERTIES_USAGE", &usage_path)
1191 .stdin(Stdio::null())
1192 .stdout(log.try_clone()?)
1193 .stderr(log);
1194 let status = run_session(&mut command, self.args.timeout, "verifier")?;
1195 let usage = read_usage(&usage_path, &format!("verifier {id}"))?;
1196
1197 let mut errors = Vec::new();
1198 match status {
1199 None => errors.push("the verifier reached the timeout".to_string()),
1200 Some(status) if !status.success() => {
1201 errors.push(format!("verifier command failed ({status})"))
1202 }
1203 Some(_) => {}
1204 }
1205 let judgment = match fs::read(&verdict_path) {
1206 Ok(verdict) => match serde_json::from_slice::<Judgment>(&verdict) {
1207 Ok(judgment)
1209 if judgment.verdict == Verdict::Rejected && judgment.evidence.is_empty() =>
1210 {
1211 errors.push("the verifier rejected the group without evidence".to_string());
1212 Judgment { verdict: Verdict::NeedsValidation, ..judgment }
1213 }
1214 Ok(judgment) => judgment,
1215 Err(error) => {
1216 errors.push(format!("the verifier wrote an invalid verdict: {error}"));
1217 Judgment::default()
1218 }
1219 },
1220 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1221 errors.push("the verifier wrote no verdict".to_string());
1222 Judgment::default()
1223 }
1224 Err(error) => return Err(error).wrap_err("failed to read verifier verdict"),
1225 };
1226 let log_display = log_path.strip_prefix(&self.config.root).unwrap_or(&log_path);
1227 let error = (!errors.is_empty())
1228 .then(|| format!("{}: see {}", errors.join("; "), log_display.display()));
1229 Ok(Verification { judgment, error, usage })
1230 }
1231}
1232
1233impl<'a> Sandbox<'a> {
1234 pub(crate) fn new(config: &'a Config, skipped: Vec<PathBuf>) -> Result<Self> {
1237 let forge = std::env::current_exe().wrap_err("failed to resolve Forge executable")?;
1238 let root = tempfile::Builder::new().prefix("forge-properties-").tempdir()?;
1239 let home = root.path().join("home");
1240 fs::create_dir(&home)?;
1241 if let Some(svm) = Solc::svm_home().filter(|svm| svm.is_dir()) {
1242 workspace::symlink_entry(&svm, &home.join(".svm"), fs::metadata(&svm)?.file_type())?;
1243 }
1244 Ok(Self { config, forge, skipped, build_cache: None, home, root })
1245 }
1246
1247 pub(crate) fn end_on_interrupt(&self, reserved: Vec<PathBuf>) -> Result<()> {
1250 let root = self.root.path().to_path_buf();
1251 ctrlc::set_handler(move || {
1252 #[cfg(unix)]
1254 let sessions = SESSIONS.lock().unwrap();
1255 #[cfg(unix)]
1256 for group in sessions.iter() {
1257 let _ = kill_process_group(*group, Signal::KILL);
1258 }
1259 let _ = fs::remove_dir_all(&root);
1260 for dir in &reserved {
1262 let _ = fs::remove_dir(dir);
1263 }
1264 std::process::exit(130);
1265 })?;
1266 Ok(())
1267 }
1268
1269 pub(crate) fn build(&mut self) -> Result<Option<String>> {
1273 let workspace = self.workspace(&[])?;
1274 if let Some(errors) = self.build_errors(workspace.path())? {
1275 return Ok(Some(errors));
1276 }
1277 self.build_cache = Some(workspace);
1278 Ok(None)
1279 }
1280
1281 pub(crate) fn build_errors(&self, workspace: &Path) -> Result<Option<String>> {
1285 let Some(output) = self.output(self.command(workspace).args(["build", "--no-lint"]))?
1287 else {
1288 return Ok(Some(format!("the build reached the timeout of {RUN_TIMEOUT:?}")));
1289 };
1290 if !output.status.success() {
1291 return Ok(Some(stderr(&output)));
1292 }
1293 let stdout = String::from_utf8_lossy(&output.stdout);
1294 Ok(stdout.find("Warning (").filter(|_| self.config.deny.warnings()).map(|start| {
1295 format!("the project denies compiler warnings: {}", shorten(&stdout[start..]))
1296 }))
1297 }
1298
1299 pub(crate) fn workspace(&self, files: &[TestFile]) -> Result<TempDir> {
1302 let config = self.config;
1303 let workspace =
1304 tempfile::Builder::new().prefix("workspace-").tempdir_in(self.root.path())?;
1305 let dependencies = self.dependencies();
1306 let skipped = [config.out.clone(), config.cache_path.clone()]
1307 .into_iter()
1308 .chain(self.skipped.iter().cloned())
1309 .collect::<Vec<_>>();
1310 copy_project_tree(config, workspace.path(), Path::new(""), &dependencies, &skipped)?;
1311 let mut rebased = workspace::rebase_config_paths(config, workspace.path());
1314 rebased.extends = None;
1315 rebased.deny = DenyLevel::Never;
1318 rebased.dynamic_test_linking = false;
1322 rebased.allow_paths.extend(
1326 dependencies
1327 .iter()
1328 .filter(|path| path.is_dir())
1329 .filter_map(|path| canonicalize_path(path).ok()),
1330 );
1331 let mut toml = rebased.to_string_pretty()?.parse::<DocumentMut>()?;
1332 let profiles = toml[Config::PROFILE_SECTION]
1335 .as_table_mut()
1336 .expect("the resolved config has a profile table");
1337 for profile in config.profiles.iter().filter(|profile| **profile != config.profile) {
1338 profiles.entry(profile.as_str().as_str()).or_insert_with(toml_edit::table);
1339 }
1340 fs::write(workspace.path().join(Config::FILE_NAME), toml.to_string())?;
1341 for file in ["remappings.txt", "soldeer.lock"] {
1345 let path = workspace.path().join(file);
1346 if path.exists() {
1347 fs::remove_file(path)?;
1348 }
1349 }
1350 if let Some(build) = &self.build_cache {
1351 copy_build_output(config, build.path(), workspace.path())?;
1352 }
1353 for file in files {
1354 let path = workspace.path().join(&file.path);
1355 ensure_inside(workspace.path(), &path, &file.path)?;
1356 fs::create_dir_all(path.parent().expect("test path has a parent"))?;
1357 fs::write(path, &file.content)?;
1358 }
1359 Ok(workspace)
1360 }
1361
1362 fn dependencies(&self) -> Vec<PathBuf> {
1365 let config = self.config;
1366 config
1367 .libs
1368 .iter()
1369 .map(|lib| lib.as_path())
1370 .chain(["node_modules".as_ref(), "dependencies".as_ref()])
1371 .map(|path| config.root.join(path))
1372 .collect()
1373 }
1374
1375 pub(crate) fn command(&self, workspace: &Path) -> Command {
1379 let mut command = Command::new(&self.forge);
1380 command
1381 .current_dir(workspace)
1382 .env_clear()
1383 .envs(PASSED_ENV.iter().filter_map(|key| Some((key, std::env::var_os(key)?))))
1384 .env("HOME", &self.home)
1385 .env("FOUNDRY_PROFILE", self.config.profile.as_str().as_str());
1386 command
1387 }
1388
1389 pub(crate) fn output(&self, command: &mut Command) -> Result<Option<Output>> {
1393 let dir = tempfile::Builder::new().prefix("output-").tempdir_in(self.root.path())?;
1394 let (stdout, stderr) = (dir.path().join("stdout"), dir.path().join("stderr"));
1395 command
1396 .stdin(Stdio::null())
1397 .stdout(fs::File::create(&stdout)?)
1398 .stderr(fs::File::create(&stderr)?);
1399 let Some(status) = run_session(command, Some(RUN_TIMEOUT.as_secs()), "forge")? else {
1400 return Ok(None);
1401 };
1402 Ok(Some(Output { status, stdout: fs::read(stdout)?, stderr: fs::read(stderr)? }))
1403 }
1404}
1405
1406fn group_failures(failures: Vec<AgentFailure>) -> Vec<BugGroup> {
1409 let claims =
1410 failures.iter().map(|failure| failure.failure.claim.as_deref()).collect::<Vec<_>>();
1411 let group_of = matcher::root_cause_groups(&claims);
1412 let mut groups = BTreeMap::<usize, Vec<AgentFailure>>::new();
1413 for (failure, group) in failures.into_iter().zip(group_of) {
1414 groups.entry(group).or_default().push(failure);
1415 }
1416 let mut groups = groups
1417 .into_values()
1418 .map(|mut failures| {
1419 failures.sort_by(|a, b| {
1421 let key = |failure: &AgentFailure| {
1422 (failure.failure.claim.is_none(), failure.agent, failure.failure.test.clone())
1423 };
1424 key(a).cmp(&key(b))
1425 });
1426 let agents = failures.iter().map(|failure| failure.agent).collect::<BTreeSet<_>>();
1427 BugGroup { agents: agents.into_iter().collect(), failures, verification: None }
1428 })
1429 .collect::<Vec<_>>();
1430 groups.sort_by(|a, b| {
1431 let key = |group: &BugGroup| {
1432 let lead = &group.failures[0].failure;
1433 (lead.claim.is_none(), Reverse(group.agents.len()), lead.test.clone())
1434 };
1435 key(a).cmp(&key(b))
1436 });
1437 groups
1438}
1439
1440fn session_prompt(targets: &[PathBuf], context: &[PathBuf], out: &Path) -> String {
1444 let list = |paths: &[PathBuf]| {
1445 paths.iter().map(|path| format!("- {}\n", path.display())).collect::<String>()
1446 };
1447 let mut prompt = format!(
1448 "Find bugs in these targets of the Foundry project in the current directory:\n{}",
1449 list(targets)
1450 );
1451 if !context.is_empty() {
1452 prompt.push_str(&format!(
1453 "\nThese context files state intended behavior:\n{}",
1454 list(context)
1455 ));
1456 }
1457 prompt.push_str(&format!(
1458 "\nProve each bug with a Foundry test in {} that fails because of the bug and encodes the \
1459 intended behavior. The code under test can be wrong: the intended behavior is what its \
1460 documentation, its interfaces, and the standards it claims state. Name each test after \
1461 the bug. Right above each test, write exactly one comment line in this form:\n\n\
1462 // @bug <Contract>.<function> <file>:<line> — <claim>\n\n\
1463 <Contract>.<function> is the target function that has the bug, <file>:<line> is the \
1464 target source line that breaks the intended behavior, and <claim> is one sentence. Use \
1465 `//`, not `///`: the compiler rejects `@bug` as a NatSpec tag. You may run `forge test` \
1466 freely.\n",
1467 out.display()
1468 ));
1469 prompt
1470}
1471
1472fn collect_tests(
1476 workspace: &Path,
1477 out: &Path,
1478 agent: usize,
1479 rejected: &mut Vec<Rejected>,
1480) -> Result<Vec<TestFile>> {
1481 let out_dir = workspace.join(out);
1482 ensure_inside(workspace, &out_dir, out)?;
1483 match fs::symlink_metadata(&out_dir) {
1484 Ok(metadata) => ensure!(
1485 metadata.is_dir(),
1486 "the generated test directory {} is not a directory",
1487 out.display()
1488 ),
1489 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
1491 Err(error) => {
1492 return Err(error).wrap_err_with(|| format!("failed to read {}", out.display()));
1493 }
1494 }
1495 let mut files = Vec::new();
1496 let mut pending = vec![out.to_path_buf()];
1497 while let Some(dir) = pending.pop() {
1498 for entry in fs::read_dir(workspace.join(&dir))? {
1499 let entry = entry?;
1500 let path = dir.join(entry.file_name());
1501 let file_type = entry.file_type()?;
1502 if file_type.is_dir() {
1503 pending.push(path);
1504 continue;
1505 }
1506 if !file_type.is_file() || path.extension().is_none_or(|extension| extension != "sol") {
1507 continue;
1508 }
1509 let glob = ['*', '?', '[', ']', '{', '}', ',', '\\'];
1510 if path.iter().any(|component| component.to_string_lossy().contains(glob)) {
1511 let reason = "the path has a glob character".to_string();
1512 rejected.push(Rejected { agent, paths: vec![path], reason });
1513 continue;
1514 }
1515 match fs::read_to_string(entry.path()) {
1516 Ok(content) => files.push(TestFile { path, content }),
1517 Err(error) => {
1518 let reason = error.to_string();
1519 rejected.push(Rejected { agent, paths: vec![path], reason });
1520 }
1521 }
1522 }
1523 }
1524 files.sort_by(|a, b| a.path.cmp(&b.path));
1525 Ok(files)
1526}
1527
1528fn test_claim(source: &str, contract: &str, function: &str) -> Option<String> {
1533 let code = COMMENT.replace_all(source, |comment: ®ex::Captures<'_>| {
1535 comment[0].bytes().map(|byte| if byte == b'\n' { '\n' } else { ' ' }).collect::<String>()
1536 });
1537 let mut contracts = CONTRACT.captures_iter(&code);
1538 let start = contracts.find(|captures| &captures[1] == contract)?.get(0)?.end();
1539 let end = contracts.next().map_or(code.len(), |captures| captures.get(0).unwrap().start());
1540 let declaration = Regex::new(&format!(r"\bfunction\s+{}\s*\(", regex::escape(function)))
1541 .ok()?
1542 .find(&code[start..end])?;
1543 let line_start = source[..start + declaration.start()].rfind('\n').map_or(0, |i| i + 1);
1544 let mut lines = source[..line_start]
1545 .lines()
1546 .rev()
1547 .map(str::trim)
1548 .take_while(|line| {
1549 line.starts_with("//") || line.starts_with('*') || line.starts_with("/*")
1550 })
1551 .filter(|line| !line.contains("forge-config:"))
1552 .map(|line| line.trim_start_matches(['/', '*']).trim_end_matches(['*', '/']).trim())
1553 .map(|line| {
1554 match line.strip_prefix('@').map(|tagged| tagged.split_once(char::is_whitespace)) {
1555 Some(Some((tag, text))) if !matches!(tag, "bug" | "custom:bug") => text,
1559 Some(None) => "",
1560 _ => line,
1561 }
1562 })
1563 .filter(|line| !line.is_empty())
1564 .collect::<Vec<_>>();
1565 lines.reverse();
1566 if let Some(tag) = lines.iter().position(|line| {
1568 line.split_whitespace().next().is_some_and(|tag| matches!(tag, "@bug" | "@custom:bug"))
1569 }) {
1570 lines.drain(..tag);
1571 }
1572 (!lines.is_empty()).then(|| lines.join(" "))
1573}
1574
1575fn collected_imports(
1577 config: &Config,
1578 workspace: &Path,
1579 files: &[TestFile],
1580) -> Result<BTreeMap<PathBuf, BTreeSet<PathBuf>>> {
1581 let rebased = workspace::rebase_config_paths(config, workspace);
1582 let graph =
1583 Graph::<MultiCompilerParser>::resolve(&rebased.project_paths::<MultiCompilerLanguage>())
1584 .wrap_err("failed to resolve generated test imports")?;
1585 let paths = files.iter().map(|file| file.path.clone()).collect::<HashSet<_>>();
1586 let mut imports = BTreeMap::new();
1587 for (index, node) in graph.nodes.iter().enumerate() {
1588 let path = workspace::relative_to_root(workspace, node.path());
1589 if paths.contains(&path) {
1590 let imported = graph
1591 .all_imported_nodes(index)
1592 .map(|index| workspace::relative_to_root(workspace, graph.node(index).path()))
1593 .filter(|path| paths.contains(path))
1594 .collect();
1595 imports.insert(path, imported);
1596 }
1597 }
1598 Ok(imports)
1599}
1600
1601fn kept_files(
1604 files: &[TestFile],
1605 imports: &BTreeMap<PathBuf, BTreeSet<PathBuf>>,
1606 run: &TestRun,
1607) -> Result<HashSet<PathBuf>> {
1608 let passing = files
1609 .iter()
1610 .map(|file| &file.path)
1611 .filter(|path| {
1612 !run.failing_files.contains(*path)
1613 && run.passing.iter().any(|test| test_path(test) == **path)
1614 })
1615 .collect::<Vec<_>>();
1616 if passing.is_empty() {
1617 return Ok(HashSet::new());
1618 }
1619 if passing.len() == files.len() {
1620 return Ok(passing.into_iter().cloned().collect());
1621 }
1622 let mut kept = HashSet::new();
1623 for path in passing {
1624 let imported = imports.get(path).ok_or_else(|| {
1625 eyre!("passing test file {} is absent from the import graph", path.display())
1626 })?;
1627 if imported.iter().all(|path| !run.failing_files.contains(path)) {
1628 kept.insert(path.clone());
1629 kept.extend(imported.iter().cloned());
1630 }
1631 }
1632 Ok(kept)
1633}
1634
1635fn ensure_inside(root: &Path, path: &Path, orig: &Path) -> Result<()> {
1638 match path.ancestors().find(|ancestor| ancestor.exists()) {
1639 Some(existing) => workspace::ensure_within_root(root, existing, "generated test", orig),
1640 None => Ok(()),
1641 }
1642}
1643
1644fn project_fingerprint(config: &Config, dependencies: &[PathBuf]) -> Fingerprint {
1650 let mut files = BTreeMap::new();
1651 let mut read = HashSet::new();
1652 let mut pending = vec![(config.root.clone(), false)];
1654 while let Some((dir, shared)) = pending.pop() {
1655 if !fs::canonicalize(&dir).is_ok_and(|dir| read.insert(dir)) {
1656 continue;
1657 }
1658 let Ok(entries) = fs::read_dir(&dir) else { continue };
1659 for entry in entries.flatten() {
1660 let path = entry.path();
1661 if entry.file_name().to_string_lossy().starts_with('.')
1662 || path == config.out
1663 || path == config.cache_path
1664 {
1665 continue;
1666 }
1667 let shared = shared
1668 || dependencies.contains(&path)
1669 || (entry.file_type().is_ok_and(|file_type| file_type.is_symlink())
1670 && fs::canonicalize(&path)
1671 .is_ok_and(|target| !target.starts_with(&config.root)));
1672 let Ok(metadata) = (if shared { fs::metadata(&path) } else { entry.metadata() }) else {
1673 continue;
1674 };
1675 if metadata.is_dir() {
1676 pending.push((path, shared));
1677 } else if shared && metadata.is_file() {
1678 let path = path.strip_prefix(&config.root).unwrap_or(&path).to_path_buf();
1680 files.insert(path, (metadata.len(), metadata.modified().ok()));
1681 }
1682 }
1683 }
1684 files
1685}
1686
1687fn copy_project_tree(
1696 config: &Config,
1697 copy: &Path,
1698 dir: &Path,
1699 dependencies: &[PathBuf],
1700 skipped: &[PathBuf],
1701) -> Result<()> {
1702 fs::create_dir_all(copy.join(dir))?;
1703 for entry in fs::read_dir(config.root.join(dir))? {
1704 let entry = entry?;
1705 let path = entry.path();
1706 let name = entry.file_name();
1707 let rel = dir.join(&name);
1708 let target = copy.join(&rel);
1709 if name == ".git"
1710 || AGENT_CONFIG.iter().any(|config| name == *config)
1711 || (dir.as_os_str().is_empty() && name.to_string_lossy().starts_with('.'))
1712 || skipped.contains(&path)
1713 {
1714 continue;
1715 }
1716 let file_type = entry.file_type()?;
1717 if dependencies.contains(&path) && path.is_dir() {
1718 fs::create_dir_all(&target)?;
1719 for dependency in fs::read_dir(&path)? {
1720 let dependency = dependency?;
1721 let link = target.join(dependency.file_name());
1722 workspace::symlink_entry(&dependency.path(), &link, dependency.file_type()?)?;
1723 }
1724 } else if file_type.is_symlink() {
1725 let link = match path.canonicalize() {
1726 Ok(resolved) => match resolved.strip_prefix(&config.root) {
1727 Ok(resolved) => copy.join(resolved),
1728 Err(_) => resolved,
1729 },
1730 Err(_) => fs::read_link(&path)?,
1732 };
1733 workspace::symlink_entry(&link, &target, file_type)?;
1734 } else if file_type.is_dir() {
1735 copy_project_tree(config, copy, &rel, dependencies, skipped)?;
1736 } else {
1737 fs::copy(&path, &target)?;
1738 }
1739 }
1740 Ok(())
1741}
1742
1743fn copy_build_output(config: &Config, build: &Path, workspace: &Path) -> Result<()> {
1751 let out = workspace::relative_to_root(&config.root, &config.out);
1752 let cache = workspace::relative_to_root(&config.root, &config.cache_path)
1753 .join(SOLIDITY_FILES_CACHE_FILENAME);
1754 if out.is_absolute() || cache.is_absolute() || !build.join(&cache).exists() {
1756 return Ok(());
1757 }
1758 workspace::copy_dir_recursive(&build.join(&out), &workspace.join(&out))?;
1759 let mut text = fs::read_to_string(build.join(&cache))?;
1760 for (from, to) in [
1762 (canonicalize_path(build)?, canonicalize_path(workspace)?),
1763 (build.to_path_buf(), workspace.to_path_buf()),
1764 ] {
1765 text = text.replace(&format!("{}/", from.display()), &format!("{}/", to.display()));
1766 }
1767 fs::create_dir_all(workspace.join(&cache).parent().expect("cache file has a parent"))?;
1768 fs::write(workspace.join(&cache), text)?;
1769 Ok(())
1770}
1771
1772fn test_results(output: &Output) -> Result<Vec<(RerunFailure, TestStatus, Option<String>)>> {
1775 let mut results = Vec::new();
1776 for (contract, test, result) in suite_results(output)? {
1777 let key = |name: &str| RerunFailure { contract: contract.clone(), test: name.into() };
1778 let handler_failures = result
1780 .invariant_handler_failures
1781 .iter()
1782 .chain(result.invariant_failures.iter().filter(|f| f.predicate_name().is_none()))
1783 .map(|failure| {
1784 format!(
1785 "handler {}: {}",
1786 failure.name(),
1787 failure_reason(failure.reason(), failure.counterexample())
1788 )
1789 })
1790 .collect::<Vec<_>>();
1791 if result.invariant_predicate_results.is_empty() {
1792 let reason = result.reason.clone().or_else(|| {
1793 let reasons = result
1794 .invariant_failures
1795 .iter()
1796 .filter(|failure| failure.predicate_name().is_some())
1797 .map(|failure| failure_reason(failure.reason(), failure.counterexample()))
1798 .chain(handler_failures.iter().cloned())
1799 .collect::<Vec<_>>();
1800 (!reasons.is_empty()).then(|| reasons.join("; "))
1801 });
1802 let reason = match (reason, result.counterexample.as_ref()) {
1803 (Some(reason), counterexample) => Some(failure_reason(&reason, counterexample)),
1804 (None, Some(counterexample)) => Some(failure_reason("", Some(counterexample))),
1805 (None, None) => None,
1806 };
1807 let status =
1808 if handler_failures.is_empty() { result.status } else { TestStatus::Failure };
1809 results.push((key(&test), status, reason));
1810 continue;
1811 }
1812 for predicate in &result.invariant_predicate_results {
1813 let failure = result
1814 .invariant_failures
1815 .iter()
1816 .find(|failure| failure.predicate_name() == Some(predicate.name.as_str()));
1817 let reason = predicate.reason.as_deref().or(failure.map(|failure| failure.reason()));
1818 let reason = reason.map(|reason| {
1819 failure_reason(reason, failure.and_then(|failure| failure.counterexample()))
1820 });
1821 results.push((key(&predicate.name), predicate.status, reason));
1822 }
1823 if !handler_failures.is_empty() {
1825 let reason = Some(handler_failures.join("; "));
1826 results.push((key(&test), TestStatus::Failure, reason));
1827 }
1828 }
1829 Ok(results)
1830}
1831
1832pub(crate) fn suite_results(output: &Output) -> Result<Vec<(String, String, TestResult)>> {
1834 let suites = serde_json::from_slice::<serde_json::Value>(&output.stdout)
1835 .wrap_err_with(|| format!("invalid Forge JSON: {}", stderr(output)))?;
1836 let suites = suites.as_object().ok_or_else(|| eyre!("Forge JSON is not an object"))?;
1837 let mut results = Vec::new();
1838 for (contract, suite) in suites {
1839 let Some(tests) = suite.get("test_results").and_then(serde_json::Value::as_object) else {
1840 continue;
1841 };
1842 for (test, result) in tests {
1843 let result = TestResult::deserialize(result)
1844 .wrap_err_with(|| format!("invalid result for {contract}::{test}"))?;
1845 results.push((contract.clone(), test.clone(), result));
1846 }
1847 }
1848 Ok(results)
1849}
1850
1851fn failure_reason(reason: &str, counterexample: Option<&CounterExample>) -> String {
1853 let Some(counterexample) = counterexample else { return reason.to_string() };
1854 let calls = match counterexample {
1855 CounterExample::Single(call) => vec![call],
1856 CounterExample::Sequence(_, calls) => calls.iter().collect(),
1857 };
1858 let calls = calls.iter().map(|call| call.to_string().trim().to_string()).collect::<Vec<_>>();
1859 let counterexample = format!("counterexample: {}", calls.join(", "));
1860 if reason.is_empty() { counterexample } else { format!("{reason}; {counterexample}") }
1861}
1862
1863fn test_display_name(test: &RerunFailure) -> String {
1865 let contract = test.contract.rsplit_once(':').map_or(test.contract.as_str(), |(_, name)| name);
1866 let name = test.test.split_once('(').map_or(test.test.as_str(), |(name, _)| name);
1867 format!("{contract}::{name}")
1868}
1869
1870fn test_path(test: &RerunFailure) -> PathBuf {
1872 PathBuf::from(test.contract.rsplit_once(':').map_or(test.contract.as_str(), |(path, _)| path))
1873}
1874
1875fn seed_dir(config: &Config, workspace: &Path, seed: &U256) -> PathBuf {
1877 workspace
1878 .join(workspace::relative_to_root(&config.root, &config.cache_path))
1879 .join("properties/seeds")
1880 .join(format!("{seed:#x}"))
1881}
1882
1883pub(crate) fn stderr(output: &Output) -> String {
1887 let stderr = String::from_utf8_lossy(&output.stderr);
1888 let has_error = stderr.lines().any(|line| line.starts_with("Error ("));
1889 let mut in_warning = false;
1890 let stderr = stderr
1891 .lines()
1892 .filter(|line| {
1893 if !has_error {
1894 return true;
1895 }
1896 if line.starts_with("Warning") {
1897 in_warning = true;
1898 } else if line.starts_with("Error") {
1899 in_warning = false;
1900 } else if in_warning && line.trim().is_empty() {
1901 in_warning = false;
1902 return false;
1903 }
1904 !in_warning
1905 })
1906 .collect::<Vec<_>>()
1907 .join("\n");
1908 shorten(&stderr)
1909}
1910
1911pub(crate) fn shorten(text: &str) -> String {
1913 const HALF: usize = 1_000;
1914 let text = text.trim();
1915 let chars = text.chars().count();
1916 if chars <= 2 * HALF {
1917 return text.to_string();
1918 }
1919 let head = text.chars().take(HALF).collect::<String>();
1920 let tail = text.chars().skip(chars - HALF).collect::<String>();
1921 format!("{head}\n[...]\n{tail}")
1922}
1923
1924fn verify_prompt(
1926 group: &BugGroup,
1927 targets: &[PathBuf],
1928 group_path: &Path,
1929 verdict: &Path,
1930) -> String {
1931 let targets = targets.iter().map(|path| format!("- {}\n", path.display())).collect::<String>();
1932 let members = group
1933 .failures
1934 .iter()
1935 .map(|failure| {
1936 let claim = failure.failure.claim.as_deref().unwrap_or("(none)");
1937 let reason = failure.failure.reason.trim().chars().take(600).collect::<String>();
1938 format!(
1939 "- file: {}\n test: {}\n claim: {claim}\n failure: {reason}\n",
1940 failure.failure.path.display(),
1941 failure.failure.test
1942 )
1943 })
1944 .collect::<Vec<_>>()
1945 .join("\n");
1946 VERIFY_PROMPT
1948 .replace("{targets}", &targets)
1949 .replace("{group}", &group_path.display().to_string())
1950 .replace("{verdict}", &verdict.display().to_string())
1951 .replace("{members}", &members)
1952}
1953
1954fn group_files(sessions: &[Session], group: &BugGroup) -> Vec<TestFile> {
1957 let mut files = Vec::new();
1958 for session in sessions.iter().filter(|session| group.agents.contains(&session.id)) {
1959 let failing = group
1960 .failures
1961 .iter()
1962 .filter(|failure| failure.agent == session.id)
1963 .map(|failure| &failure.failure.path)
1964 .collect::<BTreeSet<_>>();
1965 let paths = failing
1966 .iter()
1967 .copied()
1968 .chain(failing.iter().filter_map(|path| session.imports.get(*path)).flatten())
1969 .collect::<BTreeSet<_>>();
1970 files.extend(session.files.iter().filter(|file| paths.contains(&file.path)).cloned());
1971 }
1972 files
1973}
1974
1975fn run_session(
1979 command: &mut Command,
1980 timeout: Option<u64>,
1981 session: &str,
1982) -> Result<Option<ExitStatus>> {
1983 #[cfg(unix)]
1984 command.process_group(0);
1985 #[cfg(unix)]
1987 let mut sessions = SESSIONS.lock().unwrap();
1988 let mut child = command.spawn().wrap_err_with(|| format!("failed to run {session} command"))?;
1989 #[cfg(unix)]
1990 let group = Pid::from_child(&child);
1991 #[cfg(unix)]
1992 {
1993 sessions.push(group);
1994 drop(sessions);
1995 }
1996 let status = match timeout {
1997 Some(timeout) => {
1998 let deadline = Instant::now() + Duration::from_secs(timeout);
1999 loop {
2000 match child.try_wait() {
2001 Ok(None) if Instant::now() < deadline => thread::sleep(SESSION_POLL),
2002 status => break status,
2003 }
2004 }
2005 }
2006 None => child.wait().map(Some),
2007 };
2008 #[cfg(unix)]
2010 {
2011 let _ = kill_process_group(group, Signal::KILL);
2012 SESSIONS.lock().unwrap().retain(|running| *running != group);
2013 }
2014 Ok(match status? {
2015 Some(status) => Some(status),
2016 None => {
2017 #[cfg(not(unix))]
2018 let _ = child.kill();
2019 child.wait()?;
2020 None
2021 }
2022 })
2023}
2024
2025fn read_usage(path: &Path, session: &str) -> Result<Option<Usage>> {
2028 match fs::read(path) {
2029 Ok(usage) => Ok(serde_json::from_slice::<Usage>(&usage)
2030 .inspect_err(|error| {
2031 let _ = sh_warn!("{session} wrote invalid usage JSON: {error}");
2032 })
2033 .ok()),
2034 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2035 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {session} usage")),
2036 }
2037}
2038
2039#[cfg(test)]
2040mod tests {
2041 use super::*;
2042
2043 fn failure(agent: usize, test: &str, claim: &str) -> AgentFailure {
2044 AgentFailure {
2045 agent,
2046 failure: Failure {
2047 test: test.to_string(),
2048 path: PathBuf::from("test/generated/Fee.t.sol"),
2049 reason: "revert".to_string(),
2050 claim: (!claim.is_empty()).then(|| claim.to_string()),
2051 },
2052 file: PathBuf::new(),
2053 }
2054 }
2055
2056 #[test]
2059 fn groups_link_agreeing_failures_by_agent() {
2060 let groups = group_failures(vec![
2061 failure(3, "C::zero", "A zero amount is charged; src/Fee.sol:11 charges()."),
2062 failure(1, "A::cap", "The fee is capped at 100 ether; src/Fee.sol:7 fee()."),
2063 failure(2, "B::cap", "Large amounts pay the maximum; src/Fee.sol:7 fee()."),
2064 failure(2, "B::limit", "Large amounts pay the maximum fee (src/Fee.sol:8)."),
2065 failure(1, "D::none", ""),
2066 ]);
2067 let groups = groups
2068 .iter()
2069 .map(|group| {
2070 let tests = group.failures.iter().map(|failure| failure.failure.test.as_str());
2071 (group.agents.clone(), tests.collect::<Vec<_>>())
2072 })
2073 .collect::<Vec<_>>();
2074 assert_eq!(
2075 groups,
2076 [
2077 (vec![1, 2], vec!["A::cap", "B::cap"]),
2078 (vec![2], vec!["B::limit"]),
2080 (vec![3], vec!["C::zero"]),
2081 (vec![1], vec!["D::none"]),
2082 ]
2083 );
2084 }
2085
2086 #[test]
2090 fn test_claim_reads_the_comment_above_the_test() {
2091 let source = r#"
2092pragma solidity ^0.8.20;
2093contract FeeTest {
2094 // The fee is capped at 100 ether (src/Fee.sol:7).
2095 function testCap() public {}
2096
2097 /// @notice A zero amount is charged,
2098 /// src/Fee.sol:11.
2099 /// forge-config: default.fuzz.runs = 10
2100 function testZero(uint256 amount) public {}
2101
2102 function testBare() public {}
2103
2104 // @bug Fee.fee src/Fee.sol:7 — the cap is 10 ether.
2105 function testTagged() public {}
2106
2107 /// @custom:bug Fee.fee src/Fee.sol:7 — the cap is 10 ether.
2108 function testCustomTag() public {}
2109
2110 /// @notice Large amounts.
2111 /// @custom:bug Fee.fee src/Fee.sol:7 — the cap is 10 ether,
2112 /// not 100 ether.
2113 function testLateTag() public {}
2114
2115 // @bug Fee.pay src/Fee.sol:20 — the contract pays twice.
2116 function testPaysTwice() public {}
2117}
2118contract OtherTest {
2119 /* Rounds down: src/Fee.sol:6 */
2120 function testCap() public {}
2121}
2122"#;
2123 assert_eq!(
2124 test_claim(source, "FeeTest", "testCap").as_deref(),
2125 Some("The fee is capped at 100 ether (src/Fee.sol:7).")
2126 );
2127 assert_eq!(
2128 test_claim(source, "FeeTest", "testZero").as_deref(),
2129 Some("A zero amount is charged, src/Fee.sol:11.")
2130 );
2131 assert_eq!(test_claim(source, "FeeTest", "testBare"), None);
2132 assert_eq!(
2133 test_claim(source, "FeeTest", "testTagged").as_deref(),
2134 Some("@bug Fee.fee src/Fee.sol:7 — the cap is 10 ether.")
2135 );
2136 assert_eq!(
2137 test_claim(source, "FeeTest", "testCustomTag").as_deref(),
2138 Some("@custom:bug Fee.fee src/Fee.sol:7 — the cap is 10 ether.")
2139 );
2140 assert_eq!(
2141 test_claim(source, "FeeTest", "testLateTag").as_deref(),
2142 Some("@custom:bug Fee.fee src/Fee.sol:7 — the cap is 10 ether, not 100 ether.")
2143 );
2144 assert_eq!(
2145 test_claim(source, "FeeTest", "testPaysTwice").as_deref(),
2146 Some("@bug Fee.pay src/Fee.sol:20 — the contract pays twice.")
2147 );
2148 assert_eq!(
2149 test_claim(source, "OtherTest", "testCap").as_deref(),
2150 Some("Rounds down: src/Fee.sol:6")
2151 );
2152 assert_eq!(test_claim(source, "OtherTest", "testZero"), None);
2153 assert_eq!(test_claim(source, "MissingTest", "testCap"), None);
2154 }
2155
2156 #[test]
2160 fn collect_tests_reads_solidity_files_under_the_output_directory() {
2161 let workspace = tempfile::tempdir().unwrap();
2162 let out = Path::new("test/generated/agent-1");
2163 let write = |path: &str, content: &str| {
2164 let path = workspace.path().join(out).join(path);
2165 fs::create_dir_all(path.parent().unwrap()).unwrap();
2166 fs::write(path, content).unwrap();
2167 };
2168 write("Fee.t.sol", "contract FeeTest {}");
2169 write("helpers/Helper.sol", "library Helper {}");
2170 write("notes.md", "notes");
2171 write("A{B,C}.t.sol", "contract GlobTest {}");
2172 write("dir*/Star.t.sol", "contract StarTest {}");
2173 #[cfg(unix)]
2174 std::os::unix::fs::symlink(
2175 workspace.path().join(out).join("Fee.t.sol"),
2176 workspace.path().join(out).join("Link.t.sol"),
2177 )
2178 .unwrap();
2179
2180 let mut rejected = Vec::new();
2181 let files = collect_tests(workspace.path(), out, 1, &mut rejected).unwrap();
2182 assert_eq!(
2183 files
2184 .iter()
2185 .map(|file| (file.path.to_str().unwrap(), file.content.as_str()))
2186 .collect::<Vec<_>>(),
2187 [
2188 ("test/generated/agent-1/Fee.t.sol", "contract FeeTest {}"),
2189 ("test/generated/agent-1/helpers/Helper.sol", "library Helper {}"),
2190 ]
2191 );
2192 rejected.sort_by(|a, b| a.paths.cmp(&b.paths));
2193 assert_eq!(
2194 rejected
2195 .iter()
2196 .map(|rejected| format!("{} {}", rejected.paths[0].display(), rejected.reason))
2197 .collect::<Vec<_>>(),
2198 [
2199 "test/generated/agent-1/A{B,C}.t.sol the path has a glob character",
2200 "test/generated/agent-1/dir*/Star.t.sol the path has a glob character",
2201 ]
2202 );
2203
2204 #[cfg(unix)]
2206 {
2207 let linked = Path::new("test/generated/agent-2");
2208 std::os::unix::fs::symlink(workspace.path().join(out), workspace.path().join(linked))
2209 .unwrap();
2210 let error = collect_tests(workspace.path(), linked, 2, &mut rejected).unwrap_err();
2211 assert!(error.to_string().contains("is not a directory"), "{error}");
2212 }
2213 }
2214}