Repository: tweag/nickel
Pull request #2096 (2024-11-18 04:34:02)

chore(deps): bump cross-spawn from 7.0.3 to 7.0.5 in /lsp/vscode-extension
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.5.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md">cross-spawn's changelog</a>.</em></p>
<blockquote>
<h3><a href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.4...v7.0.5">7.0.5</a> (2024-11-07)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>fix escaping bug introduced by backtracking (<a href="https://github.com/moxystudio/node-cross-spawn/commit/640d391fde65388548601d95abedccc12943374f">640d391</a>)</li>
</ul>
<h3><a href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.4">7.0.4</a> (2024-11-07)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>disable regexp backtracking (<a href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/160">#160</a>) (<a href="https://github.com/moxystudio/node-cross-spawn/commit/5ff3a07d9add449021d806e45c4168203aa833ff">5ff3a07</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/085268352dcbcad8064c64c5efb25268b4023184"><code>0852683</code></a> chore(release): 7.0.5</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/640d391fde65388548601d95abedccc12943374f"><code>640d391</code></a> fix: fix escaping bug introduced by backtracking</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/bff0c87c8b627c4e6d04ec2449e733048bebb464"><code>bff0c87</code></a> chore: remove codecov</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/a7c6abc6fee79641d45b452fe6217deaa1bd0973"><code>a7c6abc</code></a> chore: replace travis with github workflows</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/9b9246e0969e86656d7ccd527716bc3c18842a19"><code>9b9246e</code></a> chore(release): 7.0.4</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/5ff3a07d9add449021d806e45c4168203aa833ff"><code>5ff3a07</code></a> fix: disable regexp backtracking (<a href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/160">#160</a>)</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/9521e2da94d94998f948e0455903e62d87884600"><code>9521e2d</code></a> chore: fix tests in recent node js versions</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/97ded399e9c9ae325040fc52274c1cd4def357f8"><code>97ded39</code></a> chore: convert package lock</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/d52b6b9da499ca464e609162a6afeb326f1dbbb1"><code>d52b6b9</code></a> chore: remove unused argument (<a href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/156">#156</a>)</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/5d843849e1ed434b7030e0aa49281c2bf4ad2e71"><code>5d84384</code></a> chore: add travis jobs on ppc64le (<a href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/142">#142</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.5">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cross-spawn&package-manager=npm_and_yarn&previous-version=7.0.3&new-version=7.0.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/tweag/nickel/network/alerts).

</details>


