Skip to main content

boundgraph_check/
compile.rs

1//! Does it build?
2//!
3//! The check that makes coding agents usable. A model that has been told the
4//! code compiles will say the code compiles; a compiler that has been run
5//! either exits zero or does not. Everything else in this crate is machinery
6//! around that difference.
7//!
8//! # Which compiler, and why the checker refuses to exist without knowing
9//!
10//! "It type-checks" is not a claim about a patch, it is a claim about a patch
11//! *and a compiler*. The same file passes `tsc` 5.4 and fails 5.7; `mypy`
12//! changes what it rejects with every minor release. Evidence that did not say
13//! which tool produced it would be unfalsifiable six months later, which is the
14//! opposite of what evidence is for.
15//!
16//! So [`CompileChecker::probe`] runs the tool's version command *first* and
17//! records the answer in the checker's own [`CheckerId`], which the trait then
18//! writes into every piece of evidence. A tool that cannot be identified yields
19//! no checker, and therefore no verdicts — which is the right failure, because
20//! a verdict from an unknown compiler would have been worthless anyway.
21//!
22//! # The verdict mapping
23//!
24//! | What happened | Verdict |
25//! |---|---|
26//! | exit 0 | `Pass` |
27//! | non-zero exit | `Fail`, with the diagnostics |
28//! | killed at the timeout | `Inconclusive` |
29//! | the sandbox refused, or the tool would not start | `Inconclusive` |
30//!
31//! The last two rows are the ones worth stating. A build that timed out has not
32//! shown the code to be broken, so calling it `Fail` would send a repair loop
33//! chasing a bug that is not there — and calling it `Pass` would commit code
34//! nobody compiled. `Inconclusive` is neither, and it does not commit.
35//!
36//! # Diagnostics are cut from the front
37//!
38//! A failing compiler's first error is usually the cause and the rest are
39//! consequences of it, so what a repair loop needs is the head of the output.
40//! That is the opposite of a test runner, which puts its summary at the end,
41//! and the two checkers differ accordingly.
42
43use std::path::{Path, PathBuf};
44use std::time::Duration;
45
46use async_trait::async_trait;
47use boundgraph_core::{CheckerId, Condensate, Digest, GrantSet, WorkOrder};
48
49use crate::checker::{Checker, Judgement};
50use crate::sandbox::{Command, DEFAULT_TIMEOUT, Sandbox, SandboxError, workspace_grant};
51
52/// How much of a compiler's output reaches the failure reason.
53///
54/// The reason is read by a model on the next repair attempt, so it competes for
55/// the same context window everything else does. Enough for the first several
56/// errors, and no more.
57pub const MAX_REASON: usize = 4_000;
58
59/// A tool to compile or type-check with.
60///
61/// The three constructors cover what US-0804 names. Anything else is
62/// [`CompileConfig::new`].
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CompileConfig {
65    /// The program to run.
66    pub program: String,
67    /// The arguments that perform the check.
68    pub args: Vec<String>,
69    /// The arguments that make it print its version, e.g. `--version`.
70    pub version_args: Vec<String>,
71    /// The directory to run in, and the only one the checker may read.
72    pub workspace: PathBuf,
73    /// How long the check may run before it is killed.
74    pub timeout: Duration,
75}
76
77impl CompileConfig {
78    /// A tool invocation, with the default timeout and `--version` as the
79    /// version command.
80    pub fn new(
81        program: impl Into<String>,
82        args: impl IntoIterator<Item = impl Into<String>>,
83        workspace: impl Into<PathBuf>,
84    ) -> Self {
85        Self {
86            program: program.into(),
87            args: args.into_iter().map(Into::into).collect(),
88            version_args: vec!["--version".to_string()],
89            workspace: workspace.into(),
90            timeout: DEFAULT_TIMEOUT,
91        }
92    }
93
94    /// `cargo check --all-targets`.
95    ///
96    /// `check` rather than `build`: it type-checks without linking, which is
97    /// several times faster and is the whole of what this checker claims.
98    pub fn cargo(workspace: impl Into<PathBuf>) -> Self {
99        Self::new("cargo", ["check", "--all-targets", "--quiet"], workspace)
100    }
101
102    /// `tsc --noEmit`.
103    ///
104    /// `--noEmit` because a checker may not write to the workspace, and `tsc`
105    /// writes JavaScript unless told not to.
106    pub fn tsc(workspace: impl Into<PathBuf>) -> Self {
107        Self::new("tsc", ["--noEmit"], workspace)
108    }
109
110    /// `mypy .`.
111    pub fn mypy(workspace: impl Into<PathBuf>) -> Self {
112        Self::new("mypy", ["."], workspace)
113    }
114
115    /// Ask for the version some other way than `--version`.
116    #[must_use]
117    pub fn versioned_by(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
118        self.version_args = args.into_iter().map(Into::into).collect();
119        self
120    }
121
122    /// Kill the check after `timeout`.
123    #[must_use]
124    pub fn within(mut self, timeout: Duration) -> Self {
125        self.timeout = timeout;
126        self
127    }
128
129    /// The grant this configuration needs: read the workspace, execute the
130    /// tool, nothing else.
131    pub fn grant(&self) -> GrantSet {
132        workspace_grant(&self.workspace, &self.program)
133    }
134
135    fn check_command(&self) -> Command {
136        Command::new(&self.program, self.args.clone(), self.workspace.clone()).within(self.timeout)
137    }
138
139    fn version_command(&self) -> Command {
140        Command::new(
141            &self.program,
142            self.version_args.clone(),
143            self.workspace.clone(),
144        )
145        // A tool that cannot say its own version in half a minute is broken.
146        .within(Duration::from_secs(30))
147    }
148}
149
150/// The tool could not be identified, so no checker was built.
151#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
152#[non_exhaustive]
153pub enum ProbeError {
154    /// The version command could not be run.
155    #[error("could not run `{command}` to identify the tool: {source}")]
156    Unrunnable {
157        /// What was attempted.
158        command: String,
159        /// Why it failed.
160        #[source]
161        source: SandboxError,
162    },
163
164    /// The version command ran and failed, or printed nothing.
165    #[error("`{command}` did not report a version: {detail}")]
166    NoVersion {
167        /// What was attempted.
168        command: String,
169        /// What happened instead.
170        detail: String,
171    },
172}
173
174/// Compiles or type-checks a workspace, and reports whether it built.
175///
176/// Built by [`probe`](Self::probe), which identifies the tool first. See the
177/// module documentation for why that is not optional.
178#[derive(Debug, Clone)]
179pub struct CompileChecker {
180    config: CompileConfig,
181    tool: String,
182    id: CheckerId,
183}
184
185impl CompileChecker {
186    /// Identify the tool, then build a checker bound to that version.
187    ///
188    /// # Errors
189    ///
190    /// [`ProbeError`] if the tool could not be run or would not say what it is.
191    /// There is deliberately no fallback to an unidentified checker.
192    pub async fn probe(sandbox: &dyn Sandbox, config: CompileConfig) -> Result<Self, ProbeError> {
193        let command = config.version_command();
194        let outcome = sandbox
195            .run(&command, &config.grant())
196            .await
197            .map_err(|source| ProbeError::Unrunnable {
198                command: command.rendered(),
199                source,
200            })?;
201
202        if !outcome.succeeded() {
203            return Err(ProbeError::NoVersion {
204                command: command.rendered(),
205                detail: if outcome.timed_out {
206                    "it did not finish".to_string()
207                } else {
208                    format!("exit {:?}: {}", outcome.exit, outcome.diagnostics())
209                },
210            });
211        }
212
213        // First line only. `cargo --version` is one line; `mypy --version` is
214        // one line; `tsc --version` is one line. A tool that prints a banner
215        // first would give a useless identity, and a wrong identity is worse
216        // than a long one, so this is the line that names it.
217        let tool = outcome
218            .stdout
219            .lines()
220            .map(str::trim)
221            .find(|line| !line.is_empty())
222            .ok_or_else(|| ProbeError::NoVersion {
223                command: command.rendered(),
224                detail: "it printed nothing".to_string(),
225            })?
226            .to_string();
227
228        Ok(Self::declaring(config, tool))
229    }
230
231    /// Build a checker for a tool whose version is already known.
232    ///
233    /// For a replayed run (US-1006) and for a test, where the tool identity
234    /// comes from the recording rather than from running anything. Using it
235    /// with a version the tool does not actually have produces evidence that
236    /// says something false, so it is for reconstruction, not convenience.
237    pub fn declaring(config: CompileConfig, tool: impl Into<String>) -> Self {
238        let tool = tool.into();
239        Self {
240            // The tool identity travels in the checker's name because that is
241            // the field a verifier reads. `Evidence` has one place to put it.
242            id: CheckerId::new(format!("compile[{tool}]"), 1),
243            tool,
244            config,
245        }
246    }
247
248    /// The tool this checker is bound to, as the tool reported itself.
249    pub fn tool(&self) -> &str {
250        &self.tool
251    }
252
253    /// The workspace being checked.
254    pub fn workspace(&self) -> &Path {
255        &self.config.workspace
256    }
257}
258
259/// Keep the head of `text`, which is where a compiler's real error is.
260///
261/// Counting characters rather than bytes, so a multi-byte character is never
262/// cut in half and the result is always printable.
263pub fn head(text: &str, limit: usize) -> String {
264    if text.chars().count() <= limit {
265        return text.to_string();
266    }
267    let kept: String = text.chars().take(limit).collect();
268    format!("{kept}\n… (output truncated)")
269}
270
271/// A checker built over a sandbox it does not own.
272///
273/// The sandbox is held rather than passed to `judge`, because [`Checker::judge`]
274/// takes only the work order and the condensate — the checker's environment is
275/// part of the checker.
276#[derive(Debug)]
277pub struct SandboxedCompile<S: Sandbox> {
278    checker: CompileChecker,
279    sandbox: S,
280}
281
282impl<S: Sandbox> SandboxedCompile<S> {
283    /// Identify the tool through `sandbox`, then bind the checker to it.
284    ///
285    /// # Errors
286    ///
287    /// [`ProbeError`] if the tool could not be identified.
288    pub async fn probe(sandbox: S, config: CompileConfig) -> Result<Self, ProbeError> {
289        let checker = CompileChecker::probe(&sandbox, config).await?;
290        Ok(Self { checker, sandbox })
291    }
292
293    /// Bind to a tool whose version is already known.
294    pub fn declaring(sandbox: S, config: CompileConfig, tool: impl Into<String>) -> Self {
295        Self {
296            checker: CompileChecker::declaring(config, tool),
297            sandbox,
298        }
299    }
300
301    /// The tool this checker is bound to.
302    pub fn tool(&self) -> &str {
303        self.checker.tool()
304    }
305}
306
307#[async_trait]
308impl<S: Sandbox> Checker for SandboxedCompile<S> {
309    fn id(&self) -> CheckerId {
310        self.checker.id.clone()
311    }
312
313    fn grants(&self) -> GrantSet {
314        self.checker.config.grant()
315    }
316
317    async fn judge(&self, _order: &WorkOrder, _condensate: &Condensate) -> Judgement {
318        let config = &self.checker.config;
319        let command = config.check_command();
320
321        // The tool identity is bound into the evidence as well as named in the
322        // checker id, so a verdict cannot be re-labelled with a different
323        // compiler without breaking the signature.
324        let tool = Digest::of(self.checker.tool.as_bytes());
325
326        let outcome = match self.sandbox.run(&command, &config.grant()).await {
327            Ok(outcome) => outcome,
328            // The check did not happen. That is not the code's fault, so it is
329            // not a failure of the code.
330            Err(e) => {
331                return Judgement::inconclusive(format!("the check could not be run: {e}"))
332                    .covering(tool);
333            }
334        };
335
336        if outcome.timed_out {
337            return Judgement::inconclusive(format!(
338                "`{}` did not finish within {:?}",
339                command.rendered(),
340                config.timeout
341            ))
342            .covering(tool);
343        }
344
345        let judgement = match outcome.exit {
346            Some(0) => Judgement::pass(),
347            Some(code) => {
348                let diagnostics = outcome.diagnostics();
349                let detail = if diagnostics.is_empty() {
350                    "no diagnostics were produced".to_string()
351                } else {
352                    head(&diagnostics, MAX_REASON)
353                };
354                Judgement::fail(format!(
355                    "`{}` exited {code} under {}:\n{detail}",
356                    command.rendered(),
357                    self.checker.tool
358                ))
359            }
360            // Killed by a signal, or otherwise gone without a status. Nothing
361            // was shown either way.
362            None => Judgement::inconclusive(format!(
363                "`{}` ended without an exit status",
364                command.rendered()
365            )),
366        };
367
368        judgement.covering(tool)
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::sandbox::Outcome;
376    use crate::sign::LocalKey;
377    use boundgraph_core::{CondensatePayload, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
378    use std::sync::Mutex;
379
380    /// A sandbox that answers from a script rather than running anything.
381    #[derive(Debug)]
382    struct Scripted {
383        version: Result<Outcome, SandboxError>,
384        check: Result<Outcome, SandboxError>,
385        seen: Mutex<Vec<String>>,
386    }
387
388    impl Scripted {
389        fn new(version: Outcome, check: Result<Outcome, SandboxError>) -> Self {
390            Self {
391                version: Ok(version),
392                check,
393                seen: Mutex::new(Vec::new()),
394            }
395        }
396
397        fn ran(&self) -> Vec<String> {
398            self.seen.lock().unwrap().clone()
399        }
400    }
401
402    #[async_trait]
403    impl Sandbox for Scripted {
404        async fn run(&self, command: &Command, _grant: &GrantSet) -> Result<Outcome, SandboxError> {
405            self.seen.lock().unwrap().push(command.rendered());
406            if command.args.iter().any(|a| a == "--version") {
407                self.version.clone()
408            } else {
409                self.check.clone()
410            }
411        }
412    }
413
414    fn outcome(exit: Option<i32>, stdout: &str, stderr: &str) -> Outcome {
415        Outcome {
416            exit,
417            stdout: stdout.to_string(),
418            stderr: stderr.to_string(),
419            timed_out: false,
420            truncated: false,
421            duration: Duration::from_millis(10),
422        }
423    }
424
425    fn timed_out() -> Outcome {
426        Outcome {
427            exit: None,
428            stdout: String::new(),
429            stderr: String::new(),
430            timed_out: true,
431            truncated: false,
432            duration: Duration::from_secs(300),
433        }
434    }
435
436    fn version() -> Outcome {
437        outcome(Some(0), "cargo 1.85.0 (1d8b05cdd 2026-01-20)\n", "")
438    }
439
440    fn config() -> CompileConfig {
441        CompileConfig::cargo("/work/repo")
442    }
443
444    fn order() -> WorkOrder {
445        WorkOrder::new(
446            Intent::new("patch.apply", &"fix the borrow error").unwrap(),
447            vec![],
448            GrantSet::EMPTY,
449            ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
450        )
451    }
452
453    fn condensate() -> Condensate {
454        Condensate::new(
455            SchemaId::new("boundgraph.diff.v1", 1),
456            CondensatePayload::inline(&"one file changed").unwrap(),
457            Digest::of(b"the patch"),
458            400,
459            false,
460            SlicerId::new("diff", 1),
461        )
462    }
463
464    fn key() -> LocalKey {
465        LocalKey::from_seed("host-key-1", [7u8; 32])
466    }
467
468    async fn verdict_for(check: Result<Outcome, SandboxError>) -> Verdict {
469        let sandbox = Scripted::new(version(), check);
470        let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
471        checker
472            .check(&order(), &condensate(), &key())
473            .await
474            .unwrap()
475            .verdict
476    }
477
478    // ── the verdict mapping ─────────────────────────────────────────────────
479
480    /// The criterion: exit status maps to the verdict.
481    #[tokio::test]
482    async fn a_clean_build_passes() {
483        assert_eq!(
484            verdict_for(Ok(outcome(Some(0), "", ""))).await,
485            Verdict::Pass
486        );
487    }
488
489    /// The criterion: a non-zero exit is `Fail` with the diagnostics as the
490    /// reason.
491    #[tokio::test]
492    async fn a_failed_build_fails_with_the_diagnostics() {
493        let errors = "error[E0502]: cannot borrow `x` as mutable\n  --> src/main.rs:12:5\n";
494        let verdict = verdict_for(Ok(outcome(Some(101), "", errors))).await;
495
496        let Verdict::Fail(reason) = verdict else {
497            panic!("expected Fail, got {verdict:?}");
498        };
499        assert!(reason.contains("E0502"), "{reason}");
500        assert!(reason.contains("src/main.rs:12"), "{reason}");
501        assert!(reason.contains("exited 101"), "{reason}");
502    }
503
504    /// The criterion: a timeout is `Inconclusive`, never `Pass`.
505    #[tokio::test]
506    async fn a_timeout_is_inconclusive_never_a_pass() {
507        let verdict = verdict_for(Ok(timed_out())).await;
508
509        assert!(matches!(verdict, Verdict::Inconclusive(_)), "{verdict:?}");
510        assert!(!verdict.is_pass());
511    }
512
513    /// A build that timed out has not shown the code to be broken either, so
514    /// sending a repair loop after it would be chasing a bug that is not there.
515    #[tokio::test]
516    async fn a_timeout_is_not_a_failure_either() {
517        assert!(!matches!(
518            verdict_for(Ok(timed_out())).await,
519            Verdict::Fail(_)
520        ));
521    }
522
523    #[tokio::test]
524    async fn a_sandbox_that_refuses_leaves_the_verdict_undecided() {
525        let refused = Err(SandboxError::Unenforceable {
526            atom: "net:egress:**".into(),
527            command: "cargo check".into(),
528        });
529        let verdict = verdict_for(refused).await;
530
531        assert!(matches!(verdict, Verdict::Inconclusive(_)), "{verdict:?}");
532    }
533
534    #[tokio::test]
535    async fn a_tool_killed_by_a_signal_is_inconclusive() {
536        let verdict = verdict_for(Ok(outcome(None, "", ""))).await;
537        assert!(matches!(verdict, Verdict::Inconclusive(_)), "{verdict:?}");
538    }
539
540    #[tokio::test]
541    async fn a_failure_with_no_output_at_all_still_says_something_useful() {
542        let verdict = verdict_for(Ok(outcome(Some(1), "", ""))).await;
543
544        let Verdict::Fail(reason) = verdict else {
545            panic!("expected Fail");
546        };
547        assert!(reason.contains("no diagnostics"), "{reason}");
548    }
549
550    #[tokio::test]
551    async fn diagnostics_on_stdout_are_read_too() {
552        let verdict = verdict_for(Ok(outcome(
553            Some(2),
554            "error: Type 'x' is not assignable",
555            "",
556        )))
557        .await;
558        assert!(verdict.reason().unwrap().contains("not assignable"));
559    }
560
561    // ── identifying the tool ────────────────────────────────────────────────
562
563    /// The criterion: the tool version is recorded in the evidence.
564    #[tokio::test]
565    async fn the_tool_version_reaches_the_evidence() {
566        let sandbox = Scripted::new(version(), Ok(outcome(Some(0), "", "")));
567        let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
568
569        let evidence = checker
570            .check(&order(), &condensate(), &key())
571            .await
572            .unwrap();
573
574        assert!(
575            evidence.checker.name.contains("cargo 1.85.0"),
576            "an auditor must be able to read which compiler said so: {}",
577            evidence.checker.name
578        );
579        assert!(
580            evidence.covers(&Digest::of(b"cargo 1.85.0 (1d8b05cdd 2026-01-20)")),
581            "and it must be bound by the signature, not merely printed"
582        );
583    }
584
585    #[tokio::test]
586    async fn two_versions_of_one_tool_are_different_checkers() {
587        let a = SandboxedCompile::declaring(
588            Scripted::new(version(), Ok(outcome(Some(0), "", ""))),
589            config(),
590            "tsc 5.4.2",
591        );
592        let b = SandboxedCompile::declaring(
593            Scripted::new(version(), Ok(outcome(Some(0), "", ""))),
594            config(),
595            "tsc 5.7.0",
596        );
597
598        assert_ne!(a.id(), b.id());
599    }
600
601    /// A verdict from an unknown compiler would be worthless, so there is no
602    /// fallback to an unidentified checker.
603    #[tokio::test]
604    async fn a_tool_that_will_not_say_what_it_is_yields_no_checker() {
605        let silent = Scripted::new(outcome(Some(0), "", ""), Ok(outcome(Some(0), "", "")));
606        let err = SandboxedCompile::probe(silent, config()).await.unwrap_err();
607        assert!(matches!(err, ProbeError::NoVersion { .. }), "{err:?}");
608
609        let missing = Scripted {
610            version: Err(SandboxError::Spawn {
611                command: "tsc --version".into(),
612                detail: "not found".into(),
613            }),
614            check: Ok(outcome(Some(0), "", "")),
615            seen: Mutex::new(Vec::new()),
616        };
617        let err = SandboxedCompile::probe(missing, config())
618            .await
619            .unwrap_err();
620        assert!(matches!(err, ProbeError::Unrunnable { .. }), "{err:?}");
621    }
622
623    #[tokio::test]
624    async fn a_version_command_that_fails_yields_no_checker() {
625        let broken = Scripted::new(
626            outcome(Some(127), "", "command not found"),
627            Ok(outcome(Some(0), "", "")),
628        );
629        assert!(SandboxedCompile::probe(broken, config()).await.is_err());
630    }
631
632    #[tokio::test]
633    async fn the_version_is_asked_for_before_the_check_is_run() {
634        let sandbox = Scripted::new(version(), Ok(outcome(Some(0), "", "")));
635        let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
636        checker
637            .check(&order(), &condensate(), &key())
638            .await
639            .unwrap();
640
641        let ran = checker.sandbox.ran();
642        assert!(ran[0].contains("--version"), "{ran:?}");
643        assert!(ran[1].contains("check"), "{ran:?}");
644    }
645
646    // ── authority ───────────────────────────────────────────────────────────
647
648    /// The criterion: the grant is no wider than reading the workspace, plus
649    /// the one program it has to execute.
650    #[tokio::test]
651    async fn the_grant_reads_the_workspace_and_nothing_else() {
652        let checker = SandboxedCompile::declaring(
653            Scripted::new(version(), Ok(outcome(Some(0), "", ""))),
654            config(),
655            "cargo 1.85.0",
656        );
657        let grant = checker.grants();
658
659        assert!(grant.iter().all(|a| a.action != "write"), "{grant:?}");
660        assert!(grant.iter().all(|a| a.domain != "net"), "{grant:?}");
661        assert!(grant.iter().all(|a| a.domain != "secret"), "{grant:?}");
662        assert!(
663            grant
664                .iter()
665                .filter(|a| a.domain == "fs")
666                .all(|a| a.resource.starts_with("/work/repo/")),
667            "{grant:?}"
668        );
669        assert!(
670            grant
671                .iter()
672                .any(|a| a.domain == "proc" && a.resource == "cargo")
673        );
674    }
675
676    /// And the trait enforces it: a checker declaring more never runs.
677    #[tokio::test]
678    async fn the_grant_is_within_what_a_checker_may_hold() {
679        use boundgraph_grant::{Tri, set_leq};
680        assert_eq!(
681            set_leq(&config().grant(), &crate::checker_ceiling()),
682            Tri::Yes
683        );
684    }
685
686    #[tokio::test]
687    async fn tsc_is_told_not_to_emit_because_a_checker_may_not_write() {
688        assert!(
689            CompileConfig::tsc("/w")
690                .args
691                .iter()
692                .any(|a| a == "--noEmit")
693        );
694    }
695
696    // ── output handling ─────────────────────────────────────────────────────
697
698    #[tokio::test]
699    async fn a_huge_diagnostic_is_cut_from_the_front_where_the_real_error_is() {
700        let noise = format!(
701            "error[E0001]: the first and only real problem\n{}",
702            "error: knock-on failure\n".repeat(5_000)
703        );
704        let verdict = verdict_for(Ok(outcome(Some(101), "", &noise))).await;
705        let reason = verdict.reason().unwrap();
706
707        assert!(reason.contains("E0001"), "the cause must survive");
708        assert!(reason.contains("truncated"), "and say it was cut");
709        assert!(reason.chars().count() < MAX_REASON + 500);
710    }
711
712    #[test]
713    fn head_keeps_the_start_and_says_when_it_cut() {
714        let text = "first\nmiddle\nlast";
715        assert!(head(text, 6).starts_with("first"));
716        assert!(head(text, 6).contains("truncated"));
717
718        // Short input is untouched.
719        assert_eq!(head(text, 100), text);
720    }
721
722    #[test]
723    fn truncating_does_not_cut_a_character_in_half() {
724        let text = "é".repeat(100);
725        assert!(!head(&text, 10).contains('\u{fffd}'));
726    }
727
728    // ── determinism ─────────────────────────────────────────────────────────
729
730    #[tokio::test]
731    async fn the_same_build_result_always_gives_the_same_evidence() {
732        let sandbox = Scripted::new(version(), Ok(outcome(Some(0), "", "")));
733        let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
734        let (order, k, key) = (order(), condensate(), key());
735
736        let first = checker.check(&order, &k, &key).await.unwrap();
737        for _ in 0..4 {
738            assert_eq!(checker.check(&order, &k, &key).await.unwrap(), first);
739        }
740    }
741}