Skip to main content

forge/cmd/
properties.rs

1//! The `forge properties` command: several agents look for bugs in the targets independently and
2//! in parallel, each in one session in its own copy of the project, and prove each bug with a
3//! failing test. Forge runs the tests of every agent again in a fresh copy on every seed, reports a
4//! bug only when enough agents agree on its root cause, and keeps one copy of each passing test
5//! file. An optional verifier then tries to disprove each reported bug.
6
7use 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
57/// On 12 held-out cases, four small-model agents with a quorum of two found bugs in as many cases
58/// as five (8.6 against 9.0 of 12, a tie in a bootstrap over cases) at four fifths of the cost.
59/// With a quorum of two, they kept no report that reviewers labelled wrong, because wrong claims
60/// of independent agents rarely repeat.
61const DEFAULT_AGENTS: usize = 4;
62const DEFAULT_QUORUM: usize = 2;
63/// How often Forge checks whether an agent session with a timeout has exited. Sessions take
64/// minutes, so this adds no noticeable delay.
65const SESSION_POLL: Duration = Duration::from_millis(100);
66/// How long one Forge build or test run in a workspace can take.
67const RUN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
68/// Agent settings and instruction files that a workspace leaves out at every level. Settings can
69/// hold hooks that run commands on the host. `CLAUDE.md` and `AGENTS.md` are project docs, but
70/// they steer the agents that work in the copy, so a user who wants them passes them with
71/// `--context`. Entries of dependency directories are links, so they keep their own files.
72const 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];
88/// The environment variables that builds and test runs of generated code see, besides `HOME`,
89/// `FOUNDRY_PROFILE`, and the seed settings. Generated tests run outside the agent's sandbox, so
90/// they must not read the secrets of Forge's environment with the env cheatcodes.
91const PASSED_ENV: [&str; 7] = ["LANG", "LC_ALL", "PATH", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR"];
92/// Overrides configured test filters, so they cannot hide generated tests. `$^` matches no
93/// contract or test name, and no project file is under `/forge-properties-none`.
94pub(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
107/// The task of a verifier session. The placeholders are the targets, the group file, the verdict
108/// file, and the tests of the group.
109const VERIFY_PROMPT: &str = include_str!("properties/verify_prompt.md");
110
111/// The errors of what Forge blocks in its workspaces: `vm.ffi` without `--allow-ffi`, network
112/// cheatcodes without `--allow-network`, env cheatcodes, which see only a few variables, and
113/// inline config that `--restrict-inline-config` refuses.
114static 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});
118/// Solidity line and block comments.
119static COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)//[^\n]*|/\*.*?\*/").unwrap());
120/// A contract declaration and its name.
121static CONTRACT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\bcontract\s+(\w+)").unwrap());
122/// The process groups of the running agent sessions, which Forge ends if it is interrupted.
123#[cfg(unix)]
124static SESSIONS: Mutex<Vec<Pid>> = Mutex::new(Vec::new());
125
126/// Run agents that prove bugs with failing tests and report the bugs that they agree on.
127#[derive(Clone, Debug, Parser)]
128pub struct PropertiesArgs {
129    /// Source file to look for bugs in. Can be given more than once.
130    ///
131    /// Defaults to every source file in the project's source directory.
132    #[arg(long, value_name = "PATH")]
133    target: Vec<PathBuf>,
134
135    /// Reference material for the agents, such as docs, a specification, or a threat model.
136    ///
137    /// Forge gives every agent the absolute path of each file. Can be given more than once.
138    #[arg(long, value_name = "PATH")]
139    context: Vec<PathBuf>,
140
141    /// Executable that runs one agent session, such as a wrapper around any coding agent.
142    ///
143    /// Forge runs `PATH [ARG...]` once per agent, in parallel, each in its own copy of the
144    /// project, with these environment variables:
145    ///
146    /// FORGE_PROPERTIES_PROMPT   the task: targets, context files, and output directory
147    /// FORGE_PROPERTIES_TARGETS  target files, relative to the copy, one per line
148    /// FORGE_PROPERTIES_CONTEXT  absolute paths of the --context files, one per line
149    /// FORGE_PROPERTIES_OUT      absolute, empty directory for the agent's tests
150    /// FORGE_PROPERTIES_SEED     the agent's own seed, in hex
151    /// FORGE_PROPERTIES_USAGE    file where the agent can write its model usage as JSON:
152    ///                           {"input_tokens": N, "output_tokens": N, "cost_usd": X}
153    ///
154    /// The agent writes Solidity tests under FORGE_PROPERTIES_OUT and can run `forge test` in the
155    /// copy. When it exits or reaches --timeout, Forge ends its process group and runs every
156    /// `*.sol` file under FORGE_PROPERTIES_OUT in a fresh copy of the project on every seed. A
157    /// test that fails on every seed is a possible bug. Its claim is the comment right above the
158    /// test function, `// @bug <Contract>.<function> <file>:<line> — <claim>`, else the test name.
159    /// Forge keeps the files whose tests all pass. A nonzero exit status is an error, but
160    /// Forge still runs the agent's tests. In Forge's runs, inline config can only lower runs,
161    /// depth, and rejects and change a few fuzzer and invariant settings, and a run that takes
162    /// more than ten minutes ends.
163    #[arg(long, required = true, value_name = "PATH", verbatim_doc_comment)]
164    agent_command: PathBuf,
165
166    /// Argument passed to the agent command. Can be given more than once.
167    #[arg(long, allow_hyphen_values = true, value_name = "ARG")]
168    agent_arg: Vec<OsString>,
169
170    /// Executable that tries to disprove each possible bug that reaches the quorum, such as a
171    /// wrapper around any coding agent. Without it, Forge runs no verifier.
172    ///
173    /// Forge runs `PATH [ARG...]` once per group, at most --agents at a time, each in a fresh copy
174    /// of the project that also holds the tests of the group, with these environment variables:
175    ///
176    /// FORGE_PROPERTIES_VERIFY_PROMPT  the task: try to disprove the claims of the group
177    /// FORGE_PROPERTIES_GROUP          JSON file with the agents, the claims, the tests, and the
178    ///                                 failure output of the group
179    /// FORGE_PROPERTIES_VERDICT        file where the verifier writes its verdict as JSON:
180    ///                                 {"verdict": "confirmed" | "needs_validation" | "rejected",
181    ///                                  "reason": "...",
182    ///                                  "evidence": [{"file": "...", "line": N, "quote": "..."}],
183    ///                                  "unresolved": "..."}
184    /// FORGE_PROPERTIES_USAGE          file where the verifier can write its model usage as JSON,
185    ///                                 as for an agent
186    ///
187    /// When the verifier exits or reaches --timeout, Forge ends its process group and reads the
188    /// verdict. Forge lists the confirmed groups first, then the groups that need validation with
189    /// the open fact, then the rejected groups with the disproof. A missing or invalid verdict,
190    /// and a rejection without evidence, count as needs_validation. Groups below the quorum are
191    /// not verified.
192    #[arg(long, value_name = "PATH", verbatim_doc_comment)]
193    verifier_command: Option<PathBuf>,
194
195    /// Argument passed to the verifier command. Can be given more than once.
196    #[arg(long, allow_hyphen_values = true, requires = "verifier_command", value_name = "ARG")]
197    verifier_arg: Vec<OsString>,
198
199    /// Deterministic seeds used to run the tests of every agent. Supply at least two.
200    #[arg(long, required = true, action = clap::ArgAction::Append, value_name = "SEED")]
201    seed: Vec<U256>,
202
203    /// Number of agents. Each agent runs one session independently and in parallel in its own
204    /// copy of the project, with its own seed.
205    #[arg(long, default_value_t = DEFAULT_AGENTS, value_name = "N")]
206    agents: usize,
207
208    /// Number of agents that must report the same root cause before Forge reports it as a
209    /// possible bug.
210    ///
211    /// Two failing tests have the same root cause when their `@bug` tags name the same function
212    /// in the same Solidity file at near lines. Without both tags, their claims must name the same
213    /// Solidity file and share a function name at near lines or at least 30% of their words. A
214    /// test joins a group only when it has the same root cause as every test in it. Forge lists
215    /// the groups below the quorum after the possible bugs. Defaults to 2, or 1 with a
216    /// single agent.
217    #[arg(long, value_name = "K")]
218    quorum: Option<usize>,
219
220    /// Seconds after which Forge ends an agent session and runs the tests that it wrote, or ends a
221    /// verifier and reads its verdict.
222    #[arg(long, value_name = "SECS")]
223    timeout: Option<u64>,
224
225    /// Let generated tests run host commands with `vm.ffi` if the project enables `ffi`.
226    ///
227    /// Generated tests run under Forge, outside the sandbox of the agent that wrote them, so
228    /// Forge turns `ffi` off in its copies of the project by default.
229    #[arg(long)]
230    allow_ffi: bool,
231
232    /// Let generated tests open network connections, for example with `vm.createFork`.
233    ///
234    /// By default, cheatcodes that open a connection fail in Forge's copies of the project, and
235    /// the copies have no RPC or Etherscan settings.
236    #[arg(long)]
237    allow_network: bool,
238
239    #[command(flatten)]
240    build: BuildOpts,
241}
242
243impl_figment_convert!(PropertiesArgs, build);
244
245/// Model usage that an agent reports.
246#[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/// A Solidity file that an agent wrote, by its path relative to the project root.
269#[derive(Clone, Debug)]
270pub(crate) struct TestFile {
271    pub(crate) path: PathBuf,
272    pub(crate) content: String,
273}
274
275/// A test that fails on every seed.
276#[derive(Clone, Debug, Serialize)]
277struct Failure {
278    /// `Contract::test`.
279    test: String,
280    path: PathBuf,
281    reason: String,
282    /// The comment above the test.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    claim: Option<String>,
285}
286
287/// A test of one agent that fails on every seed.
288#[derive(Debug, Serialize)]
289struct AgentFailure {
290    agent: usize,
291    #[serde(flatten)]
292    failure: Failure,
293    /// The copy of the test file for review, relative to the project root.
294    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/// Files of an agent that Forge did not run.
309#[derive(Debug, Serialize)]
310struct Rejected {
311    agent: usize,
312    paths: Vec<PathBuf>,
313    reason: String,
314}
315
316/// The failing tests of all agents that have one root cause.
317#[derive(Debug, Serialize)]
318struct BugGroup {
319    /// The agents that report the root cause.
320    agents: Vec<usize>,
321    /// The failing tests, by agent. The first test has a claim, if any test has.
322    failures: Vec<AgentFailure>,
323    /// What the verifier concluded, if there is one.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    verification: Option<Verification>,
326}
327
328/// The verdict of a verifier.
329///
330/// The order is the order of the report.
331#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
332#[serde(rename_all = "snake_case")]
333enum Verdict {
334    /// Every attempt to disprove the claim failed.
335    Confirmed,
336    /// One open fact decides the claim.
337    #[default]
338    NeedsValidation,
339    /// A disproof holds for every claim of the group.
340    Rejected,
341}
342
343/// A quote from a file that supports a verdict.
344#[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/// The verdict file of a verifier.
355#[derive(Debug, Default, Deserialize, Serialize)]
356struct Judgment {
357    verdict: Verdict,
358    #[serde(default)]
359    reason: String,
360    #[serde(default)]
361    evidence: Vec<Evidence>,
362    /// The open fact of a verdict that needs validation.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    unresolved: Option<String>,
365}
366
367/// What one verifier session left for a bug group.
368#[derive(Debug, Serialize)]
369struct Verification {
370    #[serde(flatten)]
371    judgment: Judgment,
372    /// What went wrong with the verifier, such as a missing verdict. A group whose verifier left
373    /// no valid verdict needs validation.
374    #[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    /// Returns the verification of a group whose verifier left no valid verdict.
382    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/// The tests of some files, run on every seed.
409#[derive(Default)]
410struct TestRun {
411    /// Tests that fail on every seed.
412    possible_bugs: Vec<Failure>,
413    /// Tests that pass on every seed.
414    passing: Vec<RerunFailure>,
415    /// Files with a test that does not pass on every seed.
416    failing_files: HashSet<PathBuf>,
417    /// Seeds on which Forge could not run the tests.
418    errors: Vec<String>,
419}
420
421/// What one agent session left.
422struct Session {
423    id: usize,
424    /// Why the agent command failed, if it did.
425    error: Option<String>,
426    timed_out: bool,
427    usage: Option<Usage>,
428    possible_bugs: Vec<AgentFailure>,
429    rejected: Vec<Rejected>,
430    /// The files whose tests pass on every seed, with the files that they import.
431    kept: Vec<TestFile>,
432    /// Every file that the agent wrote and Forge ran.
433    files: Vec<TestFile>,
434    /// The files of `files` that each of them imports, directly or not, as the compiler resolves
435    /// the imports.
436    imports: BTreeMap<PathBuf, BTreeSet<PathBuf>>,
437}
438
439/// The output directories that a run reserved. The ones it left empty are removed on drop.
440struct 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        // Workspaces map project paths by the resolved root, so the root must have no links.
465        // `--config-path` resolves already.
466        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        // Each run writes to directories of its own, so a run never meets the files of another.
477        // Creating them reserves them against concurrent runs.
478        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        // Workspaces leave out the project's generated tests, so every agent starts without
508        // earlier generated tests.
509        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
528/// The size and modification time of each project file, by path relative to the root.
529type Fingerprint = BTreeMap<PathBuf, (u64, Option<SystemTime>)>;
530
531/// The settings of one `forge properties` run.
532struct Campaign<'a> {
533    args: &'a PropertiesArgs,
534    config: &'a Config,
535    sandbox: Sandbox<'a>,
536    /// The verifier command, if the run has a verifier.
537    verifier_command: Option<PathBuf>,
538    /// The directory of generated tests, relative to the project root.
539    generated_tests: PathBuf,
540    /// The number of this run. Agent `N` writes to `run-<run>-agent-<N>` in `generated_tests`.
541    run: usize,
542    cache_root: PathBuf,
543    quorum: usize,
544}
545
546/// Turns off `ffi` unless `allow_ffi`, and removes the RPC and Etherscan settings unless
547/// `allow_network`. Every workspace of a [`Sandbox`] writes this config, so no generated test can
548/// call `vm.ffi` or reach an endpoint that the config names. Config ignores `FFI` environment
549/// variables.
550pub(crate) fn restrict_config(config: &mut Config, allow_ffi: bool, allow_network: bool) {
551    config.ffi &= allow_ffi;
552    // Generated tests could send data to any host, and the endpoints can hold API keys.
553    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
563/// Builds and runs generated code in temporary copies of the project. Generated code runs outside
564/// the sandbox of whatever wrote it, so a copy leaves out hidden root entries such as `.env` and
565/// agent settings, and Forge runs in it with only a few environment variables.
566pub(crate) struct Sandbox<'a> {
567    config: &'a Config,
568    forge: PathBuf,
569    /// Project paths that workspaces leave out, besides the build output and the cache.
570    skipped: Vec<PathBuf>,
571    /// The workspace whose build output every new workspace copies.
572    build_cache: Option<TempDir>,
573    /// The home directory of builds and test runs, whose `.svm` links to the installed compilers.
574    home: PathBuf,
575    /// The temporary directory that holds the home directory and every workspace, so one removal
576    /// cleans up after an interrupt. It drops last.
577    root: TempDir,
578}
579
580/// The inputs that every agent shares.
581struct AgentInputs {
582    agent_command: PathBuf,
583    /// The target files, relative to the project root.
584    targets: Vec<PathBuf>,
585    /// The absolute paths of the context files.
586    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        // The prompts, logs, usage, verdicts, and test copies of an earlier run go.
611        if self.cache_root.exists() {
612            fs::remove_dir_all(&self.cache_root)?;
613        }
614        // Agents work in copies of the project, but the copies link the entries of dependency
615        // directories. A shared file that changes while they run taints the run.
616        let fingerprint = project_fingerprint(self.config, &self.sandbox.dependencies());
617        // An agent error ends only that agent, and the run reports it after the other agents, so
618        // their results are not lost.
619        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    /// Prints the possible bugs that reach the quorum and writes one copy of each kept file to the
647    /// project. A verifier runs first, and the project files must match `fingerprint` after it.
648    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        // A test that fails only because Forge blocked a cheatcode or its inline config is not
664        // evidence of a bug.
665        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        // The groups below the quorum hold most of the bugs that agents find, so they follow the
706        // possible bugs as a separate list.
707        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        // An agent's kept files go to the project together or not at all: a path that leads
719        // outside the project, or that a concurrent run took, holds back the agent's files. A
720        // file that repeats a generated test of the project or of another agent is not written,
721        // unless a written file of its agent imports it or it imports one, so every written test
722        // compiles against the files that it ran with. Nothing is kept when files that the
723        // workspaces share with the project changed while the agents ran.
724        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                // A linked directory on the path must not lead outside the project.
738                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        // Agents can reach the project's dependencies through their copies, so a change can come
845        // from an agent, and no result of the run can be trusted.
846        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    /// Runs one agent session in its own copy of the project, then runs the tests that it wrote.
880    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        // Agents differ by seed.
895        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        // Forge writes only passing files to the project, so the copies keep the failing tests.
935        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            // A file that does not compile, such as one that the agent was writing at the
950            // timeout, must not hide the other tests. Each file that no other file imports then
951            // runs on its own, with the files that it imports. Files that import each other in a
952            // cycle have no such file, so the first of them that no group holds yet starts one.
953            // A group that does not compile rejects and holds only its first file, so the files
954            // that it imports still run without it.
955            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                // A file that several groups import runs in each of them, but its results count
962                // once.
963                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    /// Runs every test in the files on every seed.
1021    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        // The passing seeds and the failures of each test.
1026        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        // Possible bugs with a claim first.
1084        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    /// Returns a Forge command for one seed. Each seed persists fuzz and invariant failures in
1090    /// its own directory, so a counterexample found with one seed is not replayed with another.
1091    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            // Internal runs must not replace the project's own `--rerun` state.
1099            .env("FOUNDRY_TEST_FAILURES_FILE", seed_dir.join("test-failures"));
1100        command
1101    }
1102
1103    /// Runs one verifier per group, at most `--agents` at a time, and orders the groups by
1104    /// verdict. A verifier error leaves the group in need of validation.
1105    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        // The sort is stable, so groups with one verdict keep their order.
1144        groups.sort_by_key(|group| group.verification.as_ref().map(|v| v.judgment.verdict));
1145    }
1146
1147    /// Runs the verifier on one group in a fresh workspace that holds the tests of the group, then
1148    /// reads its verdict.
1149    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                // A disproof needs a quote that the group can check.
1208                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    /// Returns a sandbox for the project of `config`, whose workspaces leave out the `skipped`
1235    /// project paths.
1236    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    /// Makes an interrupt end every session and test run and remove the workspaces before Forge
1248    /// exits. They run in their own process groups, so a terminal interrupt does not reach them.
1249    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            // The lock stays held until Forge exits, so no thread starts another process.
1253            #[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            // Exiting skips the drops, so the reserved directories that stayed empty go here.
1261            for dir in &reserved {
1262                let _ = fs::remove_dir(dir);
1263            }
1264            std::process::exit(130);
1265        })?;
1266        Ok(())
1267    }
1268
1269    /// Builds the project once in a workspace whose build output later workspaces copy, so a
1270    /// test run compiles only the files that it adds. Returns the compiler errors of a project
1271    /// that does not build; it gets no shared output, and each test run builds it in full.
1272    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    /// Builds a workspace and returns the compiler errors, if any. If the project denies
1282    /// warnings, the warnings are errors. A workspace starts from a build without such warnings,
1283    /// so only the files that this build compiles can warn.
1284    pub(crate) fn build_errors(&self, workspace: &Path) -> Result<Option<String>> {
1285        // The lint step can fail where the compiler succeeds, and it does not change the tests.
1286        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    /// Copies the project and the shared build output into a temporary workspace and adds the
1300    /// files.
1301    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        // Internal runs see the project's resolved config, including inherited `extends` values,
1312        // absolute paths, and environment overrides, without reading files outside the workspace.
1313        let mut rebased = workspace::rebase_config_paths(config, workspace.path());
1314        rebased.extends = None;
1315        // Foundry turns the compiler cache off when it denies warnings, so a workspace builds with
1316        // `deny = "never"`, and `Sandbox::build_errors` checks the warnings instead.
1317        rebased.deny = DenyLevel::Never;
1318        // With dynamic test linking, every new source file makes the compiler rebuild all files, so
1319        // a workspace compiles without it. It changes how tests deploy contracts, not what they
1320        // assert.
1321        rebased.dynamic_test_linking = false;
1322        // Dependency entries link to the project's entries, and the compiler resolves links before
1323        // it checks the allowed paths. A source that the compiler reads itself, such as an import
1324        // that climbs above the root into a nested library, must stay readable.
1325        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        // Inline config may name the project's other profiles, so they must still exist. The TOML
1333        // writer quotes names such as `""` or `a.b` that are not bare keys.
1334        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        // Remappings are already resolved and rebased in the workspace config. The Soldeer lock
1342        // goes too: `forge build` then hashes each Soldeer dependency to check it, and the hash
1343        // panics when the dependency is a link, as in a workspace.
1344        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    /// Returns the dependency directories of the project, whose entries workspaces link instead of
1363    /// copying them.
1364    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    /// Returns a Forge command in a workspace. A workspace has its own resolved `foundry.toml`, so
1376    /// the command needs only the selected profile from the environment. Generated tests cannot
1377    /// read other variables.
1378    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    /// Runs a command of [`Sandbox::command`] and returns its output, or `None` if it reached
1390    /// [`RUN_TIMEOUT`]. Generated tests can loop for as long as their gas lasts, so Forge ends the
1391    /// command and every process that it started at the timeout.
1392    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
1406/// Groups the failing tests of all agents by root cause with the root-cause matcher. Groups with a
1407/// claim come first, then groups with more agents.
1408fn 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            // A failure with a claim leads, then the failures by agent.
1420            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
1440/// Returns the task of an agent session. On 26 held-out cases, plain independent small-model
1441/// audits with a task of this kind found 0.05 to 0.10 more planted bugs at every spend level than
1442/// agents that returned candidate JSON in short rounds.
1443fn 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
1472/// Returns the Solidity files under the output directory `out` of a workspace, by path relative
1473/// to the workspace, in path order. Links are not followed, and `out` itself must not be one. A
1474/// file whose path has a glob character is rejected: Forge selects the files to run by a glob.
1475fn 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        // The agent removed it.
1490        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
1528/// Returns the comment right above a test function, without comment markers, as one line. The
1529/// function is the first one with this name after the contract declaration and before the next
1530/// one. Declarations are searched outside comments, so a claim such as `the contract pays twice`
1531/// is not a declaration.
1532fn test_claim(source: &str, contract: &str, function: &str) -> Option<String> {
1533    // Blanking each comment byte keeps the offsets of the code.
1534    let code = COMMENT.replace_all(source, |comment: &regex::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                // The `@bug` tag names the target, so it stays. A NatSpec tag such as `@notice` is
1556                // not part of the claim. The compiler rejects `/// @bug`, so
1557                // `@custom:bug` stays too.
1558                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    // The claim starts at the tag, wherever the comment has it.
1567    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
1575/// Returns the collected files that each collected file imports, directly or not.
1576fn 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
1601/// Returns the files whose tests all pass on every seed, with the files that they import. A
1602/// passing file that imports a file with a failing test is not kept.
1603fn 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
1635/// Fails if the nearest existing ancestor of `path` resolves outside `root`, for example through a
1636/// linked directory.
1637fn 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
1644/// Returns the size and modification time of each file that workspaces share with the project, by
1645/// path relative to the root: the files in the `dependencies` directories, whose entries workspaces
1646/// link, and behind links that lead outside the project. Workspaces copy every other file, so a
1647/// change to one cannot come from an agent. Hidden entries and the build and cache directories are
1648/// skipped. Each directory is read once.
1649fn project_fingerprint(config: &Config, dependencies: &[PathBuf]) -> Fingerprint {
1650    let mut files = BTreeMap::new();
1651    let mut read = HashSet::new();
1652    // Each directory, and whether workspaces share its files.
1653    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                // The path through a link, not the link's target.
1679                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
1687/// Copies a project directory into a workspace, without Git data, the `skipped` paths, agent
1688/// settings and instructions (`AGENT_CONFIG`), and, at the project root, hidden entries such as
1689/// `.env`.
1690///
1691/// A dependency directory becomes a real directory whose entries link to the project's entries.
1692/// A linked dependency directory would resolve outside the workspace, and the compiler then
1693/// cannot resolve a dependency's own absolute imports, such as `src/lib/X.sol` in a pnpm package.
1694/// A link to a path in the project leads to that path in the workspace, so it stays inside it.
1695fn 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                // A dangling link stays as it is.
1731                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
1743/// Copies the build output and the compiler cache of a built workspace into another workspace.
1744/// The copy clones files where the file system can (APFS, Btrfs, XFS).
1745///
1746/// The compiler cache keys sources by their path relative to the root, but it stores remappings
1747/// with the absolute root, and other remappings invalidate it. Both workspaces have the same files
1748/// at the same relative paths, so moving the remappings to the new root keeps the cache valid, and
1749/// the compiler rebuilds only sources whose content changed.
1750fn 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    // Shared output outside the project would mix the builds of all workspaces.
1755    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    // The compiler can see either spelling of a temporary path, such as `/private/var` on macOS.
1761    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
1772/// Returns every test result in Forge's JSON output, with one result per invariant predicate of a
1773/// merged invariant campaign.
1774fn 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        // Handler assertion failures belong to the whole campaign, not to one predicate.
1779        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        // One campaign result per seed, so the count of failing seeds stays correct.
1824        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
1832/// Returns every test result in Forge's JSON output with its contract and test name.
1833pub(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
1851/// Appends the counterexample calls to a failure reason.
1852fn 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
1863/// Returns `Contract::test` for a test identity.
1864fn 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
1870/// Returns the file of a test identity.
1871fn test_path(test: &RerunFailure) -> PathBuf {
1872    PathBuf::from(test.contract.rsplit_once(':').map_or(test.contract.as_str(), |(path, _)| path))
1873}
1874
1875/// Returns the directory for one seed's builds and persisted state in a workspace.
1876fn 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
1883/// Returns stderr without compiler warning blocks and keeps both ends when it is still long.
1884/// Warnings can fill the limit before the decisive error, and many tools print the error last.
1885/// Without a compiler error, as with `deny = "warnings"`, the warnings are the errors and stay.
1886pub(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
1911/// Keeps both ends of a long tool output. Many tools print the decisive error last.
1912pub(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
1924/// Returns the task of a verifier session.
1925fn 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    // The tests come last, so a placeholder in a failure reason stays as it is.
1947    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
1954/// Returns the files of a group: the files of its failing tests, with the files of the same agent
1955/// that they import, directly or not.
1956fn 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
1975/// Runs a session command in its own process group until it exits or reaches the timeout in
1976/// seconds, then ends the process group, so no process that the session started keeps running.
1977/// Returns `None` if the session reached the timeout.
1978fn 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    // The lock spans the spawn, so an interrupt cannot miss a session that just started.
1986    #[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    // The session ends with the command: no process that it started keeps running.
2009    #[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
2025/// Reads the usage file of an agent or verifier. A missing file is no usage, and an invalid one
2026/// is a warning.
2027fn 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    // Agreeing failures form one group with the set of their agents. Groups with a claim and more
2057    // agents come first; a failure without a claim agrees with none.
2058    #[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                // It agrees with `B::cap` but not with `A::cap`, so it does not join their group.
2079                (vec![2], vec!["B::limit"]),
2080                (vec![3], vec!["C::zero"]),
2081                (vec![1], vec!["D::none"]),
2082            ]
2083        );
2084    }
2085
2086    // The claim is the comment right above the test function of the named contract, without
2087    // comment markers, NatSpec tags other than the bug tag, or inline config. A test without such a
2088    // comment has none, and the word `contract` in a comment declares no contract.
2089    #[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    // Forge collects every Solidity file under the output directory, also in subdirectories, in
2157    // path order. It skips other files and links, and rejects files that set a seed, `ffi`, or
2158    // `fs_permissions` in inline config.
2159    #[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        // The output directory itself must not be a link.
2205        #[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}