Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ mod theme;

mod build_l10n_audit;

#[expect(
clippy::disallowed_methods,
reason = "SOURCE_DATE_EPOCH is the reproducible-builds contract: the build system supplies it to the build script's process, so there is no seam to inject it through"
)]
fn manual_date() -> String {
let Ok(raw) = env::var("SOURCE_DATE_EPOCH") else {
return FALLBACK_DATE.into();
Expand Down Expand Up @@ -95,6 +99,10 @@ fn manual_date() -> String {
})
}

#[expect(
clippy::disallowed_methods,
reason = "TARGET and PROFILE are set by Cargo for the build script alone; nothing else knows the triple and profile being built, so they cannot be passed in"
)]
fn out_dir_for_target_profile() -> PathBuf {
let target = env::var("TARGET").unwrap_or_else(|_| "unknown-target".into());
let profile = env::var("PROFILE").unwrap_or_else(|_| "unknown-profile".into());
Expand Down Expand Up @@ -132,6 +140,10 @@ fn emit_rerun_directives() {
println!("cargo:rerun-if-changed=locales/es-ES/messages.ftl");
}

#[expect(
clippy::disallowed_methods,
reason = "CARGO_BIN_NAME, CARGO_PKG_NAME, CARGO_PKG_VERSION and OUT_DIR are Cargo's own build-script inputs; they describe the crate being compiled and Cargo provides them only through the environment"
)]
fn generate_man_page(out_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let cmd = cli::Cli::command();
let name = cmd
Expand Down
15 changes: 15 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,18 @@ too-many-lines-threshold = 70 # default is 100
excessive-nesting-threshold = 4 # default is off

allow-expect-in-tests = true

# Enforce the AGENTS.md environment mandate. The reason strings surface in the
# diagnostic, so a contributor who trips one is told what to do instead.
#
# Sanctioned sites carry `#[expect(clippy::disallowed_methods, reason = "..")]`
# rather than `allow`, so the expectation goes unfulfilled — and warns — once the
# site is migrated. The backlog removes itself instead of rotting.
disallowed-methods = [
{ path = "std::env::var", reason = "inject an environment reader" },
{ path = "std::env::var_os", reason = "inject an environment reader" },
Comment thread
leynos marked this conversation as resolved.
{ path = "std::env::vars", reason = "inject an environment reader" },
{ path = "std::env::vars_os", reason = "inject an environment reader" },
{ path = "std::env::set_var", reason = "use a stub environment in tests" },
{ path = "std::env::remove_var", reason = "use a stub environment in tests" },
]
47 changes: 47 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,53 @@ still safe to use. See the
[locale-pinned snapshot tests](snapshot-testing-in-netsuke-using-insta.md#locale-pinned-snapshot-tests)
section for the fixture's intended usage.

### Enforcing the environment mandate

`clippy.toml` disallows the six process-environment entry points, so
`make lint` rejects a new one:

```toml
disallowed-methods = [
{ path = "std::env::var", reason = "inject an environment reader" },
{ path = "std::env::set_var", reason = "use a stub environment in tests" },
# ... var_os, vars, vars_os, remove_var
]
```

The reason string appears in the diagnostic, so a contributor who trips the
lint is told what to do instead, not merely that they may not. `test_support`
is excluded from the workspace, so it carries its own copy of the list.

#### Annotating a sanctioned site

Use `#[expect]`, never `allow`:

```rust
#[expect(
clippy::disallowed_methods,
reason = "composition root: supplies the process environment to the read_env seam"
)]
pub fn resolve(no_emoji: Option<bool>) -> OutputPrefs {
resolve_with(no_emoji, |key| env::var(key).ok())
}
```

`expect` becomes *unfulfilled* — and warns — once the site stops tripping the
lint. A migrated file therefore fails the gate until its annotation is removed,
so the backlog cannot rot silently. `allow` would go stale invisibly.

Three dispositions are in use:

- **Composition roots** in `src/` keep a permanent site-level expectation naming
the seam they supply. These are the sanctioned ambient boundary.
- **Build scripts and artefact discovery** keep a permanent module-level
expectation: they read what Cargo reports, and there is no seam to inject.
- **Pending migrations** in `tests/` carry a module-level expectation naming the
tracking issue, removed as each file migrates.

Scope an expectation as tightly as the site allows — a function where one call
is involved, a module only where the whole file is pending migration.

### `EnvLock`

`test_support::env_lock::EnvLock` is a global mutex that serializes all
Expand Down
13 changes: 9 additions & 4 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ change before 1.0. Pin the Netsuke version in automated workflows.
## Install Netsuke

Netsuke requires [Ninja](https://ninja-build.org/) on `PATH`. A source build
also requires the dated Rust nightly toolchain pinned in `rust-toolchain.toml`,
because Netsuke builds with the Polonius borrow checker (`-Zpolonius=next`);
`rustup` installs it automatically inside a checkout.
also requires the dated Rust nightly toolchain pinned in `rust-toolchain.toml`
because Netsuke builds with the Polonius borrow checker (`-Zpolonius=next`).

Inside a checkout both settings are inherited automatically: `rustup` installs
the pinned toolchain, and the repository's `.cargo/config.toml` supplies
`RUSTFLAGS=-Zpolonius=next`. Neither has to be passed on the command line.

Netsuke v0.1.0 is available from crates.io. Where
[`cargo binstall`](https://github.com/cargo-bins/cargo-binstall) is available,
Expand Down Expand Up @@ -67,7 +70,9 @@ licence files. Installer packages do not have checksum sidecars in v0.1.0.
Windows PowerShell help files are published beside each MSI as sidecar
artefacts rather than embedded in the installer.

To install the current source checkout with Cargo:
To install the current source checkout with Cargo. The clone supplies both the
pinned nightly toolchain and `RUSTFLAGS=-Zpolonius=next`, so neither is given
here — unlike the registry install above, which runs outside a checkout:

<!-- tested-example: guide-source-install -->

Expand Down
4 changes: 4 additions & 0 deletions src/cli/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ pub trait EnvProvider {
#[derive(Debug, Default, Clone, Copy)]
pub struct StdEnvProvider;

#[expect(
clippy::disallowed_methods,
reason = "composition root: StdEnvProvider is the process-backed adapter behind the EnvProvider seam"
)]
impl EnvProvider for StdEnvProvider {
fn get(&self, key: &str) -> Option<OsString> {
std::env::var_os(key)
Expand Down
4 changes: 4 additions & 0 deletions src/locale_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ pub trait EnvProvider {
#[derive(Debug, Default, Copy, Clone)]
pub struct SystemEnv;

#[expect(
clippy::disallowed_methods,
reason = "composition root: SystemEnv is the process-backed adapter behind the EnvProvider seam"
)]
impl EnvProvider for SystemEnv {
fn var(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
Expand Down
4 changes: 4 additions & 0 deletions src/manifest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ pub enum ManifestLoadStage {
/// assert_eq!(env("FOO").unwrap(), "bar");
/// // guard restores prior value on drop
/// ```
#[expect(
clippy::disallowed_methods,
reason = "composition root: supplies the process environment to the env() Jinja helper"
)]
fn env_var(name: &str) -> std::result::Result<String, Error> {
match std::env::var(name) {
Ok(val) => Ok(val),
Expand Down
4 changes: 4 additions & 0 deletions src/output_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ impl OutputMode {
/// assert_eq!(resolve(Some(false), None), OutputMode::Standard);
/// ```
#[must_use]
#[expect(
clippy::disallowed_methods,
reason = "composition root: supplies the process environment to the read_env seam"
)]
pub fn resolve(explicit: Option<bool>, colour_policy: Option<ColourPolicy>) -> OutputMode {
resolve_with(explicit, colour_policy, |key| env::var(key).ok())
}
Expand Down
8 changes: 8 additions & 0 deletions src/output_prefs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ impl OutputPrefs {
/// assert!(!prefs.emoji_allowed());
/// ```
#[must_use]
#[expect(
clippy::disallowed_methods,
reason = "composition root: supplies the process environment to the read_env seam"
)]
pub fn resolve_from_theme(theme: Option<ThemePreference>, context: ThemeContext) -> OutputPrefs {
resolve_from_theme_with(theme, context, |key| env::var(key).ok())
}
Expand Down Expand Up @@ -227,6 +231,10 @@ where
/// assert!(resolve_with(Some(false), |_| None).emoji_allowed());
/// ```
#[must_use]
#[expect(
clippy::disallowed_methods,
reason = "composition root: supplies the process environment to the read_env seam"
)]
pub fn resolve(no_emoji: Option<bool>) -> OutputPrefs {
resolve_with(no_emoji, |key| env::var(key).ok())
}
Expand Down
4 changes: 4 additions & 0 deletions src/runner/process/ninja_program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ where

/// Resolve the configured Ninja executable as a UTF-8 path.
#[must_use]
#[expect(
clippy::disallowed_methods,
reason = "composition root: supplies the process environment to the ninja program resolver seam"
)]
pub fn resolve_ninja_program_utf8() -> Utf8PathBuf {
resolve_ninja_program_utf8_with(|key| env::var_os(key))
}
Expand Down
8 changes: 8 additions & 0 deletions src/stdlib/path/path_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ fn current_dir_utf8() -> Result<Utf8PathBuf, io::Error> {
}

#[cfg(windows)]
#[expect(
clippy::disallowed_methods,
reason = "composition root: home resolution is the path stdlib's ambient boundary; the injected ladders are tracked in the environment meta issue"
)]
fn home_from_env() -> Option<String> {
env::var("HOME")
.or_else(|_| env::var("USERPROFILE"))
Expand All @@ -165,6 +169,10 @@ fn home_from_env() -> Option<String> {
}

#[cfg(not(windows))]
#[expect(
clippy::disallowed_methods,
reason = "composition root: home resolution is the path stdlib's ambient boundary; the injected ladders are tracked in the environment meta issue"
)]
fn home_from_env() -> Option<String> {
env::var("HOME").or_else(|_| env::var("USERPROFILE")).ok()
}
4 changes: 4 additions & 0 deletions src/stdlib/which/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ pub(super) struct EnvSnapshot {
}

