Repository: tweag/nickel
Pull request #1346 (2023-06-07 10:42:49)

Add instructions for building ARM64 artifacts to RELEASING.md
Since we don't have an ARM64 GitHub Actions runner handy, this PR adds some instructions for manually building ARM64 release artifacts to `RELEASING.md`.


Patch:
diff --git a/.github/workflows/release-artifacts.yaml b/.github/workflows/release-artifacts.yaml
index 3a4bf655..dfabafa8 100644
--- a/.github/workflows/release-artifacts.yaml
+++ b/.github/workflows/release-artifacts.yaml
@@ -30,7 +30,7 @@ jobs:
           GH_TOKEN: ${{ github.token }}
           RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.release_tag }}
         run: |
-          gh release upload --clobber $RELEASE_TAG nickel-docker-image.tar.gz
+          gh release upload --clobber $RELEASE_TAG nickel-x86_64-docker-image.tar.gz
 
   static-binary:
     name: "Build Nickel release binary"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 78f39916..51a74cba 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -20,12 +20,12 @@ repository, which may or may not involve changing the source of Nickel.
 
 1. [Preamble](#preamble)
 1. [Resources](#resources)
-1. [Setup a development environment](#set-up-a-development-environment)
+1. [Set up a development environment](#set-up-a-development-environment)
 1. [How to submit changes](#how-to-submit-changes)
 
 ## Preamble
 
-Before contributing any non trivial change to this repository, please first
+Before contributing any non-trivial change to this repository, please first
 check for the existence of related work such as issues or open pull requests. If
 there are none, it's better to discuss the change you wish to make via an issue,
 by email, or using any other method with the maintainers of this repository
@@ -64,7 +64,7 @@ particular the Devs room), or fire an email at `nickel-lang@tweag.io`.
 
 ## Set up a development environment
 
-Please refer to [`HACKING.md`](./HACKING.md) to setup a development environment
+Please refer to [`HACKING.md`](./HACKING.md) to set up a development environment
 for Nickel, as well as adding or running tests and benchmarks.
 
 ## How to submit changes
@@ -76,7 +76,7 @@ hesitate to split your pull request into several ones. There are a couple
 aspects to consider when working on Nickel:
 
 1. **Documentation**: If you added new items (functions, modules) to the public API
-   of a crate, please document those items in-code. If you made an user-facing
+   of a crate, please document those items in-code. If you made a user-facing
    change (syntax, stdlib, language features, etc.), please update the existing
    documentation (in particular the user-manual) in consequence.
 2. **Tests**: be it for bug fixing or adding new features, please try to write
@@ -86,6 +86,36 @@ aspects to consider when working on Nickel:
    non-trivial way, it might be useful to run the benchmark suite on both master
    and on your branch and to report those results in the description of the PR.
 
+## The review process, or how to get your PR merged
+
+The conditions to merge a PR are whatever GitHub branch protection rules are
+currently set by the Nickel repository. If a PR has the green button available
+on GitHub, then this PR meets the conditions to be merged. Conditions usually
+include at least one review from a maintainer and a green CI.
+
+### As an external contributor
+
+As an external contributor, the Nickel team is responsible for giving your code
+a first review. Once done, they might require changes or ask questions. From
+there, please try to address every review comment (and resolve the corresponding
+conversation on GitHub), and request a new review once done.
+
+### As a maintainer
+
+If you are reviewing an external contributor's PR, _you are responsible for
+merging or rejecting the PR_. In general, you should only give an "Accept" if
+everything has been sorted out, and you're ready to click the green button
+without needing another back-and-forth with the author.
+
+If the author of a PR has commit rights (such as a Tweager or a Nickel
+maintainer), _they are responsible for merging their change_. This includes
+requiring review from the right people. When reviewing such PRs, you can leave
+an "Accept" although you made review comments: this indicates that you are fine
+with the work being merged as it is, but you have minor suggestions and
+questions that the author might want to consider. You trust the author to do
+whatever they think is best and proceed with merging the PR from there. This
+minimizes back-and-forths with trusted authors.
+
 [blog-series]: https://www.tweag.io/blog/2020-10-22-nickel-open-sourcing/
 [blog-release]: https://www.tweag.io/blog/2022-03-11-nickel-first-release/
 [user-manual]: https://nickel-lang.org/user-manual/introduction/
diff --git a/RATIONALE.md b/RATIONALE.md
index 135138ed..b80a73fb 100644
--- a/RATIONALE.md
+++ b/RATIONALE.md
@@ -135,14 +135,14 @@ the program itself or later in the pipeline ?  Using types, the generating
 language is no more oblivious to these external schemas and can model them
 internally, enabling early and precise error reporting.
 
-In Nickel, such schemas are specified using *enriched values*. Enriched values
-are meta-data about record fields like `id` or `baseURL`: they can provide
-documentation, a default value, or even a type. Types so specified are called
-*contracts*: they are not part of the static type system, but rather offer a
-principled approach to dynamic type checking. They enforce types (or more
-complex, user-defined) assertions at runtime. Equipped with enriched values, one
-can for example ensure that `baseURL` is not only a string but a valid URL, and
-document that it should be the Github homepage of a project.
+In Nickel, such schemas are specified using metadata. Metadata can provide
+documentation, a default value, or even a runtime type. Runtime types so
+specified are called *contracts*: they are not part of the static type system,
+but rather offer a principled approach to dynamic type checking. They enforce
+types (or more complex, user-defined assertions) at runtime. Equipped with
+metadata, one can for example enforce that `baseURL` is not only a string but a
+valid URL, and attach documentation to specify that it should be the Github
+homepage of a project.
 
 ### Turing completeness
 
@@ -323,7 +323,7 @@ This provides:
 - Querying: synthesize values inhabiting a type
 - Trimming: Automatically simplify code
 
-Nickel's merge system and enriched values are inspired by CUE's type lattice,
+Nickel's merge system and metadata are inspired by CUE's type lattice,
 although the flexibility of Nickel necessarily makes the two system behave
 differently.
 
@@ -344,8 +344,8 @@ functionalities to Nickel's merge system.
 ### Jsonnet vs Nickel
 
 The main difference between Jsonnet and Nickel are types. Jsonnet does not
-feature static types, contracts or enriched values, and thus can't type library
-code and has no principled approach to data validation.
+feature static types, contracts or metadata, and thus can't type library code
+and has no principled approach to data validation.
 
 ### Comparison with other configuration languages
 <!-- Intentionally duplicated in `README.md`, please update the other one for
diff --git a/RELEASING.md b/RELEASING.md
index 5e250b64..a6341d68 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -81,12 +81,24 @@ crates and dependent repositories (such as the website) in a consistent state.
 
 ### Release on GitHub
 
-1. Do the [release on
+1. Build a Docker image and a static binary for ARM64 manually, using
+
+   ```console
+   nix build --out-link nickel-arm64-docker-image.tar.gz .#packages.aarch64-linux.dockerImage
+   nix build --out-link nickel-arm64-linux .#packages.aarch64-linux.dockerImage
+   ```
+
+   on an ARM64 Linux machine (x86_64 assets are automatically handled by a
+   workflow, see 3).
+
+2. Do the [release on
    GitHub](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository),
    and include the docker image built in 1. Make sure that you set `X.Y.Z-release`
    as the target branch and have GitHub create the `X.Y.Z` tag on release.
+   Upload `nickel-arm64-docker-image.tar.gz` and `nickel-arm64-linux` as release
+   assets from the last step.
 
-2. Verify that the "Upload release artifacts" GitHub action is getting triggered
+3. Verify that the "Upload release artifacts" GitHub action is getting triggered
    and completes successfully, uploading a static Nickel binary for x86_64 Linux
    and a Docker image.
 
diff --git a/flake.nix b/flake.nix
index 97fc7e49..d4403db2 100644
--- a/flake.nix
+++ b/flake.nix
@@ -459,7 +459,7 @@
 
       devShells = (forEachRustChannel (channel: {
         name = channel;
-        value = makeDevShell { rust = mkRust { inherit channel; rustProfile = "default"; }; };
+        value = makeDevShell { rust = mkRust { inherit channel; rustProfile = "default"; targets = [ "wasm32-unknown-unknown" ]; }; };
       })) // {
         default = devShells.stable;
       };
diff --git a/pyckel/src/lib.rs b/pyckel/src/lib.rs
index abfe963e..e6d082cf 100644
--- a/pyckel/src/lib.rs
+++ b/pyckel/src/lib.rs
@@ -19,7 +19,8 @@ fn error_to_exception<E: Into<Error>, EC: Cache>(error: E, program: &mut Program
 /// Evaluate from a Python str of a Nickel expression to a Python str of the resulting JSON.
 #[pyfunction]
 pub fn run(s: String) -> PyResult<String> {
-    let mut program: Program<CacheImpl> = Program::new_from_source(Cursor::new(s), "python")?;
+    let mut program: Program<CacheImpl> =
+        Program::new_from_source(Cursor::new(s), "python", std::io::sink())?;
 
     let term = program
         .eval_full()
diff --git a/src/bin/nickel.rs b/src/bin/nickel.rs
index c75577e2..e08f846b 100644
--- a/src/bin/nickel.rs
+++ b/src/bin/nickel.rs
@@ -6,7 +6,7 @@ use nickel_lang::program::Program;
 use nickel_lang::repl::query_print;
 #[cfg(feature = "repl")]
 use nickel_lang::repl::rustyline_frontend;
-use nickel_lang::term::{RichTerm, Term};
+use nickel_lang::term::RichTerm;
 use nickel_lang::{serialize, serialize::ExportFormat};
 use std::path::{Path, PathBuf};
 use std::{
@@ -154,8 +154,8 @@ fn main() {
         let mut program = opts
             .file
             .clone()
-            .map(Program::new_from_file)
-            .unwrap_or_else(Program::new_from_stdin)
+            .map(|f| Program::new_from_file(f, std::io::stderr()))
+            .unwrap_or_else(|| Program::new_from_stdin(std::io::stderr()))
             .unwrap_or_else(|err| {
                 eprintln!("Error when reading input: {err}");
                 process::exit(1)
@@ -207,9 +207,7 @@ fn main() {
                 stdout,
                 format,
             }) => export_doc(&mut program, opts.file.as_ref(), output, stdout, format),
-            None => program
-                .eval_full()
-                .map(|t| println!("{}", Term::from(t).deep_repr())),
+            None => program.eval_full().map(|t| println!("{t}")),
         };
 
         if let Err(err) = result {
diff --git a/src/deserialize.rs b/src/deserialize.rs
index 2ab2f92c..743ca8cb 100644
--- a/src/deserialize.rs
+++ b/src/deserialize.rs
@@ -584,12 +584,18 @@ impl serde::de::Error for RustDeserializationError {
 mod tests {
     use std::io::Cursor;
 
-    use nickel_lang_utilities::test_program::TestProgram;
+    use nickel_lang_utilities::{
+        nickel_lang::{deserialize::RustDeserializationError, term::RichTerm},
+        test_program::TestProgram,
+    };
     use serde::Deserialize;
 
-    use crate::{eval::cache::CacheImpl, program::Program};
-
-    use super::RustDeserializationError;
+    fn eval(source: &str) -> RichTerm {
+        TestProgram::new_from_source(Cursor::new(source), "source", std::io::stderr())
+            .expect("program shouldn't fail")
+            .eval_full()
+            .expect("evaluation shouldn't fail")
+    }
 
     #[test]
     fn rust_deserialize_struct_with_fields() {
@@ -621,15 +627,7 @@ mod tests {
 
         assert_eq!(
             A::deserialize(
-                TestProgram::new_from_source(
-                    Cursor::new(
-                        br#"{ a = 10, b = "test string", c = null, d = true, e = 'foo, f = null, g = -10, h = { bar = "some other string" } }"#.to_vec()
-                    ),
-                    "source"
-                )
-                .expect("program shouldn't fail")
-                .eval_full()
-                .expect("evaluation shouldn't fail")
+                eval(r#"{ a = 10, b = "test string", c = null, d = true, e = 'foo, f = null, g = -10, h = { bar = "some other string" } }"#)
             )
             .expect("deserialization shouldn't fail"),
             A {
@@ -648,13 +646,8 @@ mod tests {
     #[test]
     fn rust_deserialize_array_of_numbers() {
         assert_eq!(
-            Vec::<f64>::deserialize(
-                TestProgram::new_from_source(Cursor::new(br#"[1, 2, 3, 4]"#.to_vec()), "source")
-                    .expect("program shouldn't fail")
-                    .eval_full()
-                    .expect("evaluation shouldn't fail")
-            )
-            .expect("deserialization shouldn't fail"),
+            Vec::<f64>::deserialize(eval(r#"[1, 2, 3, 4]"#))
+                .expect("deserialization shouldn't fail"),
             vec![1.0, 2.0, 3.0, 4.0]
         )
     }
@@ -664,16 +657,8 @@ mod tests {
         #[derive(Debug, PartialEq, Deserialize)]
         struct A;
 
-        let mut p = Program::<CacheImpl>::new_from_source(
-            Cursor::new(br#"fun a b => a + b"#.to_vec()),
-            "source",
-        )
-        .expect("program shouldn't fail");
-
-        let q = p.eval_full().expect("evaluation shouldn't fail");
-
         assert_eq!(
-            A::deserialize(q),
+            A::deserialize(eval(r#"fun a b => a + b"#)),
             Err(RustDeserializationError::InvalidType {
                 expected: "Null".to_string(),
                 occurred: "Function".to_string()
@@ -689,16 +674,8 @@ mod tests {
         }
 
         assert_eq!(
-            A::deserialize(
-                TestProgram::new_from_source(
-                    Cursor::new(br#"{ a = (10 | Number) }"#.to_vec()),
-                    "source"
-                )
-                .expect("program shouldn't fail")
-                .eval_full()
-                .expect("evaluation shouldn't fail")
-            )
-            .expect("deserialization shouldn't fail"),
+            A::deserialize(eval(r#"{ a = (10 | Number) }"#))
+                .expect("deserialization shouldn't fail"),
             A { a: 10.0 }
         )
     }
diff --git a/src/eval/cache/lazy.rs b/src/eval/cache/lazy.rs
index 3c7a48a8..510eedd3 100644
--- a/src/eval/cache/lazy.rs
+++ b/src/eval/cache/lazy.rs
@@ -547,8 +547,8 @@ impl Thunk {
 
     /// Determine if a thunk is worth being put on the stack for future update.
     ///
-    /// Typically, WHNFs and enriched values will not be evaluated to a simpler expression and are not
-    /// worth updating.
+    /// Typically, WHNFs and annotated values will not be evaluated to a simpler expression and are
+    /// not worth updating.
     pub fn should_update(&self) -> bool {
         let term = &self.borrow().body.term;
         !term.is_whnf() && !term.is_annotated()
diff --git a/src/eval/merge.rs b/src/eval/merge.rs
index b2860af4..919d2081 100644
--- a/src/eval/merge.rs
+++ b/src/eval/merge.rs
@@ -1,7 +1,7 @@
 //! Evaluation of the merge operator.
 //!
 //! Merge is a primitive operation of Nickel, which recursively combines records. Together with
-//! enriched values, it allows to write and mix contracts with standard records.
+//! field metadata, it allows to write and mix contracts with standard records.
 //!
 //! # Operational semantics
 //!
@@ -13,44 +13,17 @@
 //! - Fields that are both in `r1` and `r2` are recursively merged: for a field `f`, the result
 //! contains the binding `f = r1.f & r2.f`
 //!
-//! As fields are recursively merged, merge needs to operate on any value, not only on records.
+//! As fields are recursively merged, merge needs to operate on any value, not only on records:
 //!
-//! ## On simple values
+//! - *function*: merging a function with anything else fails
+//! - *values*: merging any other values succeeds if and only if these two values are equals, in
+//! which case it evaluates to this common value.
 //!
-//! Simple values are terms which are not enriched values.
+//! ## Metadata
 //!
-//! - *Function*: merging a function with anything else fails
-//! - *Values*: merging any other values succeeds if and only if these two values are equals, in which case it evaluates to
-//! this common value.
-//!
-//! Note that merging of arrays is not yet implemented.
-//!
-//! ## On enriched values
-//!
-//! Enriched values (currently `Contract`, `Default`, `ContractDefault` or `Docstring`) get their
-//! special powers from their interaction with the merge operator.
-//!
-//! ### Enriched/Enriched
-//!
-//! - *Contract/contract*: merging two contracts evaluates to a contract which is the composition
-//! of the two
-//! - *Default/default*: merging two default values evaluates to a default which value is the merge
-//! of the two
-//! - *Contract/default*: merging a `Default` with a `Contract` evaluates to a `ContractDefault`
-//! - *ContractDefault/_*: Merging `ContractDefault` is done component-wise: with another
-//! `ContractDefault`, it evaluates to a `ContractDefault` where the two contracts as well as the
-//! two default values are respectively merged together. With either just a `Contract` or a
-//! `Default`, it simply merges the corresponding component and let the other unchanged.
-//!
-//! ### Enriched/Simple
-//!
-//! - *Docstring*: merging a docstring (with inner term `inner`) with another term `t` recursively merges
-//! `inner` and `t`, and evaluates to this result wrapped in the original docstring (`t` may be a simple value or an
-//! enriched one here)
-//! - *Default erasure*: merging a `Default` with a simple value drops the default value and
-//! evaluates to the simple value
-//! - *Contract check*: merging a `Contract` or a `ContractDefault` with a simple value `t`
-//! evaluates to a contract check, that is an `Assume(..., t)`
+//! One can think of merge to be defined on metadata as well. When merging two fields, the
+//! resulting metadata is the result of merging the two original field's metadata. The semantics
+//! depend on each metadata.
 use super::*;
 use crate::error::{EvalError, IllegalPolymorphicTailAction};
 use crate::label::{Label, MergeLabel};
diff --git a/src/eval/mod.rs b/src/eval/mod.rs
index e9a8e871..a383bd72 100644
--- a/src/eval/mod.rs
+++ b/src/eval/mod.rs
@@ -92,6 +92,8 @@ use crate::{
     transform::Closurizable,
 };
 
+use std::io::Write;
+
 pub mod cache;
 pub mod callstack;
 pub mod fixpoint;
@@ -122,24 +124,28 @@ pub struct VirtualMachine<R: ImportResolver, C: Cache> {
     import_resolver: R,
     // The evaluation cache.
     pub cache: C,
+    // The stream for writing trace output.
+    trace: Box<dyn Write>,
 }
 
 impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
-    pub fn new(import_resolver: R) -> Self {
+    pub fn new(import_resolver: R, trace: impl Write + 'static) -> Self {
         VirtualMachine {
             import_resolver,
             call_stack: Default::default(),
             stack: Stack::new(),
             cache: Cache::new(),
+            trace: Box::new(trace),
         }
     }
 
-    pub fn new_with_cache(import_resolver: R, cache: C) -> Self {
+    pub fn new_with_cache(import_resolver: R, cache: C, trace: impl Write + 'static) -> Self {
         VirtualMachine {
             import_resolver,
             call_stack: Default::default(),
             stack: Stack::new(),
             cache,
+            trace: Box::new(trace),
         }
     }
 
diff --git a/src/eval/operation.rs b/src/eval/operation.rs
index 6c368e0a..301ce95f 100644
--- a/src/eval/operation.rs
+++ b/src/eval/operation.rs
@@ -1104,7 +1104,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             },
             UnaryOp::Trace() => {
                 if let Term::Str(s) = &*t {
-                    eprintln!("std.trace: {s}");
+                    let _ = writeln!(self.trace, "std.trace: {s}");
                     Ok(())
                 } else {
                     Err(mk_type_error!("trace", "String"))
@@ -3207,7 +3207,7 @@ mod tests {
     fn ite_operation() {
         let cont: OperationCont = OperationCont::Op1(UnaryOp::Ite(), TermPos::None);
         let mut vm: VirtualMachine<DummyResolver, CacheImpl> =
-            VirtualMachine::new(DummyResolver {});
+            VirtualMachine::new(DummyResolver {}, std::io::sink());
 
         vm.stack
             .push_arg(Closure::atomic_closure(mk_term::integer(5)), TermPos::None);
@@ -3248,7 +3248,7 @@ mod tests {
             body: mk_term::integer(7),
             env: Environment::new(),
         };
-        let mut vm = VirtualMachine::new(DummyResolver {});
+        let mut vm = VirtualMachine::new(DummyResolver {}, std::io::sink());
         vm.stack.push_op_cont(cont, 0, TermPos::None);
 
         clos = vm.continuate_operation(clos).unwrap();
@@ -3293,7 +3293,7 @@ mod tests {
         );
 
         let mut vm: VirtualMachine<DummyResolver, CacheImpl> =
-            VirtualMachine::new(DummyResolver {});
+            VirtualMachine::new(DummyResolver {}, std::io::sink());
         let mut clos = Closure {
             body: mk_term::integer(6),
             env: Environment::new(),
diff --git a/src/label.rs b/src/label.rs
index a3b6104f..f8d4d8e6 100644
--- a/src/label.rs
+++ b/src/label.rs
@@ -235,8 +235,9 @@ but this field doesn't exist in {}",
 
 /// A blame label.
 ///
-/// A label is associated to a contract check (an assume, a promise or a contract as an enriched
-/// value) and contains information to report to the user when a contract fails and a blame occurs.
+/// A label is associated to a contract check (introduced by an explicit contract annotation, or
+/// implicitly by a merge expression) and contains information to report to the user when a
+/// contract fails and a blame occurs.
 /// It includes in particular a [type path][ty_path::Path] and a **polarity**.
 ///
 /// # Polarity
diff --git a/src/pretty.rs b/src/pretty.rs
index 1ce93822..a857bb67 100644
--- a/src/pretty.rs
+++ b/src/pretty.rs
@@ -410,6 +410,17 @@ where
 }
 
 impl<'a, D, A> Pretty<'a, D, A> for RichTerm
+where
+    D: NickelAllocatorExt<'a, A>,
+    D::Doc: Clone,
+    A: Clone + 'a,
+{
+    fn pretty(self, allocator: &'a D) -> DocBuilder<'a, D, A> {
+        self.as_ref().pretty(allocator)
+    }
+}
+
+impl<'a, D, A> Pretty<'a, D, A> for &Term
 where
     D: NickelAllocatorExt<'a, A>,
     D::Doc: Clone,
@@ -418,7 +429,7 @@ where
     fn pretty(self, allocator: &'a D) -> DocBuilder<'a, D, A> {
         use Term::*;
 
-        match self.as_ref() {
+        match self {
             Null => allocator.text("null"),
             Bool(v) => allocator.as_string(v),
             Num(n) => allocator.as_string(format!("{}", n.to_sci())),
@@ -453,8 +464,8 @@ where
             // TODO Pattern destructuring to implement.
             FunPattern(..) => {
                 let mut params = vec![];
-                let mut rt = &self;
-                while let FunPattern(id, dst, t) = rt.as_ref() {
+                let mut rt = self;
+                while let FunPattern(id, dst, t) = rt {
                     params.push(if let Some(id) = id {
                         allocator
                             .as_string(id)
@@ -462,7 +473,7 @@ where
                     } else {
                         dst.pretty(allocator)
                     });
-                    rt = t;
+                    rt = t.as_ref();
                 }
                 allocator
                     .text("fun")
diff --git a/src/program.rs b/src/program.rs
index 3e772e14..8188ace8 100644
--- a/src/program.rs
+++ b/src/program.rs
@@ -30,7 +30,7 @@ use crate::term::{record::Field, RichTerm};
 use codespan::FileId;
 use codespan_reporting::term::termcolor::{Ansi, ColorChoice, StandardStream};
 use std::ffi::OsString;
-use std::io::{self, Cursor, Read};
+use std::io::{self, Cursor, Read, Write};
 use std::result::Result;
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -119,14 +119,17 @@ pub struct Program<EC: EvalCache> {
 
 impl<EC: EvalCache> Program<EC> {
     /// Create a program by reading it from the standard input.
-    pub fn new_from_stdin() -> std::io::Result<Self> {
-        Program::new_from_source(io::stdin(), "<stdin>")
+    pub fn new_from_stdin(trace: impl Write + 'static) -> std::io::Result<Self> {
+        Program::new_from_source(io::stdin(), "<stdin>", trace)
     }
 
-    pub fn new_from_file(path: impl Into<OsString>) -> std::io::Result<Self> {
+    pub fn new_from_file(
+        path: impl Into<OsString>,
+        trace: impl Write + 'static,
+    ) -> std::io::Result<Self> {
         let mut cache = Cache::new(ErrorTolerance::Strict);
         let main_id = cache.add_file(path)?;
-        let vm = VirtualMachine::new(cache);
+        let vm = VirtualMachine::new(cache, trace);
 
         Ok(Self {
             main_id,
@@ -136,14 +139,18 @@ impl<EC: EvalCache> Program<EC> {
     }
 
     /// Create a program by reading it from a generic source.
-    pub fn new_from_source<T, S>(source: T, source_name: S) -> std::io::Result<Self>
+    pub fn new_from_source<T, S>(
+        source: T,
+        source_name: S,
+        trace: impl Write + 'static,
+    ) -> std::io::Result<Self>
     where
         T: Read,
         S: Into<OsString> + Clone,
     {
         let mut cache = Cache::new(ErrorTolerance::Strict);
         let main_id = cache.add_source(source_name, source)?;
-        let vm = VirtualMachine::new(cache);
+        let vm = VirtualMachine::new(cache, trace);
 
         Ok(Self {
             main_id,
@@ -601,8 +608,8 @@ mod tests {
     fn eval_full(s: &str) -> Result<RichTerm, Error> {
         let src = Cursor::new(s);
 
-        let mut p: Program<CacheImpl> =
-            Program::new_from_source(src, "<test>").map_err(|io_err| {
+        let mut p: Program<CacheImpl> = Program::new_from_source(src, "<test>", std::io::sink())
+            .map_err(|io_err| {
                 Error::EvalError(EvalError::Other(
                     format!("IO error: {io_err}"),
                     TermPos::None,
@@ -614,8 +621,8 @@ mod tests {
     fn typecheck(s: &str) -> Result<(), Error> {
         let src = Cursor::new(s);
 
-        let mut p: Program<CacheImpl> =
-            Program::new_from_source(src, "<test>").map_err(|io_err| {
+        let mut p: Program<CacheImpl> = Program::new_from_source(src, "<test>", std::io::sink())
+            .map_err(|io_err| {
                 Error::EvalError(EvalError::Other(
                     format!("IO error: {io_err}"),
                     TermPos::None,
diff --git a/src/repl/mod.rs b/src/repl/mod.rs
index 6cca8753..10a60220 100644
--- a/src/repl/mod.rs
+++ b/src/repl/mod.rs
@@ -84,12 +84,12 @@ pub struct ReplImpl<EC: EvalCache> {
 
 impl<EC: EvalCache> ReplImpl<EC> {
     /// Create a new empty REPL.
-    pub fn new() -> Self {
+    pub fn new(trace: impl Write + 'static) -> Self {
         ReplImpl {
             parser: grammar::ExtendedTermParser::new(),
             env: Envs::new(),
             initial_type_ctxt: typecheck::Context::new(),
-            vm: VirtualMachine::new(Cache::new(ErrorTolerance::Strict)),
+            vm: VirtualMachine::new(Cache::new(ErrorTolerance::Strict), trace),
         }
     }
 
diff --git a/src/repl/rustyline_frontend.rs b/src/repl/rustyline_frontend.rs
index d4b6158f..10901430 100644
--- a/src/repl/rustyline_frontend.rs
+++ b/src/repl/rustyline_frontend.rs
@@ -33,7 +33,7 @@ impl From<ColorOpt> for rustyline::config::ColorMode {
 
 /// Main loop of the REPL.
 pub fn repl(histfile: PathBuf, color_opt: ColorOpt) -> Result<(), InitError> {
-    let mut repl = ReplImpl::<CacheImpl>::new();
+    let mut repl = ReplImpl::<CacheImpl>::new(std::io::stderr());
 
     match repl.load_stdlib() {
         Ok(()) => (),
@@ -95,7 +95,7 @@ pub fn repl(histfile: PathBuf, color_opt: ColorOpt) -> Result<(), InitError> {
                     }),
                     Ok(Command::Print(exp)) => {
                         match repl.eval_full(&exp) {
-                            Ok(EvalResult::Evaluated(rt)) => println!("{}\n", rt.as_ref().deep_repr()),
+                            Ok(EvalResult::Evaluated(rt)) => println!("{rt}"),
                             Ok(EvalResult::Bound(_)) => (),
                             Err(err) => program::report(repl.cache_mut(), err, color_opt),
                         };
@@ -120,7 +120,7 @@ pub fn repl(histfile: PathBuf, color_opt: ColorOpt) -> Result<(), InitError> {
             }
             Ok(line) => {
                 match repl.eval_full(&line) {
-                    Ok(EvalResult::Evaluated(rt)) => println!("{}\n", rt.as_ref().deep_repr()),
+                    Ok(EvalResult::Evaluated(rt)) => println!("{rt}\n"),
                     Ok(EvalResult::Bound(_)) => (),
                     Err(err) => program::report(repl.cache_mut(), err, color_opt),
                 };
diff --git a/src/repl/simple_frontend.rs b/src/repl/simple_frontend.rs
index 0185ad7a..e32bcf91 100644
--- a/src/repl/simple_frontend.rs
+++ b/src/repl/simple_frontend.rs
@@ -29,8 +29,8 @@ impl From<Error> for InputError {
 }
 
 /// Return a new instance of an REPL with the standard library loaded.
-pub fn init<EC: EvalCache>() -> Result<ReplImpl<EC>, Error> {
-    let mut repl = ReplImpl::new();
+pub fn init<EC: EvalCache>(trace: impl Write + 'static) -> Result<ReplImpl<EC>, Error> {
+    let mut repl = ReplImpl::new(trace);
     repl.load_stdlib()?;
     Ok(repl)
 }
@@ -65,7 +65,7 @@ pub fn input<R: Repl>(repl: &mut R, line: &str) -> Result<InputResult, InputErro
             Ok(Command::Print(exp)) => repl
                 .eval_full(&exp)
                 .map(|res| match res {
-                    EvalResult::Evaluated(rt) => InputResult::Success(rt.as_ref().deep_repr()),
+                    EvalResult::Evaluated(rt) => InputResult::Success(format!("{rt}\n")),
                     EvalResult::Bound(_) => InputResult::Blank,
                 })
                 .map_err(InputError::from),
@@ -82,9 +82,7 @@ pub fn input<R: Repl>(repl: &mut R, line: &str) -> Result<InputResult, InputErro
     } else {
         repl.eval_full(line)
             .map(|eval_res| match eval_res {
-                EvalResult::Evaluated(rt) => {
-                    InputResult::Success(format!("{}\n", rt.as_ref().deep_repr()))
-                }
+                EvalResult::Evaluated(rt) => InputResult::Success(format!("{rt}\n")),
                 EvalResult::Bound(_) => InputResult::Success(String::new()),
             })
             .map_err(InputError::from)
diff --git a/src/repl/wasm_frontend.rs b/src/repl/wasm_frontend.rs
index 565b1e93..bfd1e5bb 100644
--- a/src/repl/wasm_frontend.rs
+++ b/src/repl/wasm_frontend.rs
@@ -271,6 +271,42 @@ impl TryInto<ExportFormat> for WasmExportFormat {
     }
 }
 
+/// A `Write` implementor that calls a callback whenever it gets written to.
+struct CallbackWriter {
+    callback: Option<js_sys::Function>,
+    buf: Vec<u8>,
+}
+
+impl CallbackWriter {
+    fn new(callback: Option<js_sys::Function>) -> Self {
+        Self {
+            callback,
+            buf: Vec::new(),
+        }
+    }
+}
+
+impl std::io::Write for CallbackWriter {
+    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+        self.buf.extend_from_slice(buf);
+        if buf.contains(&b'\n') {
+            self.flush()?;
+        }
+        Ok(buf.len())
+    }
+
+    fn flush(&mut self) -> std::io::Result<()> {
+        if let Some(callback) = &self.callback {
+            let js_string = JsValue::from_str(String::from_utf8_lossy(&self.buf).trim());
+            callback
+                .call1(&JsValue::NULL, &js_string)
+                .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{e:?}")))?;
+        }
+        self.buf.clear();
+        Ok(())
+    }
+}
+
 /// Render error diagnostics as a string.
 pub fn diags_to_string(cache: &mut Cache, diags: &[Diagnostic<FileId>]) -> String {
     let mut buffer = Ansi::new(Cursor::new(Vec::new()));
@@ -300,8 +336,9 @@ pub fn err_to_string(cache: &mut Cache, error: InputError) -> String {
 
 /// Return a new instance of the WASM REPL, with the standard library loaded.
 #[wasm_bindgen]
-pub fn repl_init() -> WasmInitResult {
-    let mut repl = ReplImpl::new();
+pub fn repl_init(trace_callback: Option<js_sys::Function>) -> WasmInitResult {
+    let trace = CallbackWriter::new(trace_callback);
+    let mut repl = ReplImpl::new(trace);
     match repl.load_stdlib() {
         Ok(()) => WasmInitResult {
             msg: String::new(),
diff --git a/src/serialize.rs b/src/serialize.rs
index f6dac56e..1216c8a2 100644
--- a/src/serialize.rs
+++ b/src/serialize.rs
@@ -259,62 +259,55 @@ pub fn to_string(format: ExportFormat, rt: &RichTerm) -> Result<String, ExportEr
 mod tests {
     use super::*;
     use crate::cache::resolvers::DummyResolver;
-    use crate::error::{Error, EvalError};
     use crate::eval::cache::CacheImpl;
     use crate::eval::{Environment, VirtualMachine};
-    use crate::position::TermPos;
     use crate::program::Program;
     use crate::term::{make as mk_term, BinaryOp};
     use serde_json::json;
     use std::io::Cursor;
 
-    fn mk_program(s: &str) -> Result<Program<CacheImpl>, Error> {
+    fn eval(s: &str) -> RichTerm {
         let src = Cursor::new(s);
-
-        Program::new_from_source(src, "<test>").map_err(|io_err| {
-            Error::EvalError(EvalError::Other(
-                format!("IO error: {io_err}"),
-                TermPos::None,
-            ))
-        })
+        let mut prog =
+            Program::<CacheImpl>::new_from_source(src, "<test>", std::io::stderr()).unwrap();
+        prog.eval_full().expect("program eval should succeed")
     }
 
     macro_rules! assert_json_eq {
         ( $term:expr, $result:expr ) => {
             assert_eq!(
-                serde_json::to_string(&mk_program($term).and_then(|mut p| p.eval_full()).unwrap())
-                    .unwrap(),
+                serde_json::to_string(&eval($term)).unwrap(),
                 serde_json::to_string(&$result).unwrap()
             )
         };
     }
 
+    macro_rules! assert_nickel_eq {
+        ( $term:expr, $result:expr ) => {
+            assert_eq!(
+                VirtualMachine::<_, CacheImpl>::new(DummyResolver {}, std::io::stderr())
+                    .eval(
+                        mk_term::op2(BinaryOp::Eq(), $term, $result),
+                        &Environment::new(),
+                    )
+                    .map(Term::from),
+                Ok(Term::Bool(true))
+            )
+        };
+    }
+
     macro_rules! assert_pass_validation {
         ( $format:expr, $term:expr, true) => {
-            validate(
-                $format,
-                &mk_program($term)
-                    .and_then(|mut p| p.eval_full())
-                    .unwrap()
-                    .into(),
-            )
-            .unwrap();
+            validate($format, &eval($term)).unwrap();
         };
         ( $format:expr, $term:expr, false) => {
-            validate(
-                $format,
-                &mk_program($term)
-                    .and_then(|mut p| p.eval_full())
-                    .unwrap()
-                    .into(),
-            )
-            .unwrap_err();
+            validate($format, &eval($term)).unwrap_err();
         };
     }
 
     macro_rules! assert_involutory {
         ( $term:expr ) => {
-            let evaluated = mk_program($term).and_then(|mut p| p.eval_full()).unwrap();
+            let evaluated = eval($term);
             let from_json: RichTerm =
                 serde_json::from_str(&serde_json::to_string(&evaluated).unwrap()).unwrap();
             let from_yaml: RichTerm =
@@ -322,33 +315,9 @@ mod tests {
             let from_toml: RichTerm =
                 toml::from_str(&format!("{}", &toml::to_string(&evaluated).unwrap())).unwrap();
 
-            assert_eq!(
-                VirtualMachine::<_, CacheImpl>::new(DummyResolver {})
-                    .eval(
-                        mk_term::op2(BinaryOp::Eq(), from_json, evaluated.clone()),
-                        &Environment::new(),
-                    )
-                    .map(Term::from),
-                Ok(Term::Bool(true))
-            );
-            assert_eq!(
-                VirtualMachine::<_, CacheImpl>::new(DummyResolver {})
-                    .eval(
-                        mk_term::op2(BinaryOp::Eq(), from_yaml, evaluated.clone()),
-                        &Environment::new(),
-                    )
-                    .map(Term::from),
-                Ok(Term::Bool(true))
-            );
-            assert_eq!(
-                VirtualMachine::<_, CacheImpl>::new(DummyResolver {})
-                    .eval(
-                        mk_term::op2(BinaryOp::Eq(), from_toml, evaluated),
-                        &Environment::new(),
-                    )
-                    .map(Term::from),
-                Ok(Term::Bool(true))
-            );
+            assert_nickel_eq!(from_json, evaluated.clone());
+            assert_nickel_eq!(from_yaml, evaluated.clone());
+            assert_nickel_eq!(from_toml, evaluated);
         };
     }
 
diff --git a/src/term/mod.rs b/src/term/mod.rs
index 361facfe..12ecc3ae 100644
--- a/src/term/mod.rs
+++ b/src/term/mod.rs
@@ -7,16 +7,8 @@
 //! - Data structures: arrays and records
 //! - Binders: functions and let bindings
 //!
-//! It also features type annotations (promise and assume), and other typechecking related
+//! It also features types and type annotations, and other typechecking or contracts-related
 //! constructs (label, symbols, etc.).
-//!
-//! # Enriched values
-//!
-//! Enriched values are special terms used to represent metadata about record fields: types or
-//! contracts, default values, documentation, etc. They bring such usually external object down to
-//! the term level, and together with [crate::eval::merge], they allow for flexible and modular
-//! definitions of contracts, record and metadata all together.
-
 pub mod array;
 pub mod record;
 pub mod string;
@@ -742,40 +734,6 @@ impl Term {
         }
     }
 
-    /// Return a deep string representation of a term, used for printing in the REPL
-    pub fn deep_repr(&self) -> String {
-        match self {
-            Term::Record(r) | Term::RecRecord(r, ..) => {
-                let fields_str: Vec<String> = r
-                    .fields
-                    .iter()
-                    .map(|(ident, field)| {
-                        if let Some(ref value) = field.value {
-                            format!("{} = {}", ident, value.as_ref().deep_repr())
-                        } else {
-                            format!("{ident}")
-                        }
-                    })
-                    .collect();
-
-                let suffix = match self {
-                    Term::RecRecord(_, dyn_fields, ..) if !dyn_fields.is_empty() => ", ..",
-                    _ => "",
-                };
-
-                format!("{{ {}{} }}", fields_str.join(", "), suffix)
-            }
-            Term::Array(elements, _) => {
-                let elements_str: Vec<String> = elements
-                    .iter()
-                    .map(|term| term.as_ref().deep_repr())
-                    .collect();
-                format!("[ {} ]", elements_str.join(", "))
-            }
-            _ => self.shallow_repr(),
-        }
-    }
-
     /// Determine if a term is in evaluated from, called weak head normal form (WHNF).
     pub fn is_whnf(&self) -> bool {
         match self {
@@ -1734,9 +1692,8 @@ impl From<Term> for RichTerm {
 impl std::fmt::Display for RichTerm {
     fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
         use crate::pretty::*;
-        use pretty::BoxAllocator;
 
-        let allocator = BoxAllocator;
+        let allocator = pretty::BoxAllocator;
 
         let doc: DocBuilder<_, ()> = self.clone().pretty(&allocator);
         doc.render_fmt(80, f)
diff --git a/src/transform/share_normal_form.rs b/src/transform/share_normal_form.rs
index e26b69f4..8524fa8f 100644
--- a/src/transform/share_normal_form.rs
+++ b/src/transform/share_normal_form.rs
@@ -48,8 +48,8 @@ struct Binding {
 /// This function is not recursive: it just tries to apply one step of the transformation to
 /// the top-level node of the AST. For example, it transforms `[1 + 1, [1 + 2]]` to `let %0 = 1
 /// + 1 in [%0, [1 + 2]]`: the nested subterm `[1 + 2]` is left as it was. If the term is
-/// neither a record, an array nor an enriched value, it is returned the same.  In other words,
-/// the transformation is implemented as rewrite rules, and must be used in conjunction a
+/// neither a record, an array nor an annotated value, it is returned the same.  In other words,
+/// the transformation is implemented as rewrite rules, and must be used in conjunction with a
 /// traversal to obtain a full transformation.
 pub fn transform_one(rt: RichTerm) -> RichTerm {
     let pos = rt.pos;
diff --git a/src/types.rs b/src/types.rs
index 63630a8c..883a5940 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -46,9 +46,8 @@
 //!
 //! To each type corresponds a contract, which is a Nickel function which checks at runtime that
 //! its argument is of the given type and either returns it if it passes or raise a blame
-//! otherwise.  Contract checks are introduced by `Promise` and `Assume` blocks or alternatively by
-//! enriched values `Contract` or `ContractDefault`. They ensure sane interaction between typed and
-//! untyped parts.
+//! otherwise.  Contract checks are introduced by a contract annotation or propagated via merging.
+//! They ensure sane interaction between typed and untyped parts.
 use crate::{
     error::{EvalError, ParseError, ParseErrors, TypecheckError},
     identifier::Ident,
diff --git a/utilities/src/bench.rs b/utilities/src/bench.rs
index 73ffc5d8..46c2b8c3 100644
--- a/utilities/src/bench.rs
+++ b/utilities/src/bench.rs
@@ -121,9 +121,13 @@ pub fn bench_terms<'r>(rts: Vec<Bench<'r>>) -> Box<dyn Fn(&mut Criterion) + 'r>
                             c_local.typecheck(id, &type_ctxt).unwrap();
                         } else {
                             c_local.prepare(id, &type_ctxt).unwrap();
-                            VirtualMachine::new_with_cache(c_local, eval_cache.clone())
-                                .eval(t, &eval_env)
-                                .unwrap();
+                            VirtualMachine::new_with_cache(
+                                c_local,
+                                eval_cache.clone(),
+                                std::io::sink(),
+                            )
+                            .eval(t, &eval_env)
+                            .unwrap();
                         }
                     },
                     criterion::BatchSize::LargeInput,
@@ -197,7 +201,7 @@ macro_rules! ncl_bench_group {
                                 c_local.typecheck(id, &type_ctxt).unwrap();
                             } else {
                                 c_local.prepare(id, &type_ctxt).unwrap();
-                                VirtualMachine::new_with_cache(c_local, eval_cache.clone()).eval(t, &eval_env).unwrap();
+                                VirtualMachine::new_with_cache(c_local, eval_cache.clone(), std::io::sink()).eval(t, &eval_env).unwrap();
                             }
                         },
                         criterion::BatchSize::LargeInput,
diff --git a/utilities/src/lib.rs b/utilities/src/lib.rs
index 1925f5ac..75f11e08 100644
--- a/utilities/src/lib.rs
+++ b/utilities/src/lib.rs
@@ -1,3 +1,5 @@
 pub mod annotated_test;
 pub mod bench;
 pub mod test_program;
+
+pub use nickel_lang;


Test patch:
diff --git a/src/eval/tests.rs b/src/eval/tests.rs
index 27528fce..3a51bd9f 100644
--- a/src/eval/tests.rs
+++ b/src/eval/tests.rs
@@ -13,7 +13,7 @@ use codespan::Files;
 
 /// Evaluate a term without import support.
 fn eval_no_import(t: RichTerm) -> Result<Term, EvalError> {
-    VirtualMachine::<_, CacheImpl>::new(DummyResolver {})
+    VirtualMachine::<_, CacheImpl>::new(DummyResolver {}, std::io::sink())
         .eval(t, &Environment::new())
         .map(Term::from)
 }
@@ -117,7 +117,7 @@ fn asking_for_various_types() {
 
 #[test]
 fn imports() {
-    let mut vm = VirtualMachine::new(SimpleResolver::new());
+    let mut vm = VirtualMachine::new(SimpleResolver::new(), std::io::sink());
     vm.import_resolver_mut()
         .add_source(String::from("two"), String::from("1 + 1"));
     vm.import_resolver_mut()
@@ -266,7 +266,7 @@ fn initial_env() {
 
     let t = mk_term::let_in("x", mk_term::integer(2), mk_term::var("x"));
     assert_eq!(
-        VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone())
+        VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
             .eval(t, &initial_env)
             .map(RichTerm::without_pos),
         Ok(mk_term::integer(2))
@@ -274,7 +274,7 @@ fn initial_env() {
 
     let t = mk_term::let_in("x", mk_term::integer(2), mk_term::var("g"));
     assert_eq!(
-        VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone())
+        VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
             .eval(t, &initial_env)
             .map(RichTerm::without_pos),
         Ok(mk_term::integer(1))
@@ -283,7 +283,7 @@ fn initial_env() {
     // Shadowing of the initial environment
     let t = mk_term::let_in("g", mk_term::integer(2), mk_term::var("g"));
     assert_eq!(
-        VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone())
+        VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
             .eval(t, &initial_env)
             .map(RichTerm::without_pos),
         Ok(mk_term::integer(2))
diff --git a/tests/examples/main.rs b/tests/examples/main.rs
index aaade407..dc2fd1b4 100644
--- a/tests/examples/main.rs
+++ b/tests/examples/main.rs
@@ -12,7 +12,8 @@ fn check_example_file(path: &str) {
     let test: TestCase<Expectation> =
         read_annotated_test_case(path).expect("Failed to parse annotated program");
 
-    let mut p = TestProgram::new_from_file(path).expect("Failed to load program from file");
+    let mut p = TestProgram::new_from_file(path, std::io::stderr())
+        .expect("Failed to load program from file");
 
     match test.annotation {
         Expectation::Pass => {
diff --git a/tests/integration/main.rs b/tests/integration/main.rs
index a959a9d2..8a248614 100644
--- a/tests/integration/main.rs
+++ b/tests/integration/main.rs
@@ -43,8 +43,12 @@ fn run_test(test_case: TestCase<Test>, path: String) {
     let test = test_case.annotation.test;
 
     for _ in 0..repeat {
-        let p =
-            TestProgram::new_from_source(Cursor::new(program.clone()), path.as_str()).expect("");
+        let p = TestProgram::new_from_source(
+            Cursor::new(program.clone()),
+            path.as_str(),
+            std::io::stderr(),
+        )
+        .expect("");
         match test.clone() {
             Expectation::Error(expected_err) => {
                 let err = eval_strategy.eval_program_to_err(p);
diff --git a/tests/integration/query.rs b/tests/integration/query.rs
index 38a3b5c3..e38b4d0c 100644
--- a/tests/integration/query.rs
+++ b/tests/integration/query.rs
@@ -10,6 +10,7 @@ pub fn test_query_metadata_basic() {
     let mut program = TestProgram::new_from_source(
         "{val | doc \"Test basic\" = (1 + 1)}".as_bytes(),
         "regr_tests",
+        std::io::stderr(),
     )
     .unwrap();
     let result = program.query(Some(String::from("val"))).unwrap();
@@ -25,11 +26,11 @@ pub fn test_query_with_wildcard() {
     /// Checks whether `lhs` and `rhs` both evaluate to terms with the same static type
     #[track_caller]
     fn assert_types_eq(lhs: &str, rhs: &str, path: Option<String>) {
-        let term1 = TestProgram::new_from_source(lhs.as_bytes(), "regr_tests")
+        let term1 = TestProgram::new_from_source(lhs.as_bytes(), "regr_tests", std::io::stderr())
             .unwrap()
             .query(path.clone())
             .unwrap();
-        let term2 = TestProgram::new_from_source(rhs.as_bytes(), "regr_tests")
+        let term2 = TestProgram::new_from_source(rhs.as_bytes(), "regr_tests", std::io::stderr())
             .unwrap()
             .query(path)
             .unwrap();
@@ -75,7 +76,8 @@ pub fn test_query_with_wildcard() {
 
     // Without wildcard, the result has no type annotation
     let mut program =
-        TestProgram::new_from_source("{value = 10}".as_bytes(), "regr_tests").unwrap();
+        TestProgram::new_from_source("{value = 10}".as_bytes(), "regr_tests", std::io::stderr())
+            .unwrap();
     let result = program.query(path.clone()).unwrap();
     assert!(matches!(
         result,
diff --git a/tests/snapshot/inputs/eval/escaping.ncl b/tests/snapshot/inputs/eval/escaping.ncl
new file mode 100644
index 00000000..e67e7e3a
--- /dev/null
+++ b/tests/snapshot/inputs/eval/escaping.ncl
@@ -0,0 +1,3 @@
+# capture = 'stdout'
+# command = []
+"a\"bcd\""
diff --git a/tests/snapshot/inputs/eval/records.ncl b/tests/snapshot/inputs/eval/records.ncl
new file mode 100644
index 00000000..747a3aed
--- /dev/null
+++ b/tests/snapshot/inputs/eval/records.ncl
@@ -0,0 +1,3 @@
+# capture = 'stdout'
+# command = []
+{ a = true, b | Number = 6 * 7 }
diff --git a/tests/snapshot/snapshots/snapshot__error_stdout_escaping.ncl.snap b/tests/snapshot/snapshots/snapshot__error_stdout_escaping.ncl.snap
new file mode 100644
index 00000000..1ef52987
--- /dev/null
+++ b/tests/snapshot/snapshots/snapshot__error_stdout_escaping.ncl.snap
@@ -0,0 +1,6 @@
+---
+source: tests/snapshot/main.rs
+expression: out
+---
+"a\"bcd\""
+
diff --git a/tests/snapshot/snapshots/snapshot__error_stdout_records.ncl.snap b/tests/snapshot/snapshots/snapshot__error_stdout_records.ncl.snap
new file mode 100644
index 00000000..008f2a85
--- /dev/null
+++ b/tests/snapshot/snapshots/snapshot__error_stdout_records.ncl.snap
@@ -0,0 +1,6 @@
+---
+source: tests/snapshot/main.rs
+expression: out
+---
+{ a = true, b | Number = 42, }
+
diff --git a/utilities/src/test_program.rs b/utilities/src/test_program.rs
index b4dc4a7b..75f483c8 100644
--- a/utilities/src/test_program.rs
+++ b/utilities/src/test_program.rs
@@ -14,7 +14,8 @@ pub type TestProgram = Program<CacheImpl>;
 
 /// Create a program from a Nickel expression provided as a string.
 pub fn program_from_expr(s: impl std::string::ToString) -> Program<CacheImpl> {
-    Program::<CacheImpl>::new_from_source(Cursor::new(s.to_string()), "test").unwrap()
+    Program::<CacheImpl>::new_from_source(Cursor::new(s.to_string()), "test", std::io::stderr())
+        .unwrap()
 }
 
 pub fn eval(s: impl std::string::ToString) -> Result<Term, Error> {
@@ -41,6 +42,6 @@ pub fn typecheck_fixture(f: &str) -> Result<(), Error> {
 
 fn program_from_test_fixture(f: &str) -> Program<CacheImpl> {
     let path = format!("{}/../tests/integration/{}", env!("CARGO_MANIFEST_DIR"), f);
-    Program::new_from_file(&path)
+    Program::new_from_file(&path, std::io::stderr())
         .unwrap_or_else(|e| panic!("Could not create program from `{}`\n {}", path, e))
 }