Patch:
diff --git a/cli/src/completions.rs b/cli/src/completions.rs
index f741bae8..7008dda0 100644
--- a/cli/src/completions.rs
+++ b/cli/src/completions.rs
@@ -1,7 +1,4 @@
-use crate::{
-    cli::{GlobalOptions, Options},
-    error::CliResult,
-};
+use crate::{cli::Options, global::GlobalContext};
 
 #[derive(clap::Parser, Debug)]
 pub struct GenCompletionsCommand {
@@ -10,13 +7,12 @@ pub struct GenCompletionsCommand {
 }
 
 impl GenCompletionsCommand {
-    pub fn run(self, _: GlobalOptions) -> CliResult<()> {
+    pub fn run(self, _: &mut GlobalContext) {
         clap_complete::generate(
             self.shell,
             &mut <Options as clap::CommandFactory>::command(),
             env!("CARGO_BIN_NAME"),
             &mut std::io::stdout(),
         );
-        Ok(())
     }
 }
diff --git a/cli/src/customize/mod.rs b/cli/src/customize/mod.rs
index 0c06a47d..7b20cf72 100644
--- a/cli/src/customize/mod.rs
+++ b/cli/src/customize/mod.rs
@@ -18,7 +18,10 @@ use nickel_lang_core::{
     typ::{RecordRowF, RecordRowsIteratorItem, Type, TypeF},
 };
 
-use crate::error::{CliResult, CliUsageError, Error, UnknownFieldData};
+use crate::{
+    error::{CliResult, CliUsageError, Error, UnknownFieldData},
+    input::{PrepareError, PrepareResult},
+};
 
 pub mod interface;
 
@@ -233,7 +236,7 @@ impl CustomizableFields {
 
 pub trait Customize {
     /// Customize the program with the given options.
-    fn customize(&self, program: Program<CBNCache>) -> CliResult<Program<CBNCache>>;
+    fn customize(&self, program: Program<CBNCache>) -> PrepareResult<Program<CBNCache>>;
     /// Return the value of the `field` argument, if specified and if supported by the current
     /// customize variant.
     fn field(&self) -> Option<&String> {
@@ -274,7 +277,12 @@ impl CustomizeOptions {
 
         let assignment_overrides = match assignment_overrides {
             Ok(assignment_overrides) => assignment_overrides,
-            Err(error) => return CliResult::Err(Error::CliUsage { error, program }),
+            Err(error) => {
+                return CliResult::Err(Error::CliUsage {
+                    error,
+                    files: program.files(),
+                })
+            }
         };
 
         let force_overrides: Result<Vec<_>, CliUsageError> = self
@@ -300,7 +308,12 @@ impl CustomizeOptions {
 
         let force_overrides = match force_overrides {
             Ok(force_overrides) => force_overrides,
-            Err(error) => return CliResult::Err(Error::CliUsage { error, program }),
+            Err(error) => {
+                return CliResult::Err(Error::CliUsage {
+                    error,
+                    files: program.files(),
+                })
+            }
         };
 
         program.add_overrides(assignment_overrides.into_iter().chain(force_overrides));
@@ -311,25 +324,26 @@ impl CustomizeOptions {
 impl CustomizeMode {
     // Contains most of the actual implementation of the customizing logic for overriding, but
     // doesn't set the extracted field.
-    fn customize_impl(&self, mut program: Program<CBNCache>) -> CliResult<Program<CBNCache>> {
+    fn customize_impl(&self, mut program: Program<CBNCache>) -> PrepareResult<Program<CBNCache>> {
         if self.customize_mode.is_empty() {
             return Ok(program);
         }
 
-        let evaled = match program.eval_record_spine() {
-            Ok(evaled) => evaled,
-            // We need a `return` control-flow to be able to take `program` out
-            Err(error) => return CliResult::Err(Error::Program { error, program }),
-        };
+        let evaled = program
+            .eval_record_spine()
+            .map_err(|error| Error::Program {
+                error,
+                files: program.files(),
+            })?;
 
         let customizable_fields = CustomizableFields::new(TermInterface::from(evaled.as_ref()));
         let opts = CustomizeOptions::parse_from(self.customize_mode.iter());
 
         match opts.command {
-            None => opts.do_customize(customizable_fields, program),
+            None => Ok(opts.do_customize(customizable_fields, program)?),
             Some(CustomizeCommand::List(list_command)) => {
                 list_command.run(&customizable_fields)?;
-                Err(Error::CustomizeInfoPrinted)
+                Err(PrepareError::EarlyReturn)
             }
         }
     }
@@ -339,7 +353,7 @@ impl Customize for CustomizeMode {
     // XXX: we should give a nice error message when someone tries to evaluate some
     //      expression that has unset values, telling them they can set them using
     //      this method
-    fn customize(&self, program: Program<CBNCache>) -> CliResult<Program<CBNCache>> {
+    fn customize(&self, program: Program<CBNCache>) -> PrepareResult<Program<CBNCache>> {
         program_with_field(
             self.customize_impl(program)?,
             self.extract_field.field.clone(),
@@ -363,7 +377,7 @@ pub struct ExtractFieldOnly {
 }
 
 impl Customize for ExtractFieldOnly {
-    fn customize(&self, program: Program<CBNCache>) -> CliResult<Program<CBNCache>> {
+    fn customize(&self, program: Program<CBNCache>) -> PrepareResult<Program<CBNCache>> {
         program_with_field(program, self.field.clone())
     }
 
@@ -377,7 +391,7 @@ impl Customize for ExtractFieldOnly {
 pub struct NoCustomizeMode;
 
 impl Customize for NoCustomizeMode {
-    fn customize(&self, program: Program<CBNCache>) -> CliResult<Program<CBNCache>> {
+    fn customize(&self, program: Program<CBNCache>) -> PrepareResult<Program<CBNCache>> {
         Ok(program)
     }
 }
@@ -385,18 +399,15 @@ impl Customize for NoCustomizeMode {
 fn program_with_field(
     mut program: Program<CBNCache>,
     field: Option<String>,
-) -> CliResult<Program<CBNCache>> {
+) -> PrepareResult<Program<CBNCache>> {
     if let Some(field) = field {
-        match program.parse_field_path(field) {
-            Ok(field_path) => program.field = field_path,
-            Err(error) => {
-                return Err(Error::CliUsage {
-                    error: CliUsageError::FieldPathParseError { error },
-                    program,
-                });
-            }
-        }
-    };
+        program.field = program
+            .parse_field_path(field)
+            .map_err(|error| Error::CliUsage {
+                error: CliUsageError::FieldPathParseError { error },
+                files: program.files(),
+            })?;
+    }
 
     Ok(program)
 }
diff --git a/cli/src/doc.rs b/cli/src/doc.rs
index 60f4a988..5204ba04 100644
--- a/cli/src/doc.rs
+++ b/cli/src/doc.rs
@@ -10,12 +10,7 @@ use nickel_lang_core::{
     program::Program,
 };
 
-use crate::{
-    cli::GlobalOptions,
-    customize::ExtractFieldOnly,
-    error::{CliResult, ResultErrorExt},
-    input::{InputOptions, Prepare},
-};
+use crate::{customize::ExtractFieldOnly, global::GlobalContext, input::InputOptions};
 
 #[derive(Copy, Clone, Eq, PartialEq, Debug, Default, clap::ValueEnum)]
 pub enum DocFormat {
@@ -63,12 +58,11 @@ pub struct DocCommand {
 const DEFAULT_OUT_DIR: &str = ".nickel/doc/";
 
 impl DocCommand {
-    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = self.input.prepare(&global)?;
-        self.export_doc(&mut program).report_with_program(program)
+    pub fn run(self, ctxt: &mut GlobalContext) {
+        ctxt.with_program(&self.input, |prog| self.export_doc(prog));
     }
 
-    fn export_doc(self, program: &mut Program<CacheImpl>) -> Result<(), Error> {
+    fn export_doc(&self, program: &mut Program<CacheImpl>) -> Result<(), Error> {
         let doc = program.extract_doc()?;
 
         let (mut out, out_path): (Box<dyn std::io::Write>, Option<Cow<'_, str>>) = if self.stdout {
diff --git a/cli/src/error.rs b/cli/src/error.rs
index f2ec8e57..7642d639 100644
--- a/cli/src/error.rs
+++ b/cli/src/error.rs
@@ -2,12 +2,11 @@
 
 use nickel_lang_core::{
     error::{
-        report::{ColorOpt, ErrorFormat},
+        report::{report, ColorOpt, ErrorFormat},
         Diagnostic, IntoDiagnostics, ParseError,
     },
-    eval::cache::lazy::CBNCache,
     files::{FileId, Files},
-    program::{FieldOverride, FieldPath, Program},
+    program::{FieldOverride, FieldPath},
 };
 
 /// Data about an unknown field error.
@@ -34,7 +33,7 @@ pub enum CliUsageError {
 
 pub enum Error {
     Program {
-        program: Program<CBNCache>,
+        files: Files,
         error: nickel_lang_core::error::Error,
     },
     Io {
@@ -51,15 +50,9 @@ pub enum Error {
     /// An invalid invocation of the CLI that couldn't be caught by the simple parsing provided by
     /// clap.
     CliUsage {
-        program: Program<CBNCache>,
+        files: Files,
         error: CliUsageError,
     },
-    /// Not an actual failure but a special early return to indicate that information was printed
-    /// during the usage of the customize mode, because a subcommand such as `list`, `show`, etc.
-    /// was used, and thus no customized program can be returned.
-    ///
-    /// Upon receiving this error, the caller should simply exit without proceeding with evaluation.
-    CustomizeInfoPrinted,
     FailedTests,
 }
 
@@ -153,29 +146,75 @@ impl IntoDiagnostics for CliUsageError {
     }
 }
 
+#[derive(Clone, Debug)]
 /// Warning emitted by the CLI.
 pub enum Warning {
     /// The user queried a program without providing any path. In this case,
     /// querying won't show most information, and it's most probably not
     /// what the user wanted.
     EmptyQueryPath,
+
+    /// A warning caused by doing something with a `Program` (e.g. parsing,
+    /// evaluating, ...). The inner warning can reference source file locations,
+    /// so it needs to be packaged with a files collection.
+    Program {
+        files: Files,
+        warning: nickel_lang_core::error::Warning,
+    },
+}
+
+impl Eq for Warning {}
+
+// This impl only compares the content of the warnings and ignores the `Files`
+// database for obvious performance reasons. Since this is only used for
+// deduplicating warnings, the impact of a false answer is limited.
+impl PartialEq for Warning {
+    fn eq(&self, other: &Self) -> bool {
+        match (self, other) {
+            (Warning::EmptyQueryPath, Warning::EmptyQueryPath) => true,
+            (Warning::Program { warning: a, .. }, Warning::Program { warning: b, .. }) => a == b,
+            _ => false,
+        }
+    }
+}
+
+impl std::hash::Hash for Warning {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        std::mem::discriminant(self).hash(state);
+        match self {
+            Warning::Program { warning, .. } => warning.hash(state),
+            Warning::EmptyQueryPath => {}
+        }
+    }
 }
 
-impl IntoDiagnostics for Warning {
-    fn into_diagnostics(self, _files: &mut Files) -> Vec<Diagnostic<FileId>> {
-        vec![Diagnostic::warning()
-            .with_message("empty query path")
-            .with_notes(vec![
-                "You queried a value without requesting a specific field path. \
+impl Warning {
+    /// Report this warning on the standard error stream.
+    pub fn report(self, format: ErrorFormat, color: ColorOpt) {
+        use nickel_lang_core::error::report::report as core_report;
+        match self {
+            Warning::EmptyQueryPath => {
+                let mut files = Files::new();
+                let diag = Diagnostic::warning()
+                    .with_message("empty query path")
+                    .with_notes(vec![
+                        "You queried a value without requesting a specific field path. \
             This operation can't find any metadata, beside listing the fields of a record."
-                    .into(),
-                "Try to query the root configuration and provide a query path instead.".into(),
-                "For example, instead of querying the expression \
+                            .into(),
+                        "Try to query the root configuration and provide a query path instead."
+                            .into(),
+                        "For example, instead of querying the expression \
             `(import \"config.ncl\").module.input` with an empty path, query \
             `config.ncl` with the `module.input` path: \
             \n`nickel query config.ncl --field module.input"
-                    .into(),
-            ])]
+                            .into(),
+                    ]);
+                core_report(&mut files, diag, format, color);
+            }
+            Warning::Program { mut files, warning } => {
+                core_report(&mut files, warning, format, color)
+            }
+        }
     }
 }
 
@@ -201,61 +240,46 @@ impl From<nickel_lang_core::repl::InitError> for Error {
     }
 }
 
-pub trait ResultErrorExt<T> {
-    fn report_with_program(self, program: Program<CBNCache>) -> CliResult<T>;
-}
+// Report a standalone error which doesn't actually refer to any source code.
+//
+// Wrapping all errors in a diagnostic makes sure all errors are rendered using
+// the same format set (potentially by default) by the `--error-format` flag.
+// This also makes error styling more consistent.
+fn report_standalone(main_label: &str, msg: Option<String>, format: ErrorFormat, color: ColorOpt) {
+    let mut dummy_files = Files::new();
+    let diagnostic = Diagnostic::error()
+        .with_message(main_label)
+        .with_notes(msg.into_iter().collect());
 
-impl<T> ResultErrorExt<T> for Result<T, nickel_lang_core::error::Error> {
-    fn report_with_program(self, program: Program<CBNCache>) -> CliResult<T> {
-        self.map_err(|error| Error::Program { program, error })
-    }
+    report(&mut dummy_files, diagnostic, format, color);
 }
 
 impl Error {
     /// Report this error on the standard error stream.
     pub fn report(self, format: ErrorFormat, color: ColorOpt) {
-        // Report a standalone error which doesn't actually refer to any source code.
-        let report_standalone = |main_label: &str, msg: Option<String>| {
-            use nickel_lang_core::{
-                cache::{Cache, ErrorTolerance},
-                error::report::report as core_report,
-            };
-
-            let mut dummy_cache = Cache::new(ErrorTolerance::Tolerant);
-            let diagnostic = Diagnostic::error()
-                .with_message(main_label)
-                .with_notes(msg.into_iter().collect());
+        use nickel_lang_core::error::report::report as core_report;
 
-            core_report(&mut dummy_cache, diagnostic, format, color);
-        };
+        let report_str = |main_label: &str| report_standalone(main_label, None, format, color);
+        let report_with_msg =
+            |main_label: &str, msg: String| report_standalone(main_label, Some(msg), format, color);
 
-        // We try to fit every error in a diagnostic. This makes sure all errors are rendered using
-        // the same format set (potentitally by default) by the `--error-format` flag. This also
-        // makes error styling more consistent.
         match self {
-            Error::Program { mut program, error } => program.report(error, format),
-            Error::Io { error } => {
-                report_standalone("IO error", Some(error.to_string()));
-            }
+            Error::Program { mut files, error } => core_report(&mut files, error, format, color),
+            Error::Io { error } => report_with_msg("IO error", error.to_string()),
             #[cfg(feature = "repl")]
             Error::Repl { error } => {
                 use nickel_lang_core::repl::InitError;
                 match error {
-                    InitError::Stdlib => {
-                        report_standalone("failed to initialize the standard library", None)
-                    }
+                    InitError::Stdlib => report_str("failed to initialize the standard library"),
                     InitError::ReadlineError(msg) => {
-                        report_standalone("failed to initialize the terminal interface", Some(msg))
+                        report_with_msg("failed to initialize the terminal interface", msg)
                     }
                 }
             }
             #[cfg(feature = "format")]
-            Error::Format { error } => report_standalone("format error", Some(error.to_string())),
-            Error::CliUsage { error, mut program } => program.report(error, format),
-            Error::FailedTests => report_standalone("tests failed", None),
-            Error::CustomizeInfoPrinted => {
-                // Nothing to do, the caller should simply exit.
-            }
+            Error::Format { error } => report_with_msg("format error", error.to_string()),
+            Error::CliUsage { error, mut files } => core_report(&mut files, error, format, color),
+            Error::FailedTests => report_str("tests failed"),
         }
     }
 }
diff --git a/cli/src/eval.rs b/cli/src/eval.rs
index e0064cf7..6dd4a804 100644
--- a/cli/src/eval.rs
+++ b/cli/src/eval.rs
@@ -1,9 +1,4 @@
-use crate::{
-    cli::GlobalOptions,
-    customize::CustomizeMode,
-    error::{CliResult, ResultErrorExt},
-    input::{InputOptions, Prepare},
-};
+use crate::{customize::CustomizeMode, global::GlobalContext, input::InputOptions};
 
 #[derive(clap::Parser, Debug)]
 pub struct EvalCommand {
@@ -12,12 +7,9 @@ pub struct EvalCommand {
 }
 
 impl EvalCommand {
-    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = self.input.prepare(&global)?;
-
-        program
-            .eval_full()
-            .map(|t| println!("{t}"))
-            .report_with_program(program)
+    pub fn run(self, ctxt: &mut GlobalContext) {
+        ctxt.with_program(&self.input, |program| {
+            program.eval_full().map(|t| println!("{t}"))
+        });
     }
 }
diff --git a/cli/src/export.rs b/cli/src/export.rs
index 7d22e4b2..690dc011 100644
--- a/cli/src/export.rs
+++ b/cli/src/export.rs
@@ -7,12 +7,7 @@ use nickel_lang_core::{
     serialize::{self, ExportFormat},
 };
 
-use crate::{
-    cli::GlobalOptions,
-    customize::CustomizeMode,
-    error::{CliResult, ResultErrorExt},
-    input::{InputOptions, Prepare},
-};
+use crate::{customize::CustomizeMode, global::GlobalContext, input::InputOptions};
 
 #[derive(clap::Parser, Debug)]
 pub struct ExportCommand {
@@ -28,13 +23,11 @@ pub struct ExportCommand {
 }
 
 impl ExportCommand {
-    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = self.input.prepare(&global)?;
-
-        self.export(&mut program).report_with_program(program)
+    pub fn run(self, ctxt: &mut GlobalContext) {
+        ctxt.with_program(&self.input, |program| self.export(program));
     }
 
-    fn export(self, program: &mut Program<CBNCache>) -> Result<(), Error> {
+    fn export(&self, program: &mut Program<CBNCache>) -> Result<(), Error> {
         let rt = program.eval_full_for_export()?;
 
         // We only add a trailing newline for JSON exports. Both YAML and TOML
@@ -43,7 +36,7 @@ impl ExportCommand {
 
         serialize::validate(self.format, &rt)?;
 
-        if let Some(file) = self.output {
+        if let Some(file) = &self.output {
             let mut file = fs::File::create(file).map_err(IOError::from)?;
             serialize::to_writer(&mut file, self.format, &rt)?;
 
diff --git a/cli/src/format.rs b/cli/src/format.rs
index 84fff195..770c4075 100644
--- a/cli/src/format.rs
+++ b/cli/src/format.rs
@@ -8,7 +8,7 @@ use std::{
 use tempfile::NamedTempFile;
 
 use crate::{
-    cli::GlobalOptions, customize::NoCustomizeMode, error::CliResult, input::InputOptions,
+    customize::NoCustomizeMode, error::CliResult, global::GlobalContext, input::InputOptions,
 };
 
 #[derive(Debug)]
@@ -82,20 +82,24 @@ pub struct FormatCommand {
 }
 
 impl FormatCommand {
-    pub fn run(self, _global: GlobalOptions) -> CliResult<()> {
+    pub fn run(self, ctxt: &mut GlobalContext) {
         fn format(input: impl Read, mut output: Output) -> CliResult<()> {
             nickel_lang_core::format::format(input, &mut output)
                 .map_err(FormatError::FormatError)?;
             output.persist();
             Ok(())
         }
-        if self.input.files.is_empty() {
-            return format(stdin(), Output::Stdout);
+
+        fn format_path(path: &Path) -> CliResult<()> {
+            format(BufReader::new(File::open(path)?), Output::from_path(path)?)
         }
 
-        for file in self.input.files.iter() {
-            format(BufReader::new(File::open(file)?), Output::from_path(file)?)?;
+        if self.input.files.is_empty() {
+            ctxt.reporter.report_result(format(stdin(), Output::Stdout));
+        } else {
+            for file in self.input.files.iter() {
+                ctxt.reporter.report_result(format_path(file));
+            }
         }
-        Ok(())
     }
 }
diff --git a/cli/src/global.rs b/cli/src/global.rs
new file mode 100644
index 00000000..53b9827e
--- /dev/null
+++ b/cli/src/global.rs
@@ -0,0 +1,141 @@
+//! Global state for the nickel CLI.
+
+use std::{
+    collections::HashSet,
+    sync::mpsc::{channel, Receiver, Sender},
+};
+
+use nickel_lang_core::{
+    error::Error as CoreError, error::Reporter, eval::cache::lazy::CBNCache, files::Files,
+    program::Program,
+};
+
+use crate::{
+    cli::GlobalOptions,
+    error::{Error, Warning},
+    input::{Prepare, PrepareError},
+};
+
+/// Contains global state for the nickel CLI.
+pub struct GlobalContext {
+    /// The CLI arguments that are applicable to all commands.
+    pub opts: GlobalOptions,
+    /// Collects all errors that occur. Currently, there is at most one error but that
+    /// might change.
+    pub errors: Receiver<Error>,
+    /// Collects all emitted warnings.
+    pub warnings: Receiver<Warning>,
+    /// Contains the sending ends of the `errors` and `warnings` channels. This can be
+    /// cloned and passed to anything that needs to send errors.
+    pub reporter: CliReporter,
+}
+
+/// An implementation of [`nickel_lang_core::error::Reporter`] that makes its errors
+/// available on the [`GlobalContext`].
+///
+/// We use `mpsc` channels, not because we want to send errors across threads but so that
+/// we can decouple the lifetimes between the sending and receiving ends.
+#[derive(Clone)]
+pub struct CliReporter {
+    errors: Sender<Error>,
+    warnings: Sender<Warning>,
+}
+
+impl GlobalContext {
+    pub fn new(opts: GlobalOptions) -> Self {
+        let (errors_tx, errors) = channel();
+        let (warnings_tx, warnings) = channel();
+        GlobalContext {
+            opts,
+            errors,
+            warnings,
+            reporter: CliReporter {
+                errors: errors_tx,
+                warnings: warnings_tx,
+            },
+        }
+    }
+
+    /// Creates a program and applies a callback to it.
+    ///
+    /// The program is run with this global context as its warning collector, so
+    /// that all of the program's warnings will be available on the global context
+    /// after the program has finished.
+    ///
+    /// If the callback returns an error, that error will also be available on the
+    /// global context.
+    pub fn with_program<
+        T,
+        P: Prepare,
+        F: FnOnce(&mut Program<CBNCache>) -> Result<T, CoreError>,
+    >(
+        &mut self,
+        preparer: &P,
+        f: F,
+    ) -> Option<T> {
+        let result = match preparer.prepare(self) {
+            Ok(mut prog) => f(&mut prog).map_err(|error| {
+                Error::Program {
+                    error,
+                    files: prog.files(),
+                }
+                .into()
+            }),
+            Err(e) => Err(e),
+        };
+
+        match result {
+            Err(PrepareError::Error(error)) => {
+                self.reporter.report(error);
+                None
+            }
+            Err(PrepareError::EarlyReturn) => None,
+            Ok(x) => Some(x),
+        }
+    }
+
+    /// Drains all the warnings that have been received so far and deduplicates them.
+    pub fn deduplicated_warnings(&mut self) -> Vec<Warning> {
+        let mut seen: HashSet<Warning> = HashSet::new();
+        let mut ret = Vec::new();
+        for w in self.warnings.try_iter() {
+            if seen.insert(w.clone()) {
+                ret.push(w);
+            }
+        }
+        ret
+    }
+}
+
+impl CliReporter {
+    // This is just an alias for the trait method, but having an inherent impl
+    // makes type inference better because rust defaults to the inherit impl and
+    // doesn't get confused between `Warning` and `Error`.
+    pub fn report_result<T, E: Into<Error>>(&mut self, result: Result<T, E>) {
+        <Self as Reporter<Error>>::report_result(self, result)
+    }
+}
+
+impl Reporter<Error> for CliReporter {
+    fn report(&mut self, e: Error) {
+        // Ignore any errors, because if no one is listening for these diagnostics
+        // then they aren't needed.
+        let _ = self.errors.send(e);
+    }
+}
+
+impl Reporter<Warning> for CliReporter {
+    fn report(&mut self, w: Warning) {
+        // Ignore any errors, because if no one is listening for these diagnostics
+        // then they aren't needed.
+        let _ = self.warnings.send(w);
+    }
+}
+
+impl Reporter<(nickel_lang_core::error::Warning, Files)> for CliReporter {
+    fn report(&mut self, (warning, files): (nickel_lang_core::error::Warning, Files)) {
+        // Ignore any errors, because if no one is listening for these diagnostics
+        // then they aren't needed.
+        let _ = self.warnings.send(Warning::Program { warning, files });
+    }
+}
diff --git a/cli/src/input.rs b/cli/src/input.rs
index f02a9080..6033a833 100644
--- a/cli/src/input.rs
+++ b/cli/src/input.rs
@@ -2,7 +2,7 @@ use std::path::PathBuf;
 
 use nickel_lang_core::{eval::cache::lazy::CBNCache, program::Program};
 
-use crate::{cli::GlobalOptions, color_opt_from_clap, customize::Customize, error::CliResult};
+use crate::{customize::Customize, global::GlobalContext};
 
 #[derive(clap::Parser, Debug)]
 pub struct InputOptions<Customize: clap::Args> {
@@ -28,20 +28,38 @@ pub struct InputOptions<Customize: clap::Args> {
     pub customize_mode: Customize,
 }
 
+pub enum PrepareError {
+    /// Not a real error.
+    ///
+    /// Input preparation sometimes wants to print some info and then exit,
+    /// without proceeding to program evaluation. This error variant is used to
+    /// trigger the early return, but we don't print an error message or exit
+    /// with an error status.
+    EarlyReturn,
+    /// A real error.
+    Error(crate::error::Error),
+}
+
+impl<E: Into<crate::error::Error>> From<E> for PrepareError {
+    fn from(e: E) -> Self {
+        PrepareError::Error(e.into())
+    }
+}
+
+pub type PrepareResult<T> = Result<T, PrepareError>;
+
 pub trait Prepare {
-    fn prepare(&self, global: &GlobalOptions) -> CliResult<Program<CBNCache>>;
+    fn prepare(&self, ctx: &mut GlobalContext) -> PrepareResult<Program<CBNCache>>;
 }
 
 impl<C: clap::Args + Customize> Prepare for InputOptions<C> {
-    fn prepare(&self, global: &GlobalOptions) -> CliResult<Program<CBNCache>> {
+    fn prepare(&self, ctx: &mut GlobalContext) -> PrepareResult<Program<CBNCache>> {
         let mut program = match self.files.as_slice() {
-            [] => Program::new_from_stdin(std::io::stderr()),
-            [p] => Program::new_from_file(p, std::io::stderr()),
-            files => Program::new_from_files(files, std::io::stderr()),
+            [] => Program::new_from_stdin(std::io::stderr(), ctx.reporter.clone()),
+            [p] => Program::new_from_file(p, std::io::stderr(), ctx.reporter.clone()),
+            files => Program::new_from_files(files, std::io::stderr(), ctx.reporter.clone()),
         }?;
 
-        program.color_opt = color_opt_from_clap(global.color);
-
         program.add_import_paths(self.import_path.iter());
 
         if let Ok(nickel_path) = std::env::var("NICKEL_IMPORT_PATH") {
diff --git a/cli/src/main.rs b/cli/src/main.rs
index f2ddd9bd..319d9aba 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -17,6 +17,7 @@ mod customize;
 mod error;
 mod eval;
 mod export;
+mod global;
 mod input;
 mod pprint_ast;
 mod query;
@@ -24,6 +25,8 @@ mod typecheck;
 
 use std::process::ExitCode;
 
+use global::GlobalContext;
+
 use crate::cli::{Command, Options};
 
 fn main() -> ExitCode {
@@ -37,24 +40,26 @@ fn main() -> ExitCode {
     #[cfg(feature = "metrics")]
     let report_metrics = opts.global.metrics;
 
-    let result = match opts.command {
-        Command::Eval(eval) => eval.run(opts.global),
-        Command::PprintAst(pprint_ast) => pprint_ast.run(opts.global),
-        Command::Export(export) => export.run(opts.global),
-        Command::Query(query) => query.run(opts.global),
-        Command::Typecheck(typecheck) => typecheck.run(opts.global),
-        Command::GenCompletions(completions) => completions.run(opts.global),
+    let mut ctxt = GlobalContext::new(opts.global);
+
+    match opts.command {
+        Command::Eval(eval) => eval.run(&mut ctxt),
+        Command::PprintAst(pprint_ast) => pprint_ast.run(&mut ctxt),
+        Command::Export(export) => export.run(&mut ctxt),
+        Command::Query(query) => query.run(&mut ctxt),
+        Command::Typecheck(typecheck) => typecheck.run(&mut ctxt),
+        Command::GenCompletions(completions) => completions.run(&mut ctxt),
 
         #[cfg(feature = "repl")]
-        Command::Repl(repl) => repl.run(opts.global),
+        Command::Repl(repl) => repl.run(&mut ctxt),
 
         #[cfg(feature = "doc")]
-        Command::Doc(doc) => doc.run(opts.global),
+        Command::Doc(doc) => doc.run(&mut ctxt),
         #[cfg(feature = "doc")]
-        Command::Test(test) => test.run(opts.global),
+        Command::Test(test) => test.run(&mut ctxt),
 
         #[cfg(feature = "format")]
-        Command::Format(format) => format.run(opts.global),
+        Command::Format(format) => format.run(&mut ctxt),
     };
 
     #[cfg(feature = "metrics")]
@@ -62,15 +67,17 @@ fn main() -> ExitCode {
         metrics.report();
     }
 
-    match result {
-        // CustomizeInfoPrinted is used for early return, but it's not actually an error from the
-        // user's point of view.
-        Ok(()) | Err(error::Error::CustomizeInfoPrinted) => ExitCode::SUCCESS,
-        Err(error) => {
-            error.report(error_format, color_opt_from_clap(color));
-            ExitCode::FAILURE
-        }
+    for w in ctxt.deduplicated_warnings() {
+        w.report(error_format, color_opt_from_clap(color));
     }
+
+    let mut code = ExitCode::SUCCESS;
+    for e in ctxt.errors.try_iter() {
+        e.report(error_format, color_opt_from_clap(color));
+        code = ExitCode::FAILURE;
+    }
+
+    code
 }
 
 fn color_opt_from_clap(c: clap::ColorChoice) -> nickel_lang_core::error::report::ColorOpt {
diff --git a/cli/src/pprint_ast.rs b/cli/src/pprint_ast.rs
index cd940d6b..6517d0f9 100644
--- a/cli/src/pprint_ast.rs
+++ b/cli/src/pprint_ast.rs
@@ -1,9 +1,4 @@
-use crate::{
-    cli::GlobalOptions,
-    customize::NoCustomizeMode,
-    error::{CliResult, ResultErrorExt},
-    input::{InputOptions, Prepare},
-};
+use crate::{customize::NoCustomizeMode, global::GlobalContext, input::InputOptions};
 
 #[derive(clap::Parser, Debug)]
 pub struct PprintAstCommand {
@@ -16,10 +11,9 @@ pub struct PprintAstCommand {
 }
 
 impl PprintAstCommand {
-    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = self.inputs.prepare(&global)?;
-        program
-            .pprint_ast(&mut std::io::stdout(), self.transform)
-            .report_with_program(program)
+    pub fn run(self, ctxt: &mut GlobalContext) {
+        ctxt.with_program(&self.inputs, |program| {
+            program.pprint_ast(&mut std::io::stdout(), self.transform)
+        });
     }
 }
diff --git a/cli/src/query.rs b/cli/src/query.rs
index fdf28c75..1d95b3e5 100644
--- a/cli/src/query.rs
+++ b/cli/src/query.rs
@@ -1,9 +1,11 @@
 use std::{fs, io::Write, path::PathBuf};
 
 use nickel_lang_core::{
-    error::{Error, IOError},
+    error::{Error, IOError, Reporter as _},
+    eval::cache::lazy::CBNCache,
     identifier::{Ident, LocIdent},
     pretty::PrettyPrintCap,
+    program::Program,
     repl::query_print,
     serialize::{self, MetadataExportFormat},
     term::{record::Field, LabeledType, MergePriority, Term},
@@ -11,10 +13,10 @@ use nickel_lang_core::{
 use serde::Serialize;
 
 use crate::{
-    cli::GlobalOptions,
     customize::{Customize, ExtractFieldOnly},
-    error::{CliResult, ResultErrorExt, Warning},
-    input::{InputOptions, Prepare},
+    error::Warning,
+    global::GlobalContext,
+    input::InputOptions,
 };
 
 const VALUE_EXPORT_MAX_WIDTH: usize = 80;
@@ -127,7 +129,7 @@ impl QueryCommand {
         }
     }
 
-    fn export<T>(self, res: T, format: MetadataExportFormat) -> Result<(), Error>
+    fn export<T>(&self, res: T, format: MetadataExportFormat) -> Result<(), Error>
     where
         T: Serialize,
     {
@@ -137,7 +139,7 @@ impl QueryCommand {
         // exporters already append a trailing newline by default.
         let trailing_newline = format == MetadataExportFormat::Json;
 
-        if let Some(file) = self.output {
+        if let Some(file) = &self.output {
             let mut file = fs::File::create(file).map_err(IOError::from)?;
             serialize::to_writer_metadata(&mut file, format, &res)?;
 
@@ -155,30 +157,32 @@ impl QueryCommand {
         Ok(())
     }
 
-    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = self.inputs.prepare(&global)?;
-
+    pub fn run(self, ctxt: &mut GlobalContext) {
         if self.inputs.customize_mode.field().is_none() {
-            program.report(Warning::EmptyQueryPath, global.error_format);
+            ctxt.reporter.report(Warning::EmptyQueryPath);
         }
 
+        ctxt.with_program(&self.inputs, |program| self.output(program));
+    }
+
+    fn output(
+        &self,
+        program: &mut Program<CBNCache>,
+    ) -> Result<(), nickel_lang_core::error::Error> {
         use MetadataExportFormat::*;
         match self.format {
             Markdown => {
                 if self.output.is_some() {
                     eprintln!("Output query result in markdown format to a file is currently not supported.")
                 } else {
-                    let found = program
-                        .query()
-                        .map(|field| {
-                            query_print::write_query_result(
-                                &mut std::io::stdout(),
-                                &field,
-                                self.query_attributes(),
-                            )
-                            .unwrap()
-                        })
-                        .report_with_program(program)?;
+                    let found = program.query().map(|field| {
+                        query_print::write_query_result(
+                            &mut std::io::stdout(),
+                            &field,
+                            self.query_attributes(),
+                        )
+                        .unwrap()
+                    })?;
 
                     if !found {
                         eprintln!("No metadata found for this field.")
@@ -189,8 +193,7 @@ impl QueryCommand {
                 let _ = &program
                     .query()
                     .map(QueryResult::from)
-                    .map(|res| self.export(res, format))
-                    .report_with_program(program)?;
+                    .map(|res| self.export(res, format))?;
             }
         }
         Ok(())
diff --git a/cli/src/repl.rs b/cli/src/repl.rs
index 9f71ab62..d37a702c 100644
--- a/cli/src/repl.rs
+++ b/cli/src/repl.rs
@@ -3,7 +3,7 @@ use std::path::PathBuf;
 use directories::BaseDirs;
 use nickel_lang_core::repl::rustyline_frontend;
 
-use crate::{cli::GlobalOptions, color_opt_from_clap, error::CliResult};
+use crate::{color_opt_from_clap, global::GlobalContext};
 
 #[derive(clap::Parser, Debug)]
 pub struct ReplCommand {
@@ -12,7 +12,7 @@ pub struct ReplCommand {
 }
 
 impl ReplCommand {
-    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
+    pub fn run(self, ctxt: &mut GlobalContext) {
         let histfile = if let Some(h) = self.history_file {
             h
         } else {
@@ -21,9 +21,9 @@ impl ReplCommand {
                 .home_dir()
                 .join(".nickel_history")
         };
-        Ok(rustyline_frontend::repl(
+        ctxt.reporter.report_result(rustyline_frontend::repl(
             histfile,
-            color_opt_from_clap(global.color),
-        )?)
+            color_opt_from_clap(ctxt.opts.color),
+        ));
     }
 }
diff --git a/cli/src/typecheck.rs b/cli/src/typecheck.rs
index eed375da..17520966 100644
--- a/cli/src/typecheck.rs
+++ b/cli/src/typecheck.rs
@@ -1,11 +1,6 @@
 use nickel_lang_core::typecheck::TypecheckMode;
 
-use crate::{
-    cli::GlobalOptions,
-    customize::NoCustomizeMode,
-    error::{CliResult, ResultErrorExt},
-    input::{InputOptions, Prepare},
-};
+use crate::{customize::NoCustomizeMode, global::GlobalContext, input::InputOptions};
 
 #[derive(clap::Parser, Debug)]
 pub struct TypecheckCommand {
@@ -18,7 +13,7 @@ pub struct TypecheckCommand {
 }
 
 impl TypecheckCommand {
-    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
+    pub fn run(self, ctxt: &mut GlobalContext) {
         if self.strict_typechecking {
             // In strict mode we run *both* forms of typechecking, because in
             // fact neither one is more strict than the other. For example,
@@ -29,14 +24,8 @@ impl TypecheckCommand {
             // typechecking in "enforce" mode will succeed because it will infer
             // `x: Number`, while typechecking in walk mode will fail because it
             // will treat `x` as `Dyn` and then try to typecheck `x + 1`.
-            let mut program = self.inputs.prepare(&global)?;
-            program
-                .typecheck(TypecheckMode::Enforce)
-                .report_with_program(program)?;
+            ctxt.with_program(&self.inputs, |prog| prog.typecheck(TypecheckMode::Enforce));
         }
-        let mut program = self.inputs.prepare(&global)?;
-        program
-            .typecheck(TypecheckMode::Walk)
-            .report_with_program(program)
+        ctxt.with_program(&self.inputs, |prog| prog.typecheck(TypecheckMode::Walk));
     }
 }
diff --git a/core/src/cache.rs b/core/src/cache.rs
index 6c9818f2..1d179b0e 100644
--- a/core/src/cache.rs
+++ b/core/src/cache.rs
@@ -1430,6 +1430,9 @@ pub trait ImportResolver {
         pos: &TermPos,
     ) -> Result<(ResolvedTerm, FileId), ImportError>;
 
+    /// Return a reference to the file database.
+    fn files(&self) -> &Files;
+
     /// Get a resolved import from the term cache.
     fn get(&self, file_id: FileId) -> Option<RichTerm>;
     /// Return the (potentially normalized) file path corresponding to the ID of a resolved import.
@@ -1523,6 +1526,10 @@ impl ImportResolver for Cache {
         Ok((result, file_id))
     }
 
+    fn files(&self) -> &Files {
+        &self.files
+    }
+
     fn get(&self, file_id: FileId) -> Option<RichTerm> {
         self.terms
             .get(&file_id)
@@ -1649,6 +1656,10 @@ pub mod resolvers {
             panic!("cache::resolvers: dummy resolver should not have been invoked");
         }
 
+        fn files(&self) -> &Files {
+            panic!("cache::resolvers: dummy resolver should not have been invoked");
+        }
+
         fn get(&self, _file_id: FileId) -> Option<RichTerm> {
             panic!("cache::resolvers: dummy resolver should not have been invoked");
         }
@@ -1720,6 +1731,10 @@ pub mod resolvers {
             }
         }
 
+        fn files(&self) -> &Files {
+            &self.files
+        }
+
         fn get(&self, file_id: FileId) -> Option<RichTerm> {
             self.term_cache.get(&file_id).cloned()
         }
diff --git a/core/src/deserialize.rs b/core/src/deserialize.rs
index 05336ec1..75f74d5a 100644
--- a/core/src/deserialize.rs
+++ b/core/src/deserialize.rs
@@ -589,16 +589,23 @@ mod tests {
     use std::io::Cursor;
 
     use nickel_lang_utils::{
-        nickel_lang_core::{deserialize::RustDeserializationError, term::RichTerm},
+        nickel_lang_core::{
+            deserialize::RustDeserializationError, error::NullReporter, term::RichTerm,
+        },
         test_program::TestProgram,
     };
     use serde::Deserialize;
 
     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")
+        TestProgram::new_from_source(
+            Cursor::new(source),
+            "source",
+            std::io::stderr(),
+            NullReporter {},
+        )
+        .expect("program shouldn't fail")
+        .eval_full()
+        .expect("evaluation shouldn't fail")
     }
 
     #[test]
diff --git a/core/src/error/mod.rs b/core/src/error/mod.rs
index 8fff0ea6..bca92750 100644
--- a/core/src/error/mod.rs
+++ b/core/src/error/mod.rs
@@ -11,7 +11,6 @@ use lalrpop_util::ErrorRecovery;
 use malachite::num::conversion::traits::ToSci;
 
 use crate::{
-    cache::Cache,
     eval::callstack::CallStack,
     files::{FileId, Files},
     identifier::{Ident, LocIdent},
@@ -35,6 +34,62 @@ use crate::{
 
 pub mod report;
 pub mod suggest;
+pub mod warning;
+
+pub use warning::Warning;
+
+/// A `Reporter` is basically a callback function for reporting errors and/or warnings.
+///
+/// The error type `E` is a generic parameter, so the same object can be a `Reporter`
+/// of various different things.
+pub trait Reporter<E> {
+    /// Called when there is something (`e`) for the reporter to report.
+    fn report(&mut self, e: E);
+
+    /// A utility function for reporting error variants.
+    ///
+    /// When this is called with an `Ok(_)` it does nothing; when called with an `Err(e)`
+    /// it reports `e`.
+    fn report_result<T, E2>(&mut self, result: Result<T, E2>)
+    where
+        Self: Sized,
+        E2: Into<E>,
+    {
+        if let Err(e) = result {
+            self.report(e.into());
+        }
+    }
+}
+
+impl<E, R: Reporter<E>> Reporter<E> for &mut R {
+    fn report(&mut self, e: E) {
+        R::report(*self, e)
+    }
+}
+
+/// A [`Reporter`] that just collects errors.
+pub struct Sink<E> {
+    pub errors: Vec<E>,
+}
+
+impl<E> Default for Sink<E> {
+    fn default() -> Self {
+        Sink { errors: Vec::new() }
+    }
+}
+
+impl<E> Reporter<E> for Sink<E> {
+    fn report(&mut self, e: E) {
+        self.errors.push(e);
+    }
+}
+
+/// A [`Reporter`] that throws away all its errors.
+pub struct NullReporter {}
+
+impl<E> Reporter<E> for NullReporter {
+    fn report(&mut self, _e: E) {}
+}
 
 /// A general error occurring during either parsing or evaluation.
 #[derive(Debug, Clone, PartialEq)]
diff --git a/core/src/error/report.rs b/core/src/error/report.rs
index c2ea6445..d7817eae 100644
--- a/core/src/error/report.rs
+++ b/core/src/error/report.rs
@@ -1,4 +1,6 @@
 //! Error diagnostics reporting and serialization.
+use codespan_reporting::term::termcolor::{Ansi, NoColor};
+
 use super::*;
 
 /// Serializable wrapper type to export diagnostics with a top-level attribute.
@@ -48,7 +50,7 @@ fn colors_for_terminal(color_opt: ColorOpt, is_terminal: bool) -> ColorChoice {
 /// - `cache` is the file cache used during the evaluation, which is required by the reporting
 ///   infrastructure to point at specific locations and print snippets when needed.
 pub fn report<E: IntoDiagnostics>(
-    cache: &mut Cache,
+    files: &mut Files,
     error: E,
     format: ErrorFormat,
     color_opt: ColorOpt,
@@ -57,7 +59,7 @@ pub fn report<E: IntoDiagnostics>(
 
     report_with(
         &mut StandardStream::stderr(colors_for_terminal(color_opt, stderr().is_terminal())).lock(),
-        &mut cache.files().clone(),
+        files,
         error,
         format,
     )
@@ -70,7 +72,7 @@ pub fn report<E: IntoDiagnostics>(
 /// - `cache` is the file cache used during the evaluation, which is required by the reporting
 ///   infrastructure to point at specific locations and print snippets when needed.
 pub fn report_to_stdout<E: IntoDiagnostics>(
-    cache: &mut Cache,
+    files: &mut Files,
     error: E,
     format: ErrorFormat,
     color_opt: ColorOpt,
@@ -79,12 +81,34 @@ pub fn report_to_stdout<E: IntoDiagnostics>(
 
     report_with(
         &mut StandardStream::stdout(colors_for_terminal(color_opt, stdout().is_terminal())).lock(),
-        &mut cache.files().clone(),
+        files,
         error,
         format,
     )
 }
 
+/// Build an error report as a string and return it.
+pub fn report_as_str<E: IntoDiagnostics>(
+    files: &mut Files,
+    error: E,
+    color_opt: ColorOpt,
+) -> String {
+    let mut buffer = Vec::new();
+    let mut with_color;
+    let mut no_color;
+    let writer: &mut dyn WriteColor = if color_opt == colorchoice::ColorChoice::Never {
+        no_color = NoColor::new(&mut buffer);
+        &mut no_color
+    } else {
+        with_color = Ansi::new(&mut buffer);
+        &mut with_color
+    };
+
+    report_with(writer, files, error, ErrorFormat::Text);
+    // unwrap(): report_with() should only print valid utf8 to the the buffer
+    String::from_utf8(buffer).unwrap()
+}
+
 /// Report an error on `stderr`, provided a file database and a list of stdlib file ids.
 pub fn report_with<E: IntoDiagnostics>(
     writer: &mut dyn WriteColor,
diff --git a/core/src/error/warning.rs b/core/src/error/warning.rs
new file mode 100644
index 00000000..cb683230
--- /dev/null
+++ b/core/src/error/warning.rs
@@ -0,0 +1,40 @@
+use codespan_reporting::diagnostic::Diagnostic;
+
+use crate::error::{primary, secondary, IntoDiagnostics};
+use crate::files::{FileId, Files};
+use crate::position::TermPos;
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub enum Warning {
+    /// Applied a `fun label value => ...` (or match) as a contract directly,
+    /// instead of using the constract constructors in the standard library.
+    NakedFunctionContract {
+        /// The position of the function that was used as a contract.
+        func_pos: TermPos,
+        /// The position of the thing that the contract was applied to.
+        app_pos: TermPos,
+    },
+}
+
+impl IntoDiagnostics for Warning {
+    fn into_diagnostics(self, _files: &mut Files) -> Vec<Diagnostic<FileId>> {
+        match self {
+            Warning::NakedFunctionContract { func_pos, app_pos } => {
+                let mut labels = vec![];
+                if let Some(span) = app_pos.into_opt() {
+                    labels.push(primary(&span).with_message("applied to this term"));
+                }
+
+                if let Some(func_span) = func_pos.into_opt() {
+                    labels.push(secondary(&func_span).with_message("this function"));
+                }
+
+                vec![Diagnostic::warning()
+                    .with_message("plain functions as contracts are deprecated")
+                    .with_labels(labels)
+                    .with_notes(vec!["wrap this function using one of the constructors in `std.contract` instead, like `std.contract.from_validator` or `std.contract.custom`".to_owned()])
+                ]
+            }
+        }
+    }
+}
diff --git a/core/src/eval/mod.rs b/core/src/eval/mod.rs
index a4f329cd..eb5e2d04 100644
--- a/core/src/eval/mod.rs
+++ b/core/src/eval/mod.rs
@@ -77,8 +77,8 @@ use crate::{
     cache::{Cache as ImportCache, Envs, ImportResolver},
     closurize::{closurize_rec_record, Closurize},
     environment::Environment as GenericEnvironment,
-    error::{Error, EvalError},
-    files::FileId,
+    error::{warning::Warning, Error, EvalError, Reporter},
+    files::{FileId, Files},
     identifier::{Ident, LocIdent},
     match_sharedterm,
     metrics::{increment, measure_runtime},
@@ -130,10 +130,18 @@ pub struct VirtualMachine<R: ImportResolver, C: Cache> {
     initial_env: Environment,
     // The stream for writing trace output.
     trace: Box<dyn Write>,
+    /// A collector for warnings. Currently we only collect warnings and not errors; errors
+    /// terminate evaluation (or typechecking, or whatever) immediately, and so they just
+    /// get early-returned in a `Result`.
+    pub reporter: Box<dyn Reporter<(Warning, Files)>>,
 }
 
 impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
-    pub fn new(import_resolver: R, trace: impl Write + 'static) -> Self {
+    pub fn new(
+        import_resolver: R,
+        trace: impl Write + 'static,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
+    ) -> Self {
         VirtualMachine {
             import_resolver,
             call_stack: Default::default(),
@@ -141,10 +149,16 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             cache: Cache::new(),
             initial_env: Environment::new(),
             trace: Box::new(trace),
+            reporter: Box::new(reporter),
         }
     }
 
-    pub fn new_with_cache(import_resolver: R, cache: C, trace: impl Write + 'static) -> Self {
+    pub fn new_with_cache(
+        import_resolver: R,
+        cache: C,
+        trace: impl Write + 'static,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
+    ) -> Self {
         VirtualMachine {
             import_resolver,
             call_stack: Default::default(),
@@ -152,9 +166,25 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             cache,
             trace: Box::new(trace),
             initial_env: Environment::new(),
+            reporter: Box::new(reporter),
         }
     }
 
+    pub fn with_reporter(
+        self,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
+    ) -> VirtualMachine<R, C> {
+        VirtualMachine {
+            reporter: Box::new(reporter),
+            ..self
+        }
+    }
+
+    pub fn warn(&mut self, warning: Warning) {
+        self.reporter
+            .report((warning, self.import_resolver.files().clone()));
+    }
+
     /// Reset the state of the machine (stacks, eval mode and state of cached elements) to prepare
     /// for another evaluation round.
     pub fn reset(&mut self) {
@@ -959,7 +989,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
     /// Evaluate a term, but attempt to continue on errors.
     ///
-    /// This differs from `VirtualMachine::eval_full` in 2 ways:
+    /// This differs from `VirtualMachine::eval_full` in three ways:
     /// - We try to accumulate errors instead of bailing out. When recursing into record
     ///   fields and array elements, we keep evaluating subsequent elements even if one
     ///   fails.
diff --git a/core/src/eval/operation.rs b/core/src/eval/operation.rs
index c5a7b5f5..ce8c409e 100644
--- a/core/src/eval/operation.rs
+++ b/core/src/eval/operation.rs
@@ -18,7 +18,7 @@ use crate::nix_ffi;
 
 use crate::{
     closurize::Closurize,
-    error::{EvalError, IllegalPolymorphicTailAction},
+    error::{EvalError, IllegalPolymorphicTailAction, Warning},
     identifier::LocIdent,
     label::{ty_path, Polarity, TypeVarData},
     match_sharedterm,
@@ -26,8 +26,7 @@ use crate::{
     mk_app, mk_fun, mk_record,
     parser::utils::parse_number_sci,
     position::TermPos,
-    serialize,
-    serialize::ExportFormat,
+    serialize::{self, ExportFormat},
     stdlib::internals,
     term::{
         array::{Array, ArrayAttrs},
@@ -1859,6 +1858,17 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                 pos: pos1,
                             };
 
+                            // Warn on naked function contracts, but not if they came from the
+                            // stdlib. Some stdlib functions return naked function contracts.
+                            if let Some(pos) = pos1.as_opt_ref() {
+                                if !self.import_resolver.files().is_stdlib(pos.src_id) {
+                                    self.warn(Warning::NakedFunctionContract {
+                                        func_pos: pos1,
+                                        app_pos: pos_op,
+                                    });
+                                }
+                            }
+
                             if let BinaryOp::ContractApply = b_op {
                                 Closure {
                                     body: as_naked,
@@ -3893,6 +3903,7 @@ where
 mod tests {
     use super::*;
     use crate::cache::resolvers::DummyResolver;
+    use crate::error::NullReporter;
     use crate::eval::cache::CacheImpl;
     use crate::eval::Environment;
 
@@ -3900,7 +3911,7 @@ mod tests {
     fn ite_operation() {
         let cont: OperationCont = OperationCont::Op1(UnaryOp::IfThenElse, TermPos::None);
         let mut vm: VirtualMachine<DummyResolver, CacheImpl> =
-            VirtualMachine::new(DummyResolver {}, std::io::sink());
+            VirtualMachine::new(DummyResolver {}, std::io::sink(), NullReporter {});
 
         vm.stack
             .push_arg(Closure::atomic_closure(mk_term::integer(5)), TermPos::None);
@@ -3941,7 +3952,7 @@ mod tests {
             body: mk_term::integer(7),
             env: Environment::new(),
         };
-        let mut vm = VirtualMachine::new(DummyResolver {}, std::io::sink());
+        let mut vm = VirtualMachine::new(DummyResolver {}, std::io::sink(), NullReporter {});
         vm.stack.push_op_cont(cont, 0, TermPos::None);
 
         clos = vm.continuate_operation(clos).unwrap();
@@ -3986,7 +3997,7 @@ mod tests {
         );
 
         let mut vm: VirtualMachine<DummyResolver, CacheImpl> =
-            VirtualMachine::new(DummyResolver {}, std::io::sink());
+            VirtualMachine::new(DummyResolver {}, std::io::sink(), NullReporter {});
         let mut clos = Closure {
             body: mk_term::integer(6),
             env: Environment::new(),
diff --git a/core/src/files.rs b/core/src/files.rs
index be132327..a752d67a 100644
--- a/core/src/files.rs
+++ b/core/src/files.rs
@@ -117,7 +117,7 @@ impl Files {
         // This implementation would be a little nicer if `Vector` supported mutable access.
         // unwrap: we're allowed to panic if file_id is invalid
         let mut old = self.get(file_id).unwrap().clone();
-        old.source = source.into();
+        old = File::new(old.name, source);
         self.files.set(file_id.0 as usize, old);
     }
 
diff --git a/core/src/package.rs b/core/src/package.rs
index 53ab8043..06e2470a 100644
--- a/core/src/package.rs
+++ b/core/src/package.rs
@@ -60,7 +60,7 @@ mod tests {
     use std::io::Cursor;
 
     use super::*;
-    use crate::{eval::cache::CacheImpl, program::Program, term::Term};
+    use crate::{error::NullReporter, eval::cache::CacheImpl, program::Program, term::Term};
     use nickel_lang_utils::project_root::project_root;
 
     // Test basic package map functionality by building one manually out of
@@ -76,8 +76,13 @@ mod tests {
             packages: std::iter::once(((pkg1, Ident::new("dep")), pkg2)).collect(),
         };
 
-        let mut p: Program<CacheImpl> =
-            Program::new_from_source(Cursor::new("import pkg"), "<test>", std::io::sink()).unwrap();
+        let mut p: Program<CacheImpl> = Program::new_from_source(
+            Cursor::new("import pkg"),
+            "<test>",
+            std::io::sink(),
+            NullReporter {},
+        )
+        .unwrap();
         p.set_package_map(map);
 
         assert_eq!(p.eval_full().unwrap().as_ref(), &Term::Num(44.into()));
diff --git a/core/src/program.rs b/core/src/program.rs
index da006db5..d59ecf32 100644
--- a/core/src/program.rs
+++ b/core/src/program.rs
@@ -23,12 +23,9 @@
 use crate::{
     cache::*,
     closurize::Closurize as _,
-    error::{
-        report::{report, report_to_stdout, report_with, ColorOpt, ErrorFormat},
-        Error, EvalError, IOError, IntoDiagnostics, ParseError,
-    },
+    error::{warning::Warning, Error, EvalError, IOError, ParseError, Reporter},
     eval::{cache::Cache as EvalCache, Closure, VirtualMachine},
-    files::FileId,
+    files::{FileId, Files},
     identifier::LocIdent,
     label::Label,
     metrics::increment,
@@ -42,9 +39,6 @@ use crate::{
     typecheck::TypecheckMode,
 };
 
-use codespan_reporting::term::termcolor::{Ansi, NoColor, WriteColor};
-use colorchoice::ColorChoice;
-
 use std::{
     ffi::OsString,
     fmt,
@@ -181,8 +175,6 @@ pub struct Program<EC: EvalCache> {
     main_id: FileId,
     /// The state of the Nickel virtual machine.
     vm: VirtualMachine<Cache, EC>,
-    /// The color option to use when reporting errors.
-    pub color_opt: ColorOpt,
     /// A list of [`FieldOverride`]s. During [`prepare_eval`], each
     /// override is imported in a separate in-memory source, for complete isolation (this way,
     /// overrides can't accidentally or intentionally capture other fields of the configuration).
@@ -209,8 +201,11 @@ pub enum Input<T, S> {
 
 impl<EC: EvalCache> Program<EC> {
     /// Create a program by reading it from the standard input.
-    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_stdin(
+        trace: impl Write + 'static,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
+    ) -> std::io::Result<Self> {
+        Program::new_from_source(io::stdin(), "<stdin>", trace, reporter)
     }
 
     /// Contructor that abstracts over the Input type (file, string, etc.). Used by
@@ -218,6 +213,7 @@ impl<EC: EvalCache> Program<EC> {
     pub fn new_from_input<T, S>(
         input: Input<T, S>,
         trace: impl Write + 'static,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
     ) -> std::io::Result<Self>
     where
         T: Read,
@@ -234,11 +230,10 @@ impl<EC: EvalCache> Program<EC> {
             }
         };
 
-        let vm = VirtualMachine::new(cache, trace);
+        let vm = VirtualMachine::new(cache, trace, reporter);
         Ok(Self {
             main_id,
             vm,
-            color_opt: colorchoice::ColorChoice::Auto,
             overrides: Vec::new(),
             field: FieldPath::new(),
             contracts: Vec::new(),
@@ -248,7 +243,11 @@ impl<EC: EvalCache> Program<EC> {
     /// Constructor that abstracts over an iterator of Inputs (file, strings,
     /// etc). Published for those that need abstraction over the kind of Input
     /// or want to mix multiple different kinds of Input.
-    pub fn new_from_inputs<I, T, S>(inputs: I, trace: impl Write + 'static) -> std::io::Result<Self>
+    pub fn new_from_inputs<I, T, S>(
+        inputs: I,
+        trace: impl Write + 'static,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
+    ) -> std::io::Result<Self>
     where
         I: IntoIterator<Item = Input<T, S>>,
         T: Read,
@@ -283,12 +282,11 @@ impl<EC: EvalCache> Program<EC> {
             format!("{merge_term}"),
         );
 
-        let vm = VirtualMachine::new(cache, trace);
+        let vm = VirtualMachine::new(cache, trace, reporter);
 
         Ok(Self {
             main_id,
             vm,
-            color_opt: colorchoice::ColorChoice::Auto,
             overrides: Vec::new(),
             field: FieldPath::new(),
             contracts: Vec::new(),
@@ -298,7 +296,11 @@ impl<EC: EvalCache> Program<EC> {
     /// Create program from possibly multiple files. Each input `path` is
     /// turned into a [`Term::Import`] and the main program will be the
     /// [`BinaryOp::Merge`] of all the inputs.
-    pub fn new_from_files<I, P>(paths: I, trace: impl Write + 'static) -> std::io::Result<Self>
+    pub fn new_from_files<I, P>(
+        paths: I,
+        trace: impl Write + 'static,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
+    ) -> std::io::Result<Self>
     where
         I: IntoIterator<Item = P>,
         P: Into<OsString>,
@@ -308,16 +310,18 @@ impl<EC: EvalCache> Program<EC> {
         Self::new_from_inputs(
             paths.into_iter().map(Input::<std::fs::File, _>::Path),
             trace,
+            reporter,
         )
     }
 
     pub fn new_from_file(
         path: impl Into<OsString>,
         trace: impl Write + 'static,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
     ) -> std::io::Result<Self> {
         // The File type parameter is a dummy type and not used.
         // It just needed to be something that implements Read, and File seemed fitting.
-        Self::new_from_input(Input::<std::fs::File, _>::Path(path), trace)
+        Self::new_from_input(Input::<std::fs::File, _>::Path(path), trace, reporter)
     }
 
     /// Create a program by reading it from a generic source.
@@ -325,12 +329,13 @@ impl<EC: EvalCache> Program<EC> {
         source: T,
         source_name: S,
         trace: impl Write + 'static,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
     ) -> std::io::Result<Self>
     where
         T: Read,
         S: Into<OsString>,
     {
-        Self::new_from_input(Input::Source(source, source_name), trace)
+        Self::new_from_input(Input::Source(source, source_name), trace, reporter)
     }
 
     /// Create program from possibly multiple sources. The main program will be
@@ -338,6 +343,7 @@ impl<EC: EvalCache> Program<EC> {
     pub fn new_from_sources<I, T, S>(
         sources: I,
         trace: impl Write + 'static,
+        reporter: impl Reporter<(Warning, Files)> + 'static,
     ) -> std::io::Result<Self>
     where
         I: IntoIterator<Item = (T, S)>,
@@ -345,7 +351,7 @@ impl<EC: EvalCache> Program<EC> {
         S: Into<OsString>,
     {
         let inputs = sources.into_iter().map(|(s, n)| Input::Source(s, n));
-        Self::new_from_inputs(inputs, trace)
+        Self::new_from_inputs(inputs, trace, reporter)
     }
 
     /// Parse an assignment of the form `path.to_field=value` as an override, with the provided
@@ -576,45 +582,6 @@ impl<EC: EvalCache> Program<EC> {
         Ok(())
     }
 
-    /// Wrapper for [`report`].
-    pub fn report<E>(&mut self, error: E, format: ErrorFormat)
-    where
-        E: IntoDiagnostics,
-    {
-        report(self.vm.import_resolver_mut(), error, format, self.color_opt)
-    }
-
-    /// Wrapper for [`report_to_stdout`].
-    pub fn report_to_stdout<E>(&mut self, error: E, format: ErrorFormat)
-    where
-        E: IntoDiagnostics,
-    {
-        report_to_stdout(self.vm.import_resolver_mut(), error, format, self.color_opt)
-    }
-
-    /// Build an error report as a string and return it.
-    pub fn report_as_str<E>(&mut self, error: E) -> String
-    where
-        E: IntoDiagnostics,
-    {
-        let cache = self.vm.import_resolver_mut();
-
-        let mut buffer = Vec::new();
-        let mut with_color;
-        let mut no_color;
-        let writer: &mut dyn WriteColor = if self.color_opt == ColorChoice::Never {
-            no_color = NoColor::new(&mut buffer);
-            &mut no_color
-        } else {
-            with_color = Ansi::new(&mut buffer);
-            &mut with_color
-        };
-
-        report_with(writer, &mut cache.files().clone(), error, ErrorFormat::Text);
-        // unwrap(): report_with() should only print valid utf8 to the the buffer
-        String::from_utf8(buffer).unwrap()
-    }
-
     /// Evaluate a program into a record spine, a form suitable for extracting the general
     /// structure of a configuration, and in particular its interface (fields that might need to be
     /// filled).
@@ -877,6 +844,11 @@ impl<EC: EvalCache> Program<EC> {
 
         Ok(())
     }
+
+    /// Returns a copy of the program's current `Files` database. This doesn't actually clone the content of the source files, see [crate::files::Files].
+    pub fn files(&self) -> Files {
+        self.vm.import_resolver().files().clone()
+    }
 }
 
 #[cfg(feature = "doc")]
@@ -1169,7 +1141,7 @@ mod doc {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::error::EvalError;
+    use crate::error::{EvalError, NullReporter};
     use crate::eval::cache::CacheImpl;
     use crate::identifier::LocIdent;
     use crate::position::TermPos;
@@ -1180,26 +1152,30 @@ 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>", std::io::sink())
-            .map_err(|io_err| {
-                Error::EvalError(EvalError::Other(
-                    format!("IO error: {io_err}"),
-                    TermPos::None,
-                ))
-            })?;
+        let mut p: Program<CacheImpl> =
+            Program::new_from_source(src, "<test>", std::io::sink(), NullReporter {}).map_err(
+                |io_err| {
+                    Error::EvalError(EvalError::Other(
+                        format!("IO error: {io_err}"),
+                        TermPos::None,
+                    ))
+                },
+            )?;
         p.eval_full()
     }
 
     fn typecheck(s: &str) -> Result<(), Error> {
         let src = Cursor::new(s);
 
-        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,
-                ))
-            })?;
+        let mut p: Program<CacheImpl> =
+            Program::new_from_source(src, "<test>", std::io::sink(), NullReporter {}).map_err(
+                |io_err| {
+                    Error::EvalError(EvalError::Other(
+                        format!("IO error: {io_err}"),
+                        TermPos::None,
+                    ))
+                },
+            )?;
         p.typecheck(TypecheckMode::Walk)
     }
 
diff --git a/core/src/repl/mod.rs b/core/src/repl/mod.rs
index 6b3812a7..3789792d 100644
--- a/core/src/repl/mod.rs
+++ b/core/src/repl/mod.rs
@@ -7,6 +7,7 @@
 //! jupyter-kernel (which is not exactly user-facing, but still manages input/output and
 //! formatting), etc.
 use crate::cache::{Cache, Envs, ErrorTolerance, InputFormat, SourcePath};
+use crate::error::NullReporter;
 use crate::error::{
     report::{self, ColorOpt, ErrorFormat},
     Error, EvalError, IOError, IntoDiagnostics, ParseError, ParseErrors, ReplError,
@@ -98,7 +99,7 @@ impl<EC: EvalCache> ReplImpl<EC> {
             parser: grammar::ExtendedTermParser::new(),
             env: Envs::new(),
             initial_type_ctxt: typecheck::Context::new(),
-            vm: VirtualMachine::new(Cache::new(ErrorTolerance::Strict), trace),
+            vm: VirtualMachine::new(Cache::new(ErrorTolerance::Strict), trace, NullReporter {}),
         }
     }
 
@@ -217,8 +218,10 @@ impl<EC: EvalCache> ReplImpl<EC> {
         }
     }
 
+    #[cfg(feature = "repl")]
     fn report(&mut self, err: impl IntoDiagnostics, color_opt: ColorOpt) {
-        report::report(self.cache_mut(), err, ErrorFormat::Text, color_opt);
+        let mut files = self.cache_mut().files().clone();
+        report::report(&mut files, err, ErrorFormat::Text, color_opt);
     }
 }
 
diff --git a/core/src/serialize.rs b/core/src/serialize.rs
index d7553676..993100ff 100644
--- a/core/src/serialize.rs
+++ b/core/src/serialize.rs
@@ -585,6 +585,7 @@ pub mod toml_deser {
 mod tests {
     use super::*;
     use crate::cache::resolvers::DummyResolver;
+    use crate::error::NullReporter;
     use crate::eval::cache::CacheImpl;
     use crate::eval::VirtualMachine;
     use crate::program::Program;
@@ -594,8 +595,13 @@ mod tests {
 
     fn eval(s: &str) -> RichTerm {
         let src = Cursor::new(s);
-        let mut prog =
-            Program::<CacheImpl>::new_from_source(src, "<test>", std::io::stderr()).unwrap();
+        let mut prog = Program::<CacheImpl>::new_from_source(
+            src,
+            "<test>",
+            std::io::stderr(),
+            NullReporter {},
+        )
+        .unwrap();
         prog.eval_full().expect("program eval should succeed")
     }
 
@@ -610,9 +616,13 @@ mod tests {
     #[track_caller]
     fn assert_nickel_eq(term: RichTerm, expected: RichTerm) {
         assert_eq!(
-            VirtualMachine::<_, CacheImpl>::new(DummyResolver {}, std::io::stderr())
-                .eval(mk_term::op2(BinaryOp::Eq, term, expected))
-                .map(Term::from),
+            VirtualMachine::<_, CacheImpl>::new(
+                DummyResolver {},
+                std::io::stderr(),
+                NullReporter {}
+            )
+            .eval(mk_term::op2(BinaryOp::Eq, term, expected))
+            .map(Term::from),
             Ok(Term::Bool(true))
         )
     }
diff --git a/lsp/nls/src/background.rs b/lsp/nls/src/background.rs
index 389cdba4..59369eac 100644
--- a/lsp/nls/src/background.rs
+++ b/lsp/nls/src/background.rs
@@ -16,8 +16,8 @@ use nickel_lang_core::{
 use serde::{Deserialize, Serialize};
 
 use crate::{
-    cache::CacheExt as _, config, diagnostic::SerializableDiagnostic, files::uri_to_path,
-    world::World,
+    cache::CacheExt as _, config, diagnostic::SerializableDiagnostic, error::WarningReporter,
+    files::uri_to_path, world::World,
 };
 
 // Environment variable used to pass the recursion limit value to the child worker
@@ -101,14 +101,20 @@ pub fn worker_main() -> anyhow::Result<()> {
 
         // Evaluation diagnostics (but only if there were no parse/type errors).
         if diagnostics.is_empty() {
+            let (reporter, warnings) = WarningReporter::new();
             // TODO: avoid cloning the cache.
-            let mut vm =
-                VirtualMachine::<_, CacheImpl>::new(world.cache.clone(), std::io::stderr());
+            let mut vm = VirtualMachine::<_, CacheImpl>::new(
+                world.cache.clone(),
+                std::io::stderr(),
+                reporter,
+            );
             // We've already checked that parsing and typechecking are successful, so we
             // don't expect further errors.
             let rt = vm.prepare_eval(file_id).unwrap();
             let recursion_limit = std::env::var(RECURSION_LIMIT_ENV_VAR_NAME)?.parse::<usize>()?;
             let errors = vm.eval_permissive(rt, recursion_limit);
+            let mut files = vm.import_resolver().files().clone();
+
             diagnostics.extend(
                 errors
                     .into_iter()
@@ -118,8 +124,11 @@ pub fn worker_main() -> anyhow::Result<()> {
                             nickel_lang_core::error::EvalError::MissingFieldDef { .. }
                         )
                     })
-                    .flat_map(|e| world.lsp_diagnostics(file_id, e)),
+                    .flat_map(|e| SerializableDiagnostic::from(e, &mut files, file_id)),
             );
+            diagnostics.extend(warnings.try_iter().flat_map(|(warning, mut files)| {
+                SerializableDiagnostic::from(warning, &mut files, file_id)
+            }));
         }
 
         diagnostics.sort();
diff --git a/lsp/nls/src/command.rs b/lsp/nls/src/command.rs
index 7a0a9d19..56c0c291 100644
--- a/lsp/nls/src/command.rs
+++ b/lsp/nls/src/command.rs
@@ -2,7 +2,12 @@ use lsp_server::{RequestId, Response, ResponseError};
 use lsp_types::{ExecuteCommandParams, TextDocumentIdentifier, Url};
 use nickel_lang_core::eval::{cache::CacheImpl, VirtualMachine};
 
-use crate::{cache::CacheExt, error::Error, server::Server};
+use crate::{
+    cache::CacheExt,
+    diagnostic::SerializableDiagnostic,
+    error::{Error, WarningReporter},
+    server::Server,
+};
 
 pub fn handle_command(
     params: ExecuteCommandParams,
@@ -24,14 +29,33 @@ pub fn handle_command(
 
 fn eval(server: &mut Server, uri: &Url) -> Result<(), Error> {
     if let Some(file_id) = server.world.cache.file_id(uri)? {
+        let (reporter, warnings) = WarningReporter::new();
         // TODO: avoid cloning the cache. Maybe we can have a VM with a &mut Cache?
-        let mut vm =
-            VirtualMachine::<_, CacheImpl>::new(server.world.cache.clone(), std::io::stderr());
+        let mut vm = VirtualMachine::<_, CacheImpl>::new(
+            server.world.cache.clone(),
+            std::io::stderr(),
+            reporter,
+        );
         let rt = vm.prepare_eval(file_id)?;
-        if let Err(e) = vm.eval_full(rt) {
-            let diags = server.world.lsp_diagnostics(file_id, e);
-            server.issue_diagnostics(file_id, diags);
+
+        let result = vm.eval_full(rt);
+        // Get a possibly-updated files from the vm instead of relying on the one
+        // in `world`.
+        let mut files = vm.import_resolver().files().clone();
+
+        let mut diags: Vec<_> = warnings
+            .try_iter()
+            .flat_map(|(warning, mut files)| {
+                SerializableDiagnostic::from(warning, &mut files, file_id)
+            })
+            .collect();
+
+        if let Err(e) = result {
+            let mut error_diags = SerializableDiagnostic::from(e, &mut files, file_id);
+            diags.append(&mut error_diags);
         }
+
+        server.issue_diagnostics(file_id, diags);
     }
     Ok(())
 }
diff --git a/lsp/nls/src/diagnostic.rs b/lsp/nls/src/diagnostic.rs
index af0ccc84..e3d811b8 100644
--- a/lsp/nls/src/diagnostic.rs
+++ b/lsp/nls/src/diagnostic.rs
@@ -2,6 +2,7 @@ use std::ops::Range;
 
 use codespan_reporting::diagnostic::{self, Diagnostic, LabelStyle};
 use lsp_types::{DiagnosticRelatedInformation, NumberOrString};
+use nickel_lang_core::error::IntoDiagnostics;
 use nickel_lang_core::files::{FileId, Files};
 use nickel_lang_core::{error::UNKNOWN_SOURCE_NAME, position::RawSpan};
 use serde::{Deserialize, Serialize};
@@ -26,6 +27,23 @@ pub struct SerializableDiagnostic {
     pub related_information: Option<Vec<OrdDiagnosticRelatedInformation>>,
 }
 
+impl SerializableDiagnostic {
+    /// Converts a nickel error into a collection of diagnostic messages.
+    ///
+    /// `files` is the collection of all files that might be referred to by the diagnostics.
+    /// It's mutable because diagnostics may need to add temporary generated files; see
+    /// [`nickel_lang_core::error`] for more details.
+    ///
+    /// `current_file` is the file that we're currently reporting diagnostics for. (LSP reports
+    /// diagnostics per-file.)
+    pub fn from<E: IntoDiagnostics>(e: E, files: &mut Files, current_file: FileId) -> Vec<Self> {
+        e.into_diagnostics(files)
+            .into_iter()
+            .flat_map(|d| SerializableDiagnostic::from_codespan(current_file, d, files))
+            .collect()
+    }
+}
+
 #[derive(Debug, Eq, PartialEq, Copy, Clone, Default, Deserialize, Serialize)]
 pub struct OrdRange(pub lsp_types::Range);
 
diff --git a/lsp/nls/src/error.rs b/lsp/nls/src/error.rs
index d114310d..6dbeb067 100644
--- a/lsp/nls/src/error.rs
+++ b/lsp/nls/src/error.rs
@@ -1,5 +1,11 @@
+use std::sync::mpsc;
+
 use lsp_server::{ErrorCode, ResponseError};
 use lsp_types::{Position, Url};
+use nickel_lang_core::{
+    error::{Reporter, Warning},
+    files::Files,
+};
 
 /// Non-fatal errors generated by nls.
 #[derive(Debug, thiserror::Error)]
@@ -56,3 +62,25 @@ impl From<Error> for ResponseError {
         }
     }
 }
+
+/// An implementation of [`nickel_lang_core::error::Reporter`] that sends warnings
+/// across a channel.
+#[derive(Clone)]
+pub struct WarningReporter {
+    warnings: mpsc::Sender<(Warning, Files)>,
+}
+
+impl WarningReporter {
+    /// Create both a `WarningReporter` and a `mpsc::Receiver` that receives the
+    /// reported warnings.
+    pub fn new() -> (Self, mpsc::Receiver<(Warning, Files)>) {
+        let (warnings, warnings_rx) = mpsc::channel();
+        (WarningReporter { warnings }, warnings_rx)
+    }
+}
+
+impl Reporter<(Warning, Files)> for WarningReporter {
+    fn report(&mut self, e: (Warning, Files)) {
+        let _ = self.warnings.send(e);
+    }
+}
diff --git a/lsp/nls/src/world.rs b/lsp/nls/src/world.rs
index 30646547..f90c98cb 100644
--- a/lsp/nls/src/world.rs
+++ b/lsp/nls/src/world.rs
@@ -19,7 +19,7 @@ use nickel_lang_core::{
 use crate::{
     analysis::{Analysis, AnalysisRegistry},
     cache::CacheExt as _,
-    diagnostic::{DiagnosticCompat, SerializableDiagnostic},
+    diagnostic::SerializableDiagnostic,
     field_walker::{Def, FieldResolver},
     files::uri_to_path,
     identifier::LocIdent,
@@ -138,11 +138,7 @@ impl World {
         file_id: FileId,
         err: impl IntoDiagnostics,
     ) -> Vec<SerializableDiagnostic> {
-        let mut files = self.cache.files().clone();
-        err.into_diagnostics(&mut files)
-            .into_iter()
-            .flat_map(|d| SerializableDiagnostic::from_codespan(file_id, d, &files))
-            .collect()
+        SerializableDiagnostic::from(err, &mut self.cache.files().clone(), file_id)
     }
 
     // Make a record of I/O errors in imports so that we can retry them when appropriate.
diff --git a/lsp/vscode-extension/yarn.lock b/lsp/vscode-extension/yarn.lock
index da0eacae..d4f3b3b5 100644
--- a/lsp/vscode-extension/yarn.lock
+++ b/lsp/vscode-extension/yarn.lock
@@ -653,9 +653,9 @@ core-util-is@~1.0.0:
   integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
 
 cross-spawn@^7.0.2:
-  version "7.0.3"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
-  integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
+  version "7.0.5"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.5.tgz#910aac880ff5243da96b728bc6521a5f6c2f2f82"
+  integrity sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==
   dependencies:
     path-key "^3.1.0"
     shebang-command "^2.0.0"
diff --git a/pyckel/src/lib.rs b/pyckel/src/lib.rs
index de85ea17..949417b6 100644
--- a/pyckel/src/lib.rs
+++ b/pyckel/src/lib.rs
@@ -1,7 +1,10 @@
 use std::io::Cursor;
 
 use nickel_lang_core::{
-    error::Error,
+    error::{
+        report::{report_as_str, ColorOpt},
+        Error, NullReporter,
+    },
     eval::cache::{Cache, CacheImpl},
     program::Program,
     serialize,
@@ -13,14 +16,18 @@ create_exception!(pyckel, NickelException, PyException);
 
 /// Turn an internal Nickel error into a PyErr with a fancy diagnostic message
 fn error_to_exception<E: Into<Error>, EC: Cache>(error: E, program: &mut Program<EC>) -> PyErr {
-    NickelException::new_err(program.report_as_str(error.into()))
+    NickelException::new_err(report_as_str(
+        &mut program.files(),
+        error.into(),
+        ColorOpt::default(),
+    ))
 }
 
 /// 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", std::io::sink())?;
+        Program::new_from_source(Cursor::new(s), "python", std::io::sink(), NullReporter {})?;
 
     let term = program
         .eval_full()
diff --git a/utils/src/bench.rs b/utils/src/bench.rs
index 71fec8a6..f53c85c4 100644
--- a/utils/src/bench.rs
+++ b/utils/src/bench.rs
@@ -1,6 +1,7 @@
 use criterion::Criterion;
 use nickel_lang_core::{
     cache::{Cache, Envs, ErrorTolerance, InputFormat},
+    error::NullReporter,
     eval::{
         cache::{Cache as EvalCache, CacheImpl},
         VirtualMachine,
@@ -131,6 +132,7 @@ pub fn bench_terms<'r>(rts: Vec<Bench<'r>>) -> Box<dyn Fn(&mut Criterion) + 'r>
                                 c_local,
                                 eval_cache.clone(),
                                 std::io::sink(),
+                                NullReporter {},
                             )
                             .with_initial_env(eval_env.clone())
                             .eval(t)
@@ -225,13 +227,14 @@ macro_rules! ncl_bench_group {
                                 let mut vm = VirtualMachine::new_with_cache(
                                     c_local,
                                     eval_cache.clone(),
-                                    std::io::sink()
+                                    std::io::sink(),
+                                    nickel_lang_core::error::NullReporter {},
                                 )
                                 .with_initial_env(eval_env.clone());
 
                                 if let Err(e) = vm.eval(t) {
                                     report(
-                                        vm.import_resolver_mut(),
+                                        &mut vm.import_resolver_mut().files().clone(),
                                         e,
                                         ErrorFormat::Text,
                                         ColorOpt::default(),


Test patch:
diff --git a/cli/src/doctest.rs b/cli/src/doctest.rs
index 7d86cb4f..d6c2be37 100644
--- a/cli/src/doctest.rs
+++ b/cli/src/doctest.rs
@@ -7,8 +7,14 @@ use std::{collections::HashMap, io::Write as _, path::PathBuf, rc::Rc};
 use comrak::{arena_tree::NodeEdge, nodes::AstNode, Arena, ComrakOptions};
 use nickel_lang_core::{
     cache::{Cache, ImportResolver, InputFormat, SourcePath},
-    error::{Error as CoreError, EvalError},
-    eval::{cache::CacheImpl, Closure, Environment},
+    error::{
+        report::{report_as_str, report_to_stdout, ColorOpt},
+        Error as CoreError, EvalError, Reporter as _,
+    },
+    eval::{
+        cache::{lazy::CBNCache, CacheImpl},
+        Closure, Environment,
+    },
     identifier::{Ident, LocIdent},
     label::Label,
     match_sharedterm, mk_app, mk_fun,
@@ -24,10 +30,7 @@ use once_cell::sync::Lazy;
 use regex::Regex;
 
 use crate::{
-    cli::GlobalOptions,
-    customize::ExtractFieldOnly,
-    error::CliResult,
-    input::{InputOptions, Prepare},
+    color_opt_from_clap, customize::ExtractFieldOnly, global::GlobalContext, input::InputOptions,
 };
 
 #[derive(clap::Parser, Debug)]
@@ -163,6 +166,7 @@ fn run_tests(
     errors: &mut Vec<Error>,
     registry: &TestRegistry,
     spine: &RichTerm,
+    color: ColorOpt,
 ) {
     match spine.as_ref() {
         Term::Record(data) | Term::RecRecord(data, ..) => {
@@ -194,7 +198,7 @@ fn run_tests(
                         }
                         Err(e) => {
                             if let Some(expected) = &entry.expected_error {
-                                let message = prog.report_as_str(e);
+                                let message = report_as_str(&mut prog.files(), e, color);
                                 if !message.contains(expected) {
                                     Some(ErrorKind::WrongTestFailure {
                                         message,
@@ -221,7 +225,7 @@ fn run_tests(
                     path.pop();
                 } else if let Some(val) = field.value.as_ref() {
                     path.push(*id);
-                    run_tests(path, prog, errors, registry, val);
+                    run_tests(path, prog, errors, registry, val, color);
                     path.pop();
                 }
             }
@@ -231,17 +235,29 @@ fn run_tests(
 }
 
 impl TestCommand {
-    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = self.input.prepare(&global)?;
+    pub fn run(self, ctxt: &mut GlobalContext) {
+        let color: ColorOpt = color_opt_from_clap(ctxt.opts.color);
+        let num_errors = ctxt
+            .with_program(&self.input, |prog| self.run_tests(prog, color))
+            .unwrap_or(0);
 
-        let (spine, registry) = match self.prepare_tests(&mut program) {
-            Ok(x) => x,
-            Err(error) => return Err(crate::error::Error::Program { program, error }),
-        };
+        if num_errors > 0 {
+            eprintln!("{num_errors} failures");
+            ctxt.reporter.report(crate::error::Error::FailedTests);
+        }
+    }
+
+    /// Returns the number of test failures.
+    fn run_tests(
+        &self,
+        program: &mut Program<CBNCache>,
+        color: ColorOpt,
+    ) -> Result<usize, CoreError> {
+        let (spine, registry) = self.prepare_tests(program)?;
 
         let mut path = Vec::new();
         let mut errors = Vec::new();
-        run_tests(&mut path, &mut program, &mut errors, &registry, &spine);
+        run_tests(&mut path, program, &mut errors, &registry, &spine, color);
 
         let num_errors = errors.len();
         for e in errors {
@@ -262,24 +278,21 @@ impl TestCommand {
                 }
                 ErrorKind::UnexpectedFailure { error } => {
                     println!("test {}/{} failed", path_display, e.idx);
-                    program.report_to_stdout(
+                    report_to_stdout(
+                        &mut program.files(),
                         error,
                         nickel_lang_core::error::report::ErrorFormat::Text,
+                        color,
                     );
                 }
             }
         }
 
-        if num_errors > 0 {
-            eprintln!("{num_errors} failures");
-            Err(crate::error::Error::FailedTests)
-        } else {
-            Ok(())
-        }
+        Ok(num_errors)
     }
 
     fn prepare_tests(
-        self,
+        &self,
         program: &mut Program<CacheImpl>,
     ) -> Result<(RichTerm, TestRegistry), CoreError> {
         let mut registry = TestRegistry::default();
diff --git a/cli/tests/snapshot/inputs/warnings/naked_contract.ncl b/cli/tests/snapshot/inputs/warnings/naked_contract.ncl
new file mode 100644
index 00000000..5c130bd0
--- /dev/null
+++ b/cli/tests/snapshot/inputs/warnings/naked_contract.ncl
@@ -0,0 +1,7 @@
+# capture = 'stderr'
+# command = ['eval']
+let C = fun label value => value in
+[
+  1 | C,
+  [1, 2] | Array C
+]
diff --git a/cli/tests/snapshot/inputs/warnings/naked_contract_parametrized.ncl b/cli/tests/snapshot/inputs/warnings/naked_contract_parametrized.ncl
new file mode 100644
index 00000000..55638f8a
--- /dev/null
+++ b/cli/tests/snapshot/inputs/warnings/naked_contract_parametrized.ncl
@@ -0,0 +1,7 @@
+# capture = 'stderr'
+# command = ['eval']
+let C = fun arg label value => value in
+[
+  1 | C "hi",
+  [1, 2] | Array (C "hi")
+]
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_contract_with_custom_diagnostic.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_contract_with_custom_diagnostic.ncl.snap
index 4bfe2a6d..1a82dda1 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_contract_with_custom_diagnostic.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_contract_with_custom_diagnostic.ncl.snap
@@ -2,6 +2,24 @@
 source: cli/tests/snapshot/main.rs
 expression: err
 ---
+warning: plain functions as contracts are deprecated
+   ┌─ [INPUTS_PATH]/errors/contract_with_custom_diagnostic.ncl:13:1
+   │  
+ 3 │   let Contract = fun label _value =>
+   │ ╭────────────────'
+ 4 │ │   label
+ 5 │ │   |> std.contract.label.with_message "main error message"
+ 6 │ │   |> std.contract.label.with_notes [
+   · │
+ 9 │ │     ]
+10 │ │   |> std.contract.blame
+   │ ╰───────────────────────' this function
+   · │
+13 │   null | Contract
+   │   ^^^^ applied to this term
+   │  
+   = wrap this function using one of the constructors in `std.contract` instead, like `std.contract.from_validator` or `std.contract.custom`
+
 error: contract broken by a value
        main error message
    ┌─ [INPUTS_PATH]/errors/contract_with_custom_diagnostic.ncl:13:1
@@ -13,5 +31,3 @@ error: contract broken by a value
    │
    = This is the first note
    = This is the second note
-
-
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_naked_contract.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_naked_contract.ncl.snap
new file mode 100644
index 00000000..31543fbb
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_naked_contract.ncl.snap
@@ -0,0 +1,25 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: err
+---
+warning: plain functions as contracts are deprecated
+  ┌─ [INPUTS_PATH]/warnings/naked_contract.ncl:6:3
+  │
+3 │ let C = fun label value => value in
+  │         ------------------------ this function
+  ·
+6 │   [1, 2] | Array C
+  │   ^^^^^^ applied to this term
+  │
+  = wrap this function using one of the constructors in `std.contract` instead, like `std.contract.from_validator` or `std.contract.custom`
+
+warning: plain functions as contracts are deprecated
+  ┌─ [INPUTS_PATH]/warnings/naked_contract.ncl:5:3
+  │
+3 │ let C = fun label value => value in
+  │         ------------------------ this function
+4 │ [
+5 │   1 | C,
+  │   ^ applied to this term
+  │
+  = wrap this function using one of the constructors in `std.contract` instead, like `std.contract.from_validator` or `std.contract.custom`
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_naked_contract_parametrized.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_naked_contract_parametrized.ncl.snap
new file mode 100644
index 00000000..d212c9e1
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_naked_contract_parametrized.ncl.snap
@@ -0,0 +1,25 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: err
+---
+warning: plain functions as contracts are deprecated
+  ┌─ [INPUTS_PATH]/warnings/naked_contract_parametrized.ncl:6:3
+  │
+3 │ let C = fun arg label value => value in
+  │         ---------------------------- this function
+  ·
+6 │   [1, 2] | Array (C "hi")
+  │   ^^^^^^ applied to this term
+  │
+  = wrap this function using one of the constructors in `std.contract` instead, like `std.contract.from_validator` or `std.contract.custom`
+
+warning: plain functions as contracts are deprecated
+  ┌─ [INPUTS_PATH]/warnings/naked_contract_parametrized.ncl:5:3
+  │
+3 │ let C = fun arg label value => value in
+  │         ---------------------------- this function
+4 │ [
+5 │   1 | C "hi",
+  │   ^ applied to this term
+  │
+  = wrap this function using one of the constructors in `std.contract` instead, like `std.contract.from_validator` or `std.contract.custom`
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_subcontract_nested_custom_diagnostics.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_subcontract_nested_custom_diagnostics.ncl.snap
index 95fdf8d3..7b26d4ab 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_subcontract_nested_custom_diagnostics.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_subcontract_nested_custom_diagnostics.ncl.snap
@@ -2,6 +2,42 @@
 source: cli/tests/snapshot/main.rs
 expression: err
 ---
+warning: plain functions as contracts are deprecated
+   ┌─ [INPUTS_PATH]/errors/subcontract_nested_custom_diagnostics.ncl:19:1
+   │  
+10 │   let ParentContract = fun label value =>
+   │ ╭──────────────────────'
+11 │ │   let label =
+12 │ │     label
+13 │ │     |> std.contract.label.with_message "parent's message"
+14 │ │     |> std.contract.label.append_note "parent's note"
+15 │ │   in
+16 │ │   std.contract.apply ChildContract label value
+   │ ╰──────────────────────────────────────────────' this function
+   · │
+19 │   null | ParentContract
+   │   ^^^^ applied to this term
+   │  
+   = wrap this function using one of the constructors in `std.contract` instead, like `std.contract.from_validator` or `std.contract.custom`
+
+warning: plain functions as contracts are deprecated
+     ┌─ <stdlib/std.ncl>:1549:9
+     │
+1549 │         %contract/apply% contract (%label/push_diag% label) value,
+     │         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ applied to this term
+     │
+     ┌─ [INPUTS_PATH]/errors/subcontract_nested_custom_diagnostics.ncl:3:21
+     │  
+   3 │   let ChildContract = fun label value =>
+     │ ╭─────────────────────'
+   4 │ │   label
+   5 │ │   |> std.contract.label.with_message "child's message"
+   6 │ │   |> std.contract.label.append_note "child's note"
+   7 │ │   |> std.contract.blame
+     │ ╰───────────────────────' this function
+     │  
+     = wrap this function using one of the constructors in `std.contract` instead, like `std.contract.from_validator` or `std.contract.custom`
+
 error: contract broken by a value
        child's message
    ┌─ [INPUTS_PATH]/errors/subcontract_nested_custom_diagnostics.ncl:19:8
@@ -18,5 +54,3 @@ error: contract broken by a value
 
 note: from a parent contract violation: parent's message
  = parent's note
-
-
diff --git a/cli/tests/snapshot/snapshots/snapshot__export_stderr_non_serializable_print_path.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__export_stderr_non_serializable_print_path.ncl.snap
index 24433abb..0957b1d5 100644
--- a/cli/tests/snapshot/snapshots/snapshot__export_stderr_non_serializable_print_path.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__export_stderr_non_serializable_print_path.ncl.snap
@@ -2,6 +2,17 @@
 source: cli/tests/snapshot/main.rs
 expression: err
 ---
+warning: plain functions as contracts are deprecated
+   ┌─ [INPUTS_PATH]/errors/non_serializable_print_path.ncl:17:63
+   │
+ 8 │ let SomeParametricContract = fun parameter label value => value
+   │                              ---------------------------------- this function
+   ·
+17 │           inner = { qux_miss_param | SomeParametricContract = {} },
+   │                                                               ^^ applied to this term
+   │
+   = wrap this function using one of the constructors in `std.contract` instead, like `std.contract.from_validator` or `std.contract.custom`
+
 error: non serializable term
   ┌─ [INPUTS_PATH]/errors/non_serializable_print_path.ncl:8:30
   │
diff --git a/core/src/eval/tests.rs b/core/src/eval/tests.rs
index cf44485d..412ef1ff 100644
--- a/core/src/eval/tests.rs
+++ b/core/src/eval/tests.rs
@@ -1,7 +1,7 @@
 use super::cache::CacheImpl;
 use super::*;
 use crate::cache::resolvers::{DummyResolver, SimpleResolver};
-use crate::error::ImportError;
+use crate::error::{ImportError, NullReporter};
 use crate::files::Files;
 use crate::label::Label;
 use crate::parser::{grammar, lexer, ErrorTolerantParser};
@@ -14,14 +14,14 @@ use assert_matches::assert_matches;
 
 /// Evaluate a term without import support.
 fn eval_no_import(t: RichTerm) -> Result<Term, EvalError> {
-    VirtualMachine::<_, CacheImpl>::new(DummyResolver {}, std::io::sink())
+    VirtualMachine::<_, CacheImpl>::new(DummyResolver {}, std::io::sink(), NullReporter {})
         .eval(t)
         .map(Term::from)
 }
 
 /// Fully evaluate a term without import support.
 fn eval_full_no_import(t: RichTerm) -> Result<Term, EvalError> {
-    VirtualMachine::<_, CacheImpl>::new(DummyResolver {}, std::io::sink())
+    VirtualMachine::<_, CacheImpl>::new(DummyResolver {}, std::io::sink(), NullReporter {})
         .eval_full(t)
         .map(Term::from)
 }
@@ -122,7 +122,7 @@ fn asking_for_various_types() {
 
 #[test]
 fn imports() {
-    let mut vm = VirtualMachine::new(SimpleResolver::new(), std::io::sink());
+    let mut vm = VirtualMachine::new(SimpleResolver::new(), std::io::sink(), NullReporter {});
     vm.import_resolver_mut()
         .add_source(String::from("two"), String::from("1 + 1"));
     vm.import_resolver_mut()
@@ -273,29 +273,44 @@ fn initial_env() {
 
     let t = mk_term::let_one_in("x", mk_term::integer(2), mk_term::var("x"));
     assert_eq!(
-        VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
-            .with_initial_env(initial_env.clone())
-            .eval(t)
-            .map(RichTerm::without_pos),
+        VirtualMachine::new_with_cache(
+            DummyResolver {},
+            eval_cache.clone(),
+            std::io::sink(),
+            NullReporter {}
+        )
+        .with_initial_env(initial_env.clone())
+        .eval(t)
+        .map(RichTerm::without_pos),
         Ok(mk_term::integer(2))
     );
 
     let t = mk_term::let_one_in("x", mk_term::integer(2), mk_term::var("g"));
     assert_eq!(
-        VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
-            .with_initial_env(initial_env.clone())
-            .eval(t)
-            .map(RichTerm::without_pos),
+        VirtualMachine::new_with_cache(
+            DummyResolver {},
+            eval_cache.clone(),
+            std::io::sink(),
+            NullReporter {}
+        )
+        .with_initial_env(initial_env.clone())
+        .eval(t)
+        .map(RichTerm::without_pos),
         Ok(mk_term::integer(1))
     );
 
     // Shadowing of the initial environment
     let t = mk_term::let_one_in("g", mk_term::integer(2), mk_term::var("g"));
     assert_eq!(
-        VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
-            .with_initial_env(initial_env.clone())
-            .eval(t)
-            .map(RichTerm::without_pos),
+        VirtualMachine::new_with_cache(
+            DummyResolver {},
+            eval_cache.clone(),
+            std::io::sink(),
+            NullReporter {}
+        )
+        .with_initial_env(initial_env.clone())
+        .eval(t)
+        .map(RichTerm::without_pos),
         Ok(mk_term::integer(2))
     );
 }
diff --git a/core/tests/examples/main.rs b/core/tests/examples/main.rs
index 05e863dd..df8d98ab 100644
--- a/core/tests/examples/main.rs
+++ b/core/tests/examples/main.rs
@@ -1,5 +1,5 @@
 use assert_matches::assert_matches;
-use nickel_lang_core::error::{Error, EvalError};
+use nickel_lang_core::error::{Error, EvalError, NullReporter};
 use nickel_lang_utils::{
     annotated_test::{read_annotated_test_case, TestCase},
     project_root::project_root,
@@ -14,8 +14,12 @@ fn check_example_file(path: &str) {
         read_annotated_test_case(path).expect("Failed to parse annotated program");
 
     // `test_resources` uses paths relative to the workspace manifesty
-    let mut p = TestProgram::new_from_file(project_root().join(path), std::io::stderr())
-        .expect("Failed to load program from file");
+    let mut p = TestProgram::new_from_file(
+        project_root().join(path),
+        std::io::stderr(),
+        NullReporter {},
+    )
+    .expect("Failed to load program from file");
 
     match test.annotation {
         Expectation::Pass => {
diff --git a/core/tests/integration/main.rs b/core/tests/integration/main.rs
index fceb748a..420afb81 100644
--- a/core/tests/integration/main.rs
+++ b/core/tests/integration/main.rs
@@ -2,7 +2,8 @@ use std::{io::Cursor, thread};
 
 use nickel_lang_core::{
     error::{
-        Error, EvalError, ExportError, ExportErrorData, ImportError, ParseError, TypecheckError,
+        Error, EvalError, ExportError, ExportErrorData, ImportError, NullReporter, ParseError,
+        TypecheckError,
     },
     term::Term,
     typecheck::TypecheckMode,
@@ -70,6 +71,7 @@ fn run_test(test_case: TestCase<Test>, path: String) {
             Cursor::new(program.clone()),
             path.as_str(),
             std::io::stderr(),
+            NullReporter {},
         )
         .expect("");
         if let Some(imports) = &test_case.annotation.nickel_path {
diff --git a/core/tests/integration/query.rs b/core/tests/integration/query.rs
index 21f487c8..4904f6f7 100644
--- a/core/tests/integration/query.rs
+++ b/core/tests/integration/query.rs
@@ -1,4 +1,5 @@
 use nickel_lang_core::{
+    error::NullReporter,
     eval::cache::lazy::CBNCache,
     program::Program,
     term::{
@@ -27,6 +28,7 @@ pub fn test_query_metadata_basic() {
         "{val | doc \"Test basic\" = (1 + 1)}".as_bytes(),
         "regr_tests",
         std::io::stderr(),
+        NullReporter {},
     )
     .unwrap()
     .with_field_path("val")
@@ -44,16 +46,26 @@ 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: &str) {
-        let term1 = TestProgram::new_from_source(lhs.as_bytes(), "regr_tests", std::io::stderr())
-            .unwrap()
-            .with_field_path(path)
-            .query()
-            .unwrap();
-        let term2 = TestProgram::new_from_source(rhs.as_bytes(), "regr_tests", std::io::stderr())
-            .unwrap()
-            .with_field_path(path)
-            .query()
-            .unwrap();
+        let term1 = TestProgram::new_from_source(
+            lhs.as_bytes(),
+            "regr_tests",
+            std::io::stderr(),
+            NullReporter {},
+        )
+        .unwrap()
+        .with_field_path(path)
+        .query()
+        .unwrap();
+        let term2 = TestProgram::new_from_source(
+            rhs.as_bytes(),
+            "regr_tests",
+            std::io::stderr(),
+            NullReporter {},
+        )
+        .unwrap()
+        .with_field_path(path)
+        .query()
+        .unwrap();
         if let (
             Field {
                 metadata:
@@ -95,12 +107,16 @@ pub fn test_query_with_wildcard() {
     }
 
     // Without wildcard, the result has no type annotation
-    let result =
-        TestProgram::new_from_source("{value = 10}".as_bytes(), "regr_tests", std::io::stderr())
-            .unwrap()
-            .with_field_path(path)
-            .query()
-            .unwrap();
+    let result = TestProgram::new_from_source(
+        "{value = 10}".as_bytes(),
+        "regr_tests",
+        std::io::stderr(),
+        NullReporter {},
+    )
+    .unwrap()
+    .with_field_path(path)
+    .query()
+    .unwrap();
 
     assert!(matches!(
         result,
diff --git a/utils/src/test_program.rs b/utils/src/test_program.rs
index 335d01d4..31a1136b 100644
--- a/utils/src/test_program.rs
+++ b/utils/src/test_program.rs
@@ -1,5 +1,5 @@
 use nickel_lang_core::{
-    error::{Error, ParseError},
+    error::{Error, NullReporter, ParseError},
     eval::cache::CacheImpl,
     files::Files,
     parser::{grammar, lexer, ErrorTolerantParser, ExtendedTerm},
@@ -13,9 +13,14 @@ use std::io::Cursor;
 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", std::io::stderr())
-        .unwrap()
+pub fn program_from_expr(s: impl std::string::ToString) -> TestProgram {
+    TestProgram::new_from_source(
+        Cursor::new(s.to_string()),
+        "test",
+        std::io::stderr(),
+        NullReporter {},
+    )
+    .unwrap()
 }
 
 pub fn eval(s: impl std::string::ToString) -> Result<Term, Error> {
@@ -50,6 +55,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, std::io::stderr())
+    Program::new_from_file(&path, std::io::stderr(), NullReporter {})
         .unwrap_or_else(|e| panic!("Could not create program from `{}`\n {}", path, e))
 }