impl EnvSnapshot {
#[expect(
clippy::disallowed_methods,
reason = "composition root: PATH and PATHEXT capture is the which resolver's ambient boundary; injection is tracked in the environment meta issue"
)]
pub(super) fn capture(
cwd_override: Option<&Utf8Path>,
path_override: Option<&OsStr>,
Expand Down
4 changes: 4 additions & 0 deletions src/stdlib/which/lookup/workspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ pub(super) fn should_visit_entry(entry: &walkdir::DirEntry, skip_dirs: &Workspac
!skip_dirs.contains(&name)
}

#[expect(
clippy::disallowed_methods,
reason = "composition root: supplies the process environment to the workspace fallback seam"
)]
fn workspace_fallback_enabled() -> bool {
match env::var(WORKSPACE_FALLBACK_ENV) {
Ok(value) => {
Expand Down
14 changes: 14 additions & 0 deletions test_support/clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Enforce the AGENTS.md environment mandate. The reason strings surface in the
# diagnostic, so a contributor who trips one is told what to do instead.
#
# Sanctioned sites carry `#[expect(clippy::disallowed_methods, reason = "..")]`
# rather than `allow`, so the expectation goes unfulfilled — and warns — once the
# site is migrated. The backlog removes itself instead of rotting.
disallowed-methods = [
Comment thread
leynos marked this conversation as resolved.
{ path = "std::env::var", reason = "inject an environment reader" },
{ path = "std::env::var_os", reason = "inject an environment reader" },
{ path = "std::env::vars", reason = "inject an environment reader" },
{ path = "std::env::vars_os", reason = "inject an environment reader" },
{ path = "std::env::set_var", reason = "use a stub environment in tests" },
{ path = "std::env::remove_var", reason = "use a stub environment in tests" },
]
4 changes: 4 additions & 0 deletions test_support/src/command_helper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ pub fn compile_large_output_helper(
/// .expect("compile helper");
/// assert!(exe.as_std_path().exists());
/// ```
#[expect(
clippy::disallowed_methods,
reason = "reads the inherited environment to invoke rustc for a helper binary; the compiler must see the real toolchain"
)]
pub fn compile_rust_helper(
dir: &Dir,
root: &Utf8PathBuf,
Expand Down
4 changes: 4 additions & 0 deletions test_support/src/dev_fast/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ pub fn real_utility(utility: &str) -> Result<Utf8PathBuf> {
/// sourceable `env` shell fragment into `~/.local/bin`, so a plain file probe
/// selects a non-executable file and every sandboxed `env` invocation then
/// fails with a permission error.
#[expect(
clippy::disallowed_methods,
reason = "resolves a tool through the real PATH, which is what the sandbox under test must observe"
)]
fn which(utility: &str) -> Result<Utf8PathBuf> {
let path = std::env::var_os("PATH").context("read PATH")?;
for dir in std::env::split_paths(&path) {
Expand Down
32 changes: 32 additions & 0 deletions test_support/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,20 @@ pub trait EnvMut: Env {
}

impl EnvMut for DefaultEnv {
#[expect(
clippy::disallowed_methods,
reason = "test_support::env is the crate's process-environment boundary: these guards exist to set and restore real variables for subprocess and legacy in-process tests, which AGENTS.md sanctions"
)]
unsafe fn set_var(&self, key: &str, value: &OsStr) {
unsafe { std::env::set_var(key, value) };
}
}

impl EnvMut for MockEnv {
#[expect(
clippy::disallowed_methods,
reason = "test_support::env is the crate's process-environment boundary: these guards exist to set and restore real variables for subprocess and legacy in-process tests, which AGENTS.md sanctions"
)]
unsafe fn set_var(&self, key: &str, value: &OsStr) {
unsafe { std::env::set_var(key, value) };
}
Expand All @@ -59,6 +67,10 @@ impl EnvMut for MockEnv {
/// Returns a `MockEnv` that yields the current `PATH` when queried. Tests can
/// modify the real environment while the mock continues to expose the initial
/// value.
#[expect(
clippy::disallowed_methods,
reason = "test_support::env is the crate's process-environment boundary: these guards exist to set and restore real variables for subprocess and legacy in-process tests, which AGENTS.md sanctions"
)]
pub fn mocked_path_env() -> MockEnv {
let original = std::env::var("PATH").unwrap_or_default();
let mut env = MockEnv::new();
Expand All @@ -72,6 +84,10 @@ pub fn mocked_path_env() -> MockEnv {
///
/// The mutation is `unsafe` in Rust 2024 as it alters process state. The
/// unsafety is scoped by acquiring [`EnvLock`].
#[expect(
clippy::disallowed_methods,
reason = "test_support::env is the crate's process-environment boundary: these guards exist to set and restore real variables for subprocess and legacy in-process tests, which AGENTS.md sanctions"
)]
pub fn set_var(key: &str, value: &OsStr) -> Option<OsString> {
let _lock = EnvLock::acquire();
let previous = std::env::var_os(key);
Expand All @@ -81,6 +97,10 @@ pub fn set_var(key: &str, value: &OsStr) -> Option<OsString> {
}

/// Set an environment variable while the caller already holds [`EnvLock`].
#[expect(
clippy::disallowed_methods,
reason = "test_support::env is the crate's process-environment boundary: these guards exist to set and restore real variables for subprocess and legacy in-process tests, which AGENTS.md sanctions"
)]
pub fn set_var_locked(lock: &EnvLock, key: &str, value: &OsStr) -> Option<OsString> {
let _ = lock;
let previous = std::env::var_os(key);
Expand All @@ -93,6 +113,10 @@ pub fn set_var_locked(lock: &EnvLock, key: &str, value: &OsStr) -> Option<OsStri
///
/// The mutation is `unsafe` in Rust 2024 as it alters process state. The
/// unsafety is scoped by acquiring [`EnvLock`].
#[expect(
clippy::disallowed_methods,
reason = "test_support::env is the crate's process-environment boundary: these guards exist to set and restore real variables for subprocess and legacy in-process tests, which AGENTS.md sanctions"
)]
pub fn remove_var(key: &str) -> Option<OsString> {
let _lock = EnvLock::acquire();
let previous = std::env::var_os(key);
Expand Down Expand Up @@ -136,6 +160,10 @@ pub fn restore_many(vars: HashMap<String, Option<OsString>>) {
/// # Safety
///
/// Caller must hold [`EnvLock`] for the duration of this call.
#[expect(
clippy::disallowed_methods,
reason = "test_support::env is the crate's process-environment boundary: these guards exist to set and restore real variables for subprocess and legacy in-process tests, which AGENTS.md sanctions"
)]
pub unsafe fn restore_many_locked(vars: HashMap<String, Option<OsString>>) {
for (key, val) in vars {
if let Some(v) = val {
Expand All @@ -156,6 +184,10 @@ pub struct VarGuard {

/// Run `action` with `PATH` temporarily set to `value`, restoring on drop and
/// serialising mutations with `EnvLock`.
#[expect(
clippy::disallowed_methods,
reason = "test_support::env is the crate's process-environment boundary: these guards exist to set and restore real variables for subprocess and legacy in-process tests, which AGENTS.md sanctions"
)]
pub fn with_isolated_path<T>(value: &OsStr, action: impl FnOnce() -> T) -> T {
let _lock = EnvLock::acquire();
let original = std::env::var_os("PATH");
Expand Down
8 changes: 8 additions & 0 deletions test_support/src/env_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,18 @@ pub trait Environment {
pub struct StdEnv;

impl Environment for StdEnv {
#[expect(
clippy::disallowed_methods,
reason = "the guard's whole purpose is to mutate and restore a real process variable; injecting here would leave nothing to guard"
)]
unsafe fn set_var(&mut self, key: &str, value: &OsStr) {
unsafe { std::env::set_var(key, value) };
}

#[expect(
clippy::disallowed_methods,
reason = "the guard's whole purpose is to mutate and restore a real process variable; injecting here would leave nothing to guard"
)]
unsafe fn remove_var(&mut self, key: &str) {
unsafe { std::env::remove_var(key) };
}
Expand Down
Loading
Loading