diff --git a/Cargo.lock b/Cargo.lock index 2c31f42f2..8360e1c5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3072,6 +3072,7 @@ dependencies = [ "mockable", "netsuke-build", "proptest", + "regex", "rstest", "sha2", "tempfile", diff --git a/build.rs b/build.rs index a6c881708..2bf962163 100644 --- a/build.rs +++ b/build.rs @@ -9,9 +9,10 @@ //! A build with the `lint` feature writes both into a `lint/` subdirectory //! instead, because its command tree has a `check` subcommand the release //! artefacts must not carry. -//! - Audit localization keys declared across the three key tables — -//! `src/localization/keys.rs`, `src/localization/check_keys.rs`, and -//! `src/localization/clap_error_keys.rs` — against the Fluent bundles in +//! - Audit localization keys declared across the four key tables — +//! `src/localization/keys.rs`, `src/localization/check_keys.rs`, +//! `src/localization/clap_error_keys.rs`, and +//! `src/localization/manifest_keys.rs` — against the Fluent bundles in //! `locales/*/messages.ftl`, failing the build if any declared key is missing //! from a locale. use cap_std::{ambient_authority, fs::Dir}; @@ -85,6 +86,7 @@ mod host_pattern; pub mod locale_catalogues; mod build_l10n_audit; + /// Message rendering, shared with the library crate. /// /// Exposed as `crate::localization`, which `cli`, `cli::l10n`, and @@ -210,6 +212,7 @@ fn emit_rerun_directives() { println!("cargo:rerun-if-changed=src/localization/keys.rs"); println!("cargo:rerun-if-changed=src/localization/check_keys.rs"); println!("cargo:rerun-if-changed=src/localization/clap_error_keys.rs"); + println!("cargo:rerun-if-changed=src/localization/manifest_keys.rs"); println!("cargo:rerun-if-changed=src/locale/catalogues.rs"); println!("cargo:rerun-if-changed=Cargo.toml"); // The locale registry owns the catalogue list, so the rerun directives are @@ -312,6 +315,7 @@ fn generate_completions(out_dir: &Path) -> Result<(), Box ); Ok(()) } + fn main() -> Result<(), Box> { emit_rerun_directives(); build_l10n_audit::audit_localization_keys()?; diff --git a/build_l10n_audit/mod.rs b/build_l10n_audit/mod.rs index 4cf9a3190..ebff38c42 100644 --- a/build_l10n_audit/mod.rs +++ b/build_l10n_audit/mod.rs @@ -1,11 +1,12 @@ //! Localization audit for the build script. //! -//! Compares the keys declared by `define_keys!` in the three key tables — -//! `src/localization/keys.rs`, `src/localization/check_keys.rs`, and -//! `src/localization/clap_error_keys.rs` — with every catalogue named by the -//! locale registry in `src/locale_catalogues.rs`. The three files are one key -//! set: `keys.rs` re-exports the other two, and a key declared in more than one -//! table is an error. The audit fails the build when a catalogue is missing a +//! Compares the keys declared by `define_keys!` in the four key tables — +//! `src/localization/keys.rs`, `src/localization/check_keys.rs`, +//! `src/localization/clap_error_keys.rs`, and +//! `src/localization/manifest_keys.rs` — with every catalogue named by the +//! locale registry in `src/locale_catalogues.rs`. The four files are one key +//! set: `keys.rs` re-exports the other three, and a key declared in more than +//! one table is an error. The audit fails the build when a catalogue is missing a //! declared key, carries an orphaned key, or interpolates a different set of //! variables from the English source for a shared message. //! @@ -14,8 +15,11 @@ //! than being a second source of truth. mod compare; + mod ftl; + mod keys; + mod metadata; use crate::locale_catalogues::{LocaleCatalogue, SOURCE_LOCALE, SUPPORTED_LOCALES}; @@ -28,12 +32,15 @@ use std::path::{Path, PathBuf}; use std::{error::Error, io}; /// Repository-relative paths of the source files that define the localization -/// keys. `keys.rs` re-exports the other two, so together they are one key set. -const KEY_SOURCES: [&str; 3] = [ +/// keys. `keys.rs` re-exports `check_keys.rs`, `clap_error_keys.rs`, and +/// `manifest_keys.rs`, so together they are one key set. +const KEY_SOURCES: [&str; 4] = [ "src/localization/keys.rs", "src/localization/check_keys.rs", "src/localization/clap_error_keys.rs", + "src/localization/manifest_keys.rs", ]; + /// Repository-relative path of the manifest whose locale metadata is audited. const CARGO_MANIFEST: &str = "Cargo.toml"; diff --git a/docs/adrs/adr-0050-bound-manifest-template-evaluation.md b/docs/adrs/adr-0050-bound-manifest-template-evaluation.md index 2260d12e8..8d222660a 100644 --- a/docs/adrs/adr-0050-bound-manifest-template-evaluation.md +++ b/docs/adrs/adr-0050-bound-manifest-template-evaluation.md @@ -40,15 +40,15 @@ MiB source, 10,000 `foreach` values, and 50,000 expanded entries. Exhaustion uses a localized, redacted diagnostic and closed-vocabulary telemetry labels. The budget types contain only accounting data; the Jinja adapter owns localization and MiniJinja error conversion, and the normal full-load boundary -owns exhaustion telemetry. Manifest-query loading selects -`ManifestLoadMode::ManifestQuery`, which suppresses this budget-exhaustion -metric at the loading boundary; its omitted expansion-report observer controls -the separate expansion metrics. Other instrumentation remains owned by the -boundaries that invoke it. Budget values from the project file and every file -in its `extends` chain may only narrow ceilings established by defaults, -trusted configuration, the environment, or CLI flags. Malformed, non-positive, -and unrepresentable budget values are rejected during configuration merge -rather than treated as absent. +owns exhaustion telemetry. Manifest-query loading selects the `ManifestQuery` +stdlib registration, which suppresses this budget-exhaustion metric at the +loading boundary; its omitted expansion-report observer controls the separate +expansion metrics. Other instrumentation remains owned by the boundaries that +invoke it. Budget values from the project file and every file in its `extends` +chain may only narrow ceilings established by defaults, trusted configuration, +the environment, or CLI flags. Malformed, non-positive, and unrepresentable +budget values are rejected during configuration merge rather than treated as +absent. After a successful full manifest load, the runner continues to call `record_manifest_structure` for the bounded fixed-vocabulary structural counts @@ -237,6 +237,28 @@ macro output or internal macro and capture buffering. Addendum B's deferred remediation and its MiniJinja 3.0.0-final release condition therefore remain unchanged. +## Addendum E: Telemetry scope for derived loads (2026-11-01) + +Expansion and budget telemetry describes normal manifest loading: how often +`foreach`/`when` evaluated, what the resource budget observed, and how a build +manifest's evaluation behaved. Two derived load modes measure something else +and must not contribute to those counters. + +`ManifestQuery` loads inspect discovery metadata and evaluate a manifest only +far enough to answer a query. `Test` loads evaluate a manifest under a +deny-by-default stdlib surface. Counting either as a build-shaped load would +distort usage evidence and blend a query or test outcome into statistics about +builds. + +The load's registration therefore decides: +`StdlibRegistration::records_expansion_and_budget_telemetry` is true for +`AmbientFull` and `Full` and false for `ManifestQuery` and `Test`. The loader +passes that decision to the budget adapter and skips the expansion telemetry, +so a query or test load still enforces the resource ceilings but records +nothing under the build's telemetry names. This narrows the counters' scope; it +does not change the budget itself or the guarantee this record's Addenda A–D +describe. + ## References - [#651](https://github.com/leynos/netsuke/issues/651) and diff --git a/docs/adrs/adr-0056-manifest-linting-under-netsuke-check.md b/docs/adrs/adr-0056-manifest-linting-under-netsuke-check.md index 58b7f27ef..0dd86fe4d 100644 --- a/docs/adrs/adr-0056-manifest-linting-under-netsuke-check.md +++ b/docs/adrs/adr-0056-manifest-linting-under-netsuke-check.md @@ -232,12 +232,13 @@ Gated behind `#[cfg(feature = "lint")]`: `crate::lint`; the CLI's the runner's `check` modules and dispatch, the lint `RunnerError` variants, and check telemetry; and the `source` and `name` fields of the internal `LoadedManifest`. Deliberately not gated: the Fluent message keys in -`src/localization/keys.rs`, `src/localization/check_keys.rs`, and -`src/localization/clap_error_keys.rs` and their catalogue entries, because the -build-time localization audit is bidirectional across all 35 catalogues and -gating the constants would make every locale look orphaned; and the -subcommand-to-key routing in `src/cli/l10n/mod.rs`, which only maps command -names and carries no linting logic. +`src/localization/keys.rs`, `src/localization/check_keys.rs`, +`src/localization/clap_error_keys.rs`, and `src/localization/manifest_keys.rs` +and their catalogue entries, because the build-time localization audit is +bidirectional across all 35 catalogues and gating the constants would make +every locale look orphaned; and the subcommand-to-key routing in +`src/cli/l10n/mod.rs`, which only maps command names and carries no linting +logic. Both configurations are verified in continuous integration (CI): the existing gates build and test with `--all-features`, and a second, uninstrumented CI diff --git a/docs/adrs/adr-0070-deny-by-default-test-mode-stdlib.md b/docs/adrs/adr-0070-deny-by-default-test-mode-stdlib.md new file mode 100644 index 000000000..e9ed222f3 --- /dev/null +++ b/docs/adrs/adr-0070-deny-by-default-test-mode-stdlib.md @@ -0,0 +1,201 @@ +# Architectural decision record (ADR) 0070: Deny-by-default test-mode stdlib + +## Status + +Accepted on 2026-11-01. The `netsuke test` manifest loader grants a +deny-by-default stdlib surface, and template overlays replace withheld +functions by name. + +## Date + +2026-11-01 + +## Context and Problem Statement + +Roadmap item 7.1.2 makes the manifest loader options-carrying so that a test +runner can load a manifest without granting the capabilities a build needs. The +technical design (§4.2, §5.5) makes three requirements binding: + +- A test load must not read the host environment, inspect or mutate the project + tree, run a command, reach the network, or depend on the wall clock. An + unmocked impure call is an error, not a host-dependent answer (constraints + C2, C3, C4). +- A withheld helper must fail with a located diagnostic naming the operation. + An absent helper yields MiniJinja's generic unknown-name error, which reads + as a typo in the manifest. +- A test author must still be able to test a manifest that calls an impure + helper, by supplying the answer a real run would have produced without + granting the helper itself. + +The loader already selects its stdlib boundary through a crate-private +`StdlibRegistration` enum with `AmbientFull`, `Full`, and `ManifestQuery` +variants. The question is how to add a fourth mode without duplicating the +capability decision, the refusal diagnostics, or the environment seam. + +## Decision Drivers + +- One place decides every capability for a load (constraint C5). +- Refusals are localized, carry a stable diagnostic code, and expose a typed + error source so a caller can distinguish a withheld capability from an + unknown name. +- The test surface is deterministic: it depends only on inputs the test + supplied. +- The overlay mechanism is a loader capability compiled into the shipped + binary, not a test-only `cfg` path (constraint C9). +- Every helper whose availability differs by load surface is inventoried once, + and a new helper cannot be registered without stating what each surface does + with it. +- No second mode enum, and no public load-mode vocabulary that an embedder + could widen. + +## Decision Outcome + +Extend `StdlibRegistration` with `Test(Box)` and keep the enum +crate-private. `TestSurface` carries three things: the `StdlibConfig` the +surface grants (including the clock `now()` may read), an `EnvReader` that +`env()` answers from, and the `TemplateOverlays` the test supplied. Keeping the +payload in the surface rather than in the load options means the enum never +grows a second capability field. + +Availability is decided by one inventory. `SURFACE` +(`src/stdlib/restricted_helpers/mod.rs`) records a query disposition and a test +disposition beside every helper whose availability depends on the load surface. +`register_for_test` registers exactly the refused rows as stubs, and a contract +test checks the inventory in both directions. The implemented surface is: + +- Available: `env()` over the surface's injected reader; `timedelta`; the + lexical path filters `basename`, `dirname`, `with_suffix`, `relative_to`; the + recipe-text quoting helpers `shell_quote`, `shell_join`; the collection + filters `uniq`, `flatten`, `compact`, `group_by`; and the legacy Boolean + render formatter. +- Refused: the functions `glob`, `fetch`, `which`, and `command_available`; + the filters `which`, `shell`, `grep`, `contents`, `realpath`, `expanduser`, + `size`, `linecount`, `hash`, and `digest`; the tests `dir`, `file`, `symlink`, + `pipe`, `block_device`, `char_device`, and `device`. +- Clock-injected: `now()` is the real helper only when the surface's + `StdlibConfig` carries a clock supplied through `with_clock`, and a refusal + when it would read the ambient clock. + +Each refusal is a MiniJinja `InvalidOperation` error whose message is the +Fluent key `stdlib.test_mode.helper_unavailable` with argument `operation`, +prefixed by the stable code `[netsuke::jinja::test_mode::unavailable]` in every +catalogue, and whose source is a typed `TestModeRefusal { operation }`. Stubs +accept any positional and keyword arguments, because a real helper documents +several call shapes and a narrower signature would report an argument-count +error instead of the refusal it must deliver. + +Two rows are deliberate deviations from a "run it against an empty sandbox" +reading, and both were reviewed before implementation: + +- `which` refuses although `path_override` and `pathext_override` exist. + Running it against an empty `PATH` reports a misleading "not found" rather + than a refusal naming the withheld capability, and no sandbox-rooted resolver + exists yet. +- `expanduser` refuses rather than resolving `~`. With + `HomeDirectory::Missing` the real filter would fail anyway, and a resolved + home path exposes a host-shaped answer. + +Template overlays grant the test author a function name in the MiniJinja +environment, backed by a caller-supplied closure. Registration happens after +the stdlib, the manifest `vars`, and the manifest macros, and before `foreach` +expansion, so an overlay is visible to `when`, `foreach`, and every rendered +field and can replace a same-named helper. Overlay names are validated against +the namespaces the loader and manifest already claim — `env`, the `__netsuke_` +prefix, a `vars` key, and a manifest macro — and a collision is a typed +`TemplateOverlayError::NameCollision` rendered through +`manifest.overlay.name_collision` with code +`[netsuke::manifest::overlay::collision]`. Validation runs before any +registration, so a rejected overlay leaves the environment untouched. + +MiniJinja offers no lookup by name, but registered functions are enumerable as +environment globals. Before replacing a name the loader captures the displaced +function and hands it to the overlay as `OverlayCall::shadowed`, so an overlay +can wrap a helper rather than only discard it. This is the capture a spy +depends on. + +Of the refused rows, only the functions — `glob`, `which`, `fetch`, +`command_available`, and a system-clocked `now` — are replaceable by overlays +in the first version. Filters and tests are not mockable (UX design §8.6); +roadmap 7.4.1 adds sandbox-rooted adapters for `glob`, the file tests, and the +file-reading filters. + +## Rationale + +A fourth enum variant keeps one decision table for load capabilities. Adding a +separate `TestMode` flag, or a parallel registration path, would let two +mechanisms disagree about, for example, whether `glob` reaches the filesystem, +and a caller could combine them into a load neither designed. + +The inventory-driven surface makes the deny-by-default rule mechanical. A +missed helper is a contract-test failure rather than a silent capability grant, +and the two dispositions sit side by side so query and test behaviour cannot +drift apart. + +Refusing rather than omitting is the difference between a manifest author +seeing "unknown function `fetch`" (which suggests a typo) and seeing that +`fetch` is unavailable under `netsuke test` and that an overlay is the way to +answer it. The typed source keeps the distinction available to code, not just +to a human reading the message. + +Composing with the existing `StdlibConfig` and `disabled_env_reader` avoids +inventing a test-only stdlib. The surface narrows what the build path already +provides and reuses its diagnostic and environment shapes. + +## Consequences + +- A manifest that calls a withheld helper fails under test with a located, + localized diagnostic naming the operation; it never receives a host-dependent + answer. +- A test can answer `glob`, `which`, `fetch`, `command_available`, and a + system-clocked `now` with an overlay, and can wrap the displaced function + rather than replacing it. +- Filters and tests cannot be mocked in the first version; a manifest that + pipes through `shell` or uses `file` remains untestable until roadmap 7.4.1. +- `StdlibRegistration` stays crate-private, so the loader's four modes are not + an embedder-facing contract. +- Query loads keep their existing behaviour: the same inventory answers them, + and their refusal text is unchanged. +- Test and query loads record no expansion or budget telemetry + ([ADR-0050](adr-0050-bound-manifest-template-evaluation.md) Addendum E), + since those counters describe normal manifest loading. + +## Alternatives considered + +- A boolean `test_mode` flag on the load options. Rejected: it splits the + capability decision across a flag and the existing registration, and admits + combinations neither mode defines. +- Registering nothing for withheld helpers. Rejected: MiniJinja's unknown-name + error does not say the capability was withheld, and no typed source is + available for callers. +- Building a second, test-only stdlib. Rejected: it duplicates the surface, + drifts from build behaviour, and would need its own diagnostics. +- Intercepting MiniJinja's unknown-function error centrally. Rejected: the + refusal would lose the operation name and the call-shape reach the stubs + preserve. +- Granting the real helpers against a sandbox root. Rejected for 7.1.2: `glob`, + the file tests, and the file-reading filters do not route through + `StdlibConfig::workspace_root` yet; that work is roadmap 7.4.1. + +## Implementation references + +- `src/manifest/loader_options.rs` — `StdlibRegistration` and + `ManifestLoadOptions`. +- `src/manifest/test_surface/mod.rs` — `TestSurface`. +- `src/manifest/test_surface/overlays.rs` — `TemplateOverlays`, + `OverlayCallable`, `OverlayCall`, name validation, and registration order. +- `src/stdlib/restricted_helpers/mod.rs` — the `SURFACE` inventory. +- `src/stdlib/restricted_helpers/test_mode.rs` — `TestModeRefusal` and the + refusing registrations. +- `src/stdlib/restricted_helpers/query.rs` — the query disposition and its + refusal diagnostics. +- `src/manifest/env/reader.rs` — `disabled_env_reader`. + +## References + +- [Technical design §4.2, §4.3, §5.5, and §7](../netsuke-test-framework-technical-design.md) +- [UX and semantic design §8.6](../netsuke-test-framework-ux-design.md) +- [ADR-0008: environment seam taxonomy](adr-0008-environment-seam-taxonomy.md) +- [ADR-0010: glob capability scoping](adr-0010-scope-the-glob-metadata-capability-to-the-literal-prefix.md) +- [ADR-0050: bound manifest template evaluation](adr-0050-bound-manifest-template-evaluation.md) +- [RFC 0007: Netsukefile testing framework](../rfcs/rfc-0007-netsukefile-testing-framework.md) +- [Roadmap items 7.1.2 and 7.4.1](../roadmap.md#71-seams-and-loader-options) diff --git a/docs/contents.md b/docs/contents.md index c94c310a9..5627144e4 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -321,6 +321,9 @@ operator, user, and contributor references are easier to find. - [ADR-0069](adrs/adr-0069-split-the-localization-key-tables.md): Splitting the Fluent key tables by audience under Whitaker's module line cap, while the build audit and `keys::ALL_KEYS` keep seeing one key set. +- [ADR-0070](adrs/adr-0070-deny-by-default-test-mode-stdlib.md): + Deny-by-default stdlib surface for `netsuke test`, typed refusal stubs, and + template overlays that replace withheld functions by name. ## Proposals diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 7675ad629..0c3e0de2a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -235,9 +235,12 @@ make a helper reach both, so a helper registered privately to one surface is reachable from a build but invisible to `netsuke help targets`, or the reverse. Where the two surfaces must differ, the difference is a deliberate decision to record here rather than an accident of which function happened to be edited: -the recipe-text dialect default above is the one live example, and -`register_disabled_query_helpers` is the mechanism for a helper the query -surface must *refuse* rather than serve. +the recipe-text dialect default above is the one live example. A helper the +query surface must *refuse* rather than serve is a row in the `SURFACE` +inventory of `src/stdlib/restricted_helpers/mod.rs`; its disposition there +drives `restricted_helpers::register_query_refusals` and the build surface's +file-test names alike, so the inventory row, not a stub call site, is where a +refusal belongs. Argument style follows the shape of the options, not the author's taste. A trailing sequence of `Option` parameters, as in @@ -348,6 +351,68 @@ assert!(format!("{:?}", manifest.targets[0].name).contains("release")); This snippet mirrors the executable doctest on `from_str_with_env` in the API documentation. +### Manifest loader entry points + +`ManifestLoadOptions` carries everything a load needs beyond the YAML text and +its name, and `from_str_with_options` is the entry point that takes it. The +older entry points are thin wrappers over the same path: `from_str` loads with +the ambient build stdlib, and `from_str_with_env` differs only in the reader it +injects. + +```rust +use netsuke::manifest::{ + EnvAccessPolicy, ManifestEnvironment, ManifestLoadOptions, ManifestName, + from_str_with_options, process_env_reader, +}; +use netsuke::stdlib::StdlibConfig; + +let reader = process_env_reader(); +let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); +let options = ManifestLoadOptions::full( + StdlibConfig::from_current_dir().expect("open the workspace"), + environment, +); +let yaml = "netsuke_version: 1.0.0\n"; +let manifest = from_str_with_options(yaml, &ManifestName::new("Netsukefile"), options) + .expect("load manifest"); +# let _ = manifest; +``` + +- `ManifestLoadOptions::full` selects the complete build stdlib and takes the + environment inputs explicitly. +- `ManifestLoadOptions::test` selects the deny-by-default test surface (see + [ADR-0070](adrs/adr-0070-deny-by-default-test-mode-stdlib.md)). +- `with_budget_limits` and `with_stage_observer` adjust a load without + changing its mode. + +A path load has to open its workspace before it can decide its stdlib +configuration, so it goes through a crate-private +`from_path_with_options(path, registration_for, options)`, where +`registration_for` turns the opened workspace into a registration. Do not add +another `from_path_with_*` rung for a new caller: use `from_str_with_options` +for a string load, or an existing path entry that already opens a workspace. + +Two capabilities are reachable only through the test surface, and both are +compiled unconditionally because a test runner is a production code path: + +- `TestSurface::with_env_reader` supplies the reader `env()` answers from; + without one, every lookup reports "not set" rather than reading the process. +- `TestSurface::with_overlays` installs `TemplateOverlays`, which replace + template functions by name. Overlays are registered after the stdlib, the + manifest `vars`, and the manifest macros, and before `foreach` expansion, so + they are visible to `when`, `foreach`, and every rendered field. An overlay + may take the name of any stdlib helper, including `glob`, but not `env`, a + `__netsuke_`-prefixed name, a manifest `vars` key, or a manifest macro name. + `OverlayCall::shadowed` hands a replaced function back to the overlay, so a + test can wrap a helper rather than only discard it. + +The stdlib surface a test load grants is deny-by-default and comes from one +inventory, `SURFACE` in `src/stdlib/restricted_helpers/mod.rs`, which records a +query disposition and a test disposition per helper. A contract test checks it +in both directions, so a new stdlib helper has to state what each restricted +surface does with it, and adding a literal registration without a row fails the +gate. + ### Ninja invocation with an explicit environment A program calling Netsuke's Rust API can invoke Ninja without touching its own @@ -670,9 +735,10 @@ Every user-facing string is a Fluent message keyed from message to all 35 catalogues, and keeping its `{ $variables }` identical across them: the build audit rejects a missing key, an orphaned key, or a variable set that differs from `en-US`. The `netsuke check` keys live in -`src/localization/check_keys.rs` and the clap error keys in -`src/localization/clap_error_keys.rs`, both re-exported by `keys.rs`; the audit -reads all three files as one key set and rejects a key declared twice. +`src/localization/check_keys.rs`, the clap error keys in +`src/localization/clap_error_keys.rs`, and the manifest-loading keys in +`src/localization/manifest_keys.rs`, all re-exported by `keys.rs`; the audit +reads all four files as one key set and rejects a key declared twice. A custom `TypedValueParser` that reports `ErrorKind::ValueValidation` must attach clap's `InvalidArg` context when clap supplies the argument and must @@ -1208,12 +1274,13 @@ observability recorder — provide two definitions: one under `#[cfg(feature = "lint")]` and a `#[cfg(not(feature = "lint"))]` no-op twin. **Fluent keys stay ungated.** The Fluent message keys in -`src/localization/keys.rs`, `src/localization/check_keys.rs`, and -`src/localization/clap_error_keys.rs`, their catalogue entries, and the -subcommand-to-key routing in `src/cli/l10n/mod.rs` are deliberately not gated. -The build-time localization audit is bidirectional across all 35 catalogues, -and gating the constants would make every locale look orphaned; the routing -only maps command names and carries no linting logic. +`src/localization/keys.rs`, `src/localization/check_keys.rs`, +`src/localization/clap_error_keys.rs`, and `src/localization/manifest_keys.rs`, +their catalogue entries, and the subcommand-to-key routing in +`src/cli/l10n/mod.rs` are deliberately not gated. The build-time localization +audit is bidirectional across all 35 catalogues, and gating the constants would +make every locale look orphaned; the routing only maps command names and +carries no linting logic. **Test gating.** Check-only integration tests gate the whole test binary with an inner `#![cfg(feature = "lint")]` (`tests/check_command_tests.rs`, @@ -6994,6 +7061,38 @@ Text catalogue snapshots must continue to use the unfiltered Netsuke generator object, so unrelated `version` fields remain asserted in every snapshot. +### The temporary-path snapshot filter + +`test_support::temp_path_filter` (`test_support/src/temp_path_filter.rs`) owns +the single implementation of the temporary-path pattern both snapshot trees +bind. Each tree still owns its own `insta::Settings` — snapshot directory and +module-prefix handling differ — but the pattern itself is not restated: + +- `src/snapshot_test_support.rs` binds it in `add_temp_dir_filter` for + `src/snapshots/`. +- `tests/manifest_load_options/fixtures/settings.rs` binds it in its own + `add_temp_dir_filter` for `tests/snapshots/`. + +**Re-use policy:** any snapshot filter that rewrites a path must build its +pattern here. Do not restate the pattern in a `Settings` builder. The pattern's +separator class is `[\\/]+`, matching one **or more** of either separator, and +the `+` is load-bearing: a snapshot can hold a Windows path twice over, because +a diagnostic quotes the raw single-backslash form while `serde_json` doubles +every backslash in the serialized half, and it is the serialized half that +carries the run-specific workspace name. The class `[\\/]` reads as "either +separator" but matches exactly one character, so the doubled form survives into +the checked-in snapshot. A trailing separator is trimmed before the class is +appended, because otherwise two adjacent classes demand two separators where +the text has one. + +The builder is also why its tests are portable. It takes the directory as an +argument rather than reading `temp_dir()` itself, so the Windows rows run on +Linux; the defect is invisible on the platform that records the snapshot, and a +test reading the ambient directory could only ever check that one. The rows +live beside the builder, so both snapshot trees run the same cases rather than +each holding a copy — two copies guarded only by their own matching cases can +drift apart while both stay green. + ### `test_support::fs` `test_support::fs` (`test_support/src/fs.rs`) is the crate's single @@ -9789,10 +9888,10 @@ Budget-aware entry points include the `*_with_limits` manifest loaders, `expand_foreach_with_budget`, `render_manifest_with_budget`, and `render_manifest_for_manifest_query_with_budget`. The ordinary public wrappers construct the safe default limits. Build and manifest-query loading use the -same accounting path. Query loading selects `ManifestLoadMode::ManifestQuery`; -that mode suppresses budget-exhaustion telemetry at the loading boundary and -omits the expansion-report observer, while other instrumentation remains owned -by the boundaries that invoke it. +same accounting path. Query loading selects the `ManifestQuery` stdlib +registration, which suppresses budget-exhaustion telemetry at the loading +boundary and omits the expansion-report observer, while other instrumentation +remains owned by the boundaries that invoke it. The runner boundary promotes a budget exhaustion to the typed `RunnerError::ManifestBudgetExceeded` variant. Its localized terminal diff --git a/docs/execplans/7-1-2-options-carrying-manifest-loader-entry-point.md b/docs/execplans/7-1-2-options-carrying-manifest-loader-entry-point.md new file mode 100644 index 000000000..9cac5fb80 --- /dev/null +++ b/docs/execplans/7-1-2-options-carrying-manifest-loader-entry-point.md @@ -0,0 +1,3437 @@ +# Introduce the options-carrying manifest loader entry point (7.1.2) + +This ExecPlan (execution plan) is a living document. The sections `Constraints`, +`Tolerances (exception triggers)`, `Risks`, `Progress`, +`Surprises & discoveries`, `Decision log`, `Outcomes & retrospective`, +`Conformance basis`, and `Verification plan` must be kept up to date as work +proceeds. + +Status: COMPLETE + +Approved 2026-10-14 by the user, by explicit instruction to proceed with this +plan under the `sdlc-implementation` workflow. That approval accepts the +technical-design deviations listed under `Decision log` (entries marked +"Deviation"). The user had already settled the one open question, about +file-reading filters under test (see `Decision log`). + +The branch was rebased onto `origin/main` (`486b876c`) on 2026-10-14 before any +work began, because the base had fallen 64 commits behind while the plan sat in +draft. The rebase applied with zero conflicts and is recorded under +`Surprises & discoveries`; the plan's paths, base commit, and ADR number were +corrected to match. No production code had changed before that rebase. + +## Purpose / big picture + +Netsuke turns a YAML manifest (a `Netsukefile`) into a Ninja build file. On the +way it renders Jinja templates through MiniJinja, and those templates may call +helpers that look at the host: `env()`, `glob()`, `now()`, `which`, `fetch`, +`shell`, and file readers such as `contents`. Roadmap phase 7 adds +`netsuke test`, which checks a manifest's logic by driving *the same* loader +with those helpers replaced by deterministic stand-ins. + +Today the loader cannot be driven that way. It has eleven public entry points +(`from_str`, `from_str_with_env`, `from_path_with_policy_and_env_and_limits`, +and so on), each hard-wiring one combination of inputs, and it knows exactly +two stdlib surfaces: the full build surface and the side-effect-free surface +used by `netsuke help targets`. There is no way to hand it a third surface, and +no way to install a stand-in function before the manifest's `foreach` and +`when` expressions are evaluated. + +After this change: + +1. One options-carrying entry point, `netsuke::manifest::from_str_with_options`, + accepts a `ManifestLoadOptions` value naming the stdlib surface, the + environment, the resource budget, and a stage observer. Every existing entry + point becomes a thin wrapper that builds options and forwards to it. +2. The loader's stdlib-surface enum, `StdlibRegistration`, gains a `Test` mode + beside `Full` and `ManifestQuery`. A test load renders pure helpers exactly + as a build does, reads `now()` only from an explicitly injected clock, reads + `env()` only from an injected reader (answering "not set" by default), and + refuses every other host-observing helper with a localized diagnostic + carrying the stable code `[netsuke::jinja::test_mode::unavailable]` and the + helper's name. +3. A test surface can carry `TemplateOverlays`: named callables that replace + same-named template functions. They are registered after the stdlib and + manifest macros and before `foreach` expansion, and each overlay can reach + the function it replaced, so later roadmap items can build spies that pass + through. + +A novice can observe success as follows. A new test loads a manifest whose +`foreach` iterates `glob('src/*.c')`, first in test mode with no overlay (the +load fails with `[netsuke::jinja::test_mode::unavailable]` naming `glob`), then +with an overlay for `glob` returning `["a.c", "b.c"]` (the load succeeds and +yields targets `a.o` and `b.o`). A differential property test generates 128 +pure manifests and shows that build mode and test mode produce identical +manifests, graphs, and Ninja text for every one. Every pre-existing snapshot +and behavioural test passes unchanged, which demonstrates that the build path +did not move. + +There is no user-visible change. No command-line flag, configuration key, or +output changes. `netsuke test` itself arrives in later roadmap items. + +## Definitions + +Terms used throughout, defined here so that no prior reading is required. + +- **Manifest loader.** The code under `src/manifest/` that parses YAML, builds a + MiniJinja environment, expands `foreach`/`when`, deserializes into + `NetsukeManifest` (`src/ast/mod.rs`), and renders string fields. +- **Stdlib.** Netsuke's library of Jinja functions, filters, and tests, + registered into a MiniJinja `Environment` by `src/stdlib/register.rs`. +- **Stdlib surface / registration mode.** Which template helpers are real, + which are refusing stubs, and what configuration backs them. Selected by the + crate-private enum `StdlibRegistration` in `src/manifest/mod.rs`. +- **Refusing stub.** A helper registered under its real name whose only + behaviour is to raise a MiniJinja `InvalidOperation` error naming itself. + Refusing stubs beat leaving a helper unregistered: under strict-undefined + MiniJinja an unregistered helper produces a generic "unknown function" error, + whereas a stub produces a located, actionable diagnostic. +- **Test surface (`TestSurface`).** The payload of the `Test` mode: the + `StdlibConfig` (which owns the clock), an optional environment reader, and + the template overlays. +- **Template overlay.** A callable registered as a MiniJinja global function + *after* the stdlib and manifest macros, so it replaces any same-named + function. Test doubles (roadmap 7.3.1) will be delivered as overlays. +- **Shadowed function.** The global an overlay replaced, captured at + registration and handed to the overlay on each call. +- **Environment reader (`EnvReader`).** The closure type backing the `env()` + template helper (`src/manifest/env/reader.rs`). `ManifestEnvironment` bundles + a reader with the `EnvAccessPolicy` evaluated before each read. +- **Clock provider.** The closure type backing `now()`, owned by `StdlibConfig` + since roadmap 7.1.1 (`src/stdlib/time/clock/mod.rs`). +- **Query classification.** In `netsuke help targets`, a `when` expression that + calls a disabled helper does not fail; `classify_query_evaluation` + (`src/manifest/jinja_macros/query_classification.rs`) recognizes the query + refusal and the entry is kept with `conditional: true`. +- **Invariants I4, I5, I7.** Named obligations from the technical design §12. + I4, semantic fidelity: a manifest with no doubles whose file observations are + confined to the sandbox gives identical results under `netsuke test` and + under the build. I5: under test, no build command, network socket, or host + environment read occurs without explicit opt-in. I7, build-path neutrality: + the build behaves identically whether or not test machinery exists. +- **Differential test.** A test that runs two code paths over the same input + and asserts their outputs are identical, so neither path needs a handwritten + expected value. +- **Characterization snapshot.** An `insta` snapshot recorded from the + *unchanged* code before a refactor, used afterwards as the oracle proving the + refactor preserved behaviour. +- **Canonical form.** JSON serialized with sorted keys through the existing + `serde_json_canonicalizer` dependency, so `HashMap`-backed fields such as + `NetsukeManifest::vars` snapshot identically in every process. +- **`scrutineer`.** The agent that runs the repository gates sequentially and + reports results with log paths. Only it runs full gates. +- **Whitaker test-module split.** The repository convention for large test + modules: a `#[cfg(test)] #[path = "…_tests.rs"] mod tests;` child file, so + production files stay under the 400-line cap and Whitaker's module lints are + satisfied. +- **Driving port / driven port.** Hexagonal-architecture vocabulary. A driving + port is how callers invoke the domain (here, `from_str_with_options`); a + driven port is what the domain needs from outside (here, the environment + reader, clock, and template helpers). + +## Context and orientation + +Assume no prior knowledge of this repository. This section describes the code +as it stands on `main` at commit `486b876c`, which this branch was rebased onto +on 2026-10-14. Line numbers are indicative; anchor on the named items, because +line numbers drift. + +Module paths were reconciled against that rebase: `main` landed a module-layout +migration between this plan's draft and its implementation, so several files +named below are now directory modules. See `Surprises & discoveries` for the +full reconciliation. + +### The loader core + +`src/manifest/mod.rs` owns the private function `evaluate_manifest`, the only +place a manifest is evaluated. It receives a private bundle, `ManifestParse`, +holding the diagnostic name, an `Option`, the environment +reader and access policy, an optional manifest root (anchoring relative +`glob()` patterns), an optional expansion-report observer, and resource-budget +limits. Its order of work is: + +1. notify `ManifestLoadStage::InitialYamlParsing`; charge the source budget; + parse YAML with `serde_saphyr` into a `serde_json::Value` tree; +2. create a MiniJinja `Environment` with strict undefined behaviour; +3. register `env()` over the injected reader and policy, and `glob()` over the + manifest root; +4. register the stdlib according to `StdlibRegistration`: `Full(config)` calls + `stdlib::register_with_config`, `ManifestQuery` calls + `stdlib::register_manifest_query` (which *overwrites* `env` and `glob` with + refusing stubs), and `None` calls `stdlib::register` (which builds an + ambient configuration from the current directory); +5. register manifest `vars` as globals (`src/manifest/registration.rs`, which + also rejects the reserved names `env` and `glob`); +6. notify `TemplateExpansion`; register manifest macros + (`src/manifest/jinja_macros/mod.rs`), which add one global per macro and + append `{% from … import … %}` lines to the global + `__netsuke_manifest_macro_imports`, prepended to every rendered template; +7. run `expand_foreach_with_budget`, which evaluates `foreach` and `when` + against the raw value tree; +8. notify `FinalRendering`; deserialize into `NetsukeManifest`; render string + fields (`src/manifest/render/mod.rs`, whose `RenderMode::ManifestQuery` + skips command rendering); validate recipes. + +The function body is about 68 lines; `clippy.toml` caps functions at 70 lines +and cognitive complexity at 9, so it must be split before it grows. +`src/manifest/budget/adapter.rs::from_str_named` wraps `evaluate_manifest` and +records budget-exhaustion telemetry except for manifest queries. + +### The entry points + +`src/manifest/mod.rs` exposes `from_str` and `from_str_with_env`. +`src/manifest/parse_with_config.rs` exposes `from_str_with_env_and_config` and +`from_str_with_env_and_policy`. `src/manifest/path_loaders.rs` exposes seven +path-based loaders: `from_path`, `from_path_with_policy`, +`from_path_with_policy_and_limits`, `from_path_with_policy_and_env`, +`from_path_with_policy_and_env_and_limits`, +`from_path_with_policy_and_environment`, and +`from_path_with_policy_and_environment_and_limits`. The last is the production +build's choice (`src/runner/generation.rs`, +`load_manifest_for_build_with_limits`); two of the seven are reached only +through other wrappers. Each path loader ends in +`src/manifest/query.rs::from_path_with_registration`, which opens a +capability-scoped workspace, reads the file, and builds a `StdlibRegistration` +from a second private enum, `ManifestLoadMode` (`Full(NetworkPolicy)` or +`ManifestQuery`). `query.rs` also owns the crate entry +`from_path_for_manifest_query_with_limits`, used by `netsuke help targets` +(`src/runner/generation.rs::load_manifest_with_limits`). + +### The restricted-load precedent + +`netsuke help targets` established a side-effect-free load. Its pieces are: + +- `src/stdlib/register.rs::register_manifest_query`, which registers lexical + path filters, collection filters, and clock-independent time helpers, then + refusing stubs for `env`, `glob`, `fetch`, `shell`, `grep`, `contents`, + `which` (function and filter), `command_available`, `now`, `realpath`, + `expanduser`, `size`, `linecount`, `hash`, and `digest`; +- `manifest_query_operation_error(operation)`, which builds the stub error from + a hard-coded English marker (`MANIFEST_QUERY_DISABLED_HELPER_MARKER`). The + query stubs for `hash`, `digest`, and `contents` accept fewer arguments than + the real filters, so a call with keyword arguments fails on arity instead of + refusing. **Path reconciled at rebase:** `main` already extracted this + function, the marker, `is_manifest_query_disabled_error`, and the stub + registrations into `src/stdlib/register/query_helpers.rs`, a `#[path]` child + of `register.rs`. The remaining `register.rs` re-exports + `is_manifest_query_disabled_error` and declares the marker; +- `is_manifest_query_disabled_error`, which recognizes that marker by + substring. `classify_query_evaluation` applies it in *every* mode today, and + a match turns the `when` result into `WhenResolution::Conditional`: the entry + is kept and marked `conditional: true` (`src/manifest/expand/mod.rs`); +- `src/manifest/env/reader.rs::disabled_env_reader` (`pub(super)`), a reader + that answers every lookup with `EnvReadError::NotPresent`. + +File sizes at `486b876c`: `src/stdlib/register.rs` is 315 lines and its +`query_helpers.rs` child 151, so EP-M1's extraction there shrinks to relocating +the stub cluster into the new `restricted_helpers/`; +`src/manifest/jinja_macros/mod.rs` is still 394 lines and remains at the cap, +so the classifier extraction it needs is unchanged. The repository caps code +files at 400 lines. + +### What the full stdlib touches on the host + +`register_with_config` installs a legacy Boolean formatter (so `true` renders as +`true`, not `True`), the file tests in `FILE_TESTS` (`dir`, `file`, `symlink`, +and others) that open parent directories with ambient authority +(`src/stdlib/path/fs_utils.rs::parent_dir`), path filters of which `realpath`, +`size`, `contents`, `linecount`, `hash`, and `digest` read the *ambient* +filesystem relative to the process working directory +(`src/stdlib/path/filters.rs`), `expanduser` (reads the configured home), +`which` and `command_available` (search `PATH`, falling back to the host `PATH` +when `StdlibConfig::path_override` is unset), `now()` (reads the configured +clock), `fetch` (network), and `shell`/`grep` (spawn processes). Manifest-query +registration does *not* install the Boolean formatter. + +Localized diagnostics that callers must recognize carry a bracketed code copied +verbatim into every catalogue, for example +`stdlib.which.not_found = [netsuke::jinja::which::not_found] …` in +`locales/en-US/messages.ftl`. There are 35 catalogues under `locales/`. + +### The governing design + +`docs/netsuke-test-framework-technical-design.md` §4.2 and §4.3 specify this +task. The design predates several loader changes (the `ManifestEnvironment` +bundle, `ManifestBudgetLimits`, the manifest root, and ADR-0050's query-mode +telemetry suppression), and its public-field sketch leaves overlays outside the +mode that grants them. `Decision log` records each reconciliation; EP-M5 writes +them back into the design. + +### Files a novice will touch + +| Path | Change | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `src/manifest/loader_options.rs` (new) | `ManifestLoadOptions`, `from_str_with_options`, host-helper registration | +| `src/manifest/test_surface.rs` (new) | `TestSurface`, `TemplateOverlays`, `OverlayCallable`, `OverlayCall` | +| `src/manifest/mod.rs` | `StdlibRegistration::{AmbientFull, Test}`; split `evaluate_manifest` | +| `src/manifest/budget/adapter.rs` | ask the registration whether to record telemetry | +| `src/manifest/parse_with_config.rs`, `path_loaders.rs` | thin wrappers over the options entry | +| `src/manifest/query.rs` | `from_path_with_options`; `ManifestLoadMode` replaced by a factory | +| `src/manifest/jinja_macros/query_classification.rs` (new) | classifier extracted and gated on query mode | +| `src/manifest/jinja_macros/mod.rs`, `src/manifest/expand/*` | pass the query-classification flag | +| `src/stdlib/restricted_helpers/mod.rs` (new) | surface inventory, query and test refusing stubs | +| `src/stdlib/register.rs`, `src/stdlib/mod.rs` | delegate restricted registration; `register_for_test` | +| `src/stdlib/time/clock/mod.rs` | re-document `WallClock::is_system` as a policy input | +| `src/localization/keys.rs`, `locales/*/messages.ftl` | two new keys | +| `src/manifest/tests/*.rs`, `src/stdlib/restricted_helpers/tests.rs` | unit, inventory, and internal differential tests | +| `tests/manifest_load_options_tests.rs`, `tests/manifest_load_options/*` | public differential and property tests | +| `tests/features/manifest_load_options.feature`, `tests/bdd/…` | behavioural scenarios, steps, and `TestWorld` slots | +| `tests/ui/manifest_load_options_embedder_pass.rs` | compile-pass fixture pinning the public surface | +| `docs/…` | design, ADR-0070, ADR-0050 addendum, guides, contents, roadmap | + +*Table 1: Files in scope.* + +### The integration-test wiring contract + +`tests/integration_test_wiring_tests.rs` fails if a `tests/*/mod.rs` tree is +not declared from some top-level `tests/*.rs` file, or if Cargo's discovered +test targets differ from the `tests/*.rs` files on disk. A new top-level +`tests/manifest_load_options_tests.rs` is automatically a target; its +submodules under `tests/manifest_load_options/` must be declared with `mod`. +The behavioural suite is `tests/bdd_tests.rs`, whose +`scenarios!("tests/features", …)` collects *every* `.feature` file, and +`rstest-bdd-macros` runs with `strict-compile-time-validation`: a feature file +whose steps do not exist fails to compile the whole behavioural binary, so a +feature file and its steps must land in the same commit. + +### Relevant documentation and skills + +Read before starting: `AGENTS.md`; +`docs/netsuke-test-framework-technical-design.md` §§4, 5, 7, 12–14; +`docs/netsuke-test-framework-ux-design.md` §§1, 7–8; +`docs/rfcs/rfc-0007-netsukefile-testing-framework.md`; `docs/netsuke-design.md`; +`docs/adrs/adr-0008-environment-seam-taxonomy.md`; +`docs/adrs/adr-0010-scope-the-glob-metadata-capability-to-the-literal-prefix.md`; +`docs/adrs/adr-0050-bound-manifest-template-evaluation.md`; `docs/polonius.md`; +`docs/rust-testing-with-rstest-fixtures.md`; `docs/rstest-bdd-users-guide.md`; +`docs/rust-doctest-dry-guide.md`; +`docs/reliable-testing-in-rust-via-dependency-injection.md`; +`docs/snapshot-testing-in-netsuke-using-insta.md`; +`docs/localization-styleguide.md`; `docs/translators-guide.md` (key audit and +right-to-left rules); `docs/developers-guide.md`; +`docs/documentation-style-guide.md`; `docs/ortho-config-users-guide.md` (for +the reason recorded in `Decision log` that this task adds no configuration +surface); and the completed plan `docs/execplans/7-1-1-clock-provider-seam.md`. + +Skills to load: `rust-router` first, then `rust-types-and-apis` (options, +opaque constructors), `nll-to-polonius` (the borrowed `ManifestEnvironment` and +stage observer), `rust-errors` (typed refusal sources), `rust-unit-testing` +(rstest tables, googletest matchers, insta settings), `proptest` (manual +`TestRunner` with counters), `hexagonal-architecture` (capability policy in one +place), `execplans`, and `en-gb-oxendict`. + +## Constraints + +These are hard invariants. Violating one requires escalation, not a workaround. + +- C1. The build path's observable behaviour must not change. Every pre-existing + test, snapshot (`tests/snapshots/**`, `src/snapshots/**`), and behavioural + scenario passes without modification. A changed pre-existing snapshot is a + stop condition. +- C2. `netsuke help targets` behaviour must not change, including the exact + text of its refusal message. +- C3. Existing public entry points keep their exact signatures and semantics + and become thin wrappers; none is removed or renamed. +- C4. Test mode never consults ambient state: no host environment variable, no + implicit clock, no ambient filesystem path, no network, and no process spawn + is reachable from a test-mode load unless a caller explicitly injects it (an + environment reader, a clock provider via `StdlibConfig::with_clock`, or an + overlay). +- C5. Capabilities for a load mode are decided in one place, + `StdlibRegistration`, including whether `env()` and `glob()` are real. No + second mode enum survives. +- C6. User-facing refusal and collision text goes through Fluent, and every + diagnostic a caller must recognize carries a bracketed code identical in all + catalogues. Recognition in code uses typed error sources, never translated + text. +- C7. No new crate dependency. +- C8. No in-process environment mutation in tests; use injected readers. +- C9. The overlay mechanism is compiled unconditionally, not behind + `cfg(test)`, because the test runner is production code of the shipped binary. +- C10. No code file exceeds 400 lines; every module starts with `//!`; every + function carries `///` documentation; `evaluate_manifest` and every new + function stay within `clippy.toml`'s 70-line and complexity-9 limits. +- C11. Respect `POLONIUS(...)` and `POLONIUS-REFUSED(...)` tags; do not add + defensive clones without first compiling the borrowing form. +- C12. After EP-M0, characterization snapshots are never re-accepted. From + EP-M1 onwards `git diff --exit-code` over their directories must pass. The + baseline such a diff is taken against is `847c19cc`, not EP-M0's acceptance + commit: that later commit repaired three vacuous rows and recorded the + exhaustion path separately, and the snapshots have been stable since it. See + the Progress entry for `847c19cc`. + +## Tolerances (exception triggers) + +The counts below exclude this plan, the 35 locale catalogues, `.snap` files, +and YAML fixtures, and were set from the inventory in Table 1. + +- Scope: stop and escalate beyond 42 changed files or 2,400 net added lines. +- Interface: stop if any existing public signature must change, or if a public + item beyond those in `Interfaces and dependencies` seems necessary. +- Dependencies: stop if any new crate, including a dev-dependency, seems + necessary. +- Behaviour: stop if any pre-existing snapshot, doctest, or behavioural + scenario needs editing to pass (C1, C2), or if a characterization snapshot + changes after EP-M0 (C12). +- Iterations: stop if a gate still fails after three fix attempts for the same + root cause. +- Design: stop and set `Status: BLOCKED` if MiniJinja does not replace a + same-named global on re-registration, or if a typed error source attached to + a MiniJinja error does not survive to the loader's `anyhow` chain. +- Ambiguity: stop and present options if a helper's disposition under test is + not settled by the surface inventory in `Decision log`. + +## Risks + +- Risk: converting entry points to wrappers changes error ordering or text, for + example by building an ambient `StdlibConfig` before YAML parsing where today + it is built lazily afterwards. Severity: high. Likelihood: medium. + Mitigation: a named `AmbientFull` registration resolved at the same step as + today; canonical characterization snapshots of results *and* error text from + EP-M0; C12. + +- Risk: a test-mode refusal is classified as a query refusal inside `when`, + keeping the entry as `conditional: true` so the load succeeds with a + different manifest. Severity: high. Likelihood: medium without a structural + fix. Mitigation: the classifier runs only for `ManifestQuery` loads (a flag + passed into expansion), and test refusals carry a distinct typed source; + OBL-4 seeds both faults. + +- Risk: a new stdlib filter or test is added later without a test-mode + decision and leaks host state. MiniJinja 2.24.0 cannot list registered + filters or tests. Severity: high. Likelihood: medium over the project's life. + Mitigation: a single surface inventory drives test registration, and a + source-scanning contract test fails when any `add_function`, `add_filter`, or + `add_test` literal under `src/stdlib/` is missing from it (OBL-3). + +- Risk: characterization snapshots flake because `NetsukeManifest::vars` is a + randomly seeded `HashMap` and `serde_json` preserves insertion order, and a + developer re-accepts a genuine regression. Severity: high. Likelihood: high + without canonicalization. Mitigation: canonical form for every snapshot; C12. + +- Risk: adding two Fluent keys to 35 catalogues breaks the build-time key audit + or the right-to-left test. Severity: medium. Likelihood: medium. Mitigation: + change `keys.rs` and every catalogue in one commit; begin `ar`, `fa`, and + `he` messages whose first token is Latin or a placeable with U+200F as + `docs/translators-guide.md` requires. + +- Risk: `src/stdlib/register.rs` (398 lines) and + `src/manifest/jinja_macros/mod.rs` (394 lines) overflow the cap. Severity: + medium. Likelihood: certain. Mitigation: EP-M1 extractions before feature + work. + +- Risk: the public overlay signature cannot express spy passthrough, forcing a + breaking change in 7.1.3 or 7.3.1. Severity: medium. Likelihood: high with a + bare `(&State, &[Value])` closure. Mitigation: overlays receive an + `OverlayCall` context with private fields and accessors, including the + shadowed function. + +- Risk: technical-design divergence confuses later roadmap items. + Severity: low. Likelihood: certain. Mitigation: EP-M5 updates the design in + the same change set, and ADR-0060 records the substantive decisions. + +- Risk: a peer session pushes to this branch or `main` moves. + Severity: low. Likelihood: low. Mitigation: fetch before every push; after + any rebase, re-check ADR numbering and rerun every gate. + +- Risk: a milestone gate reports red for host load rather than for the + candidate, and the red is either committed past or mistaken for a defect. + Severity: medium. Likelihood: high — the Stage A baseline already hit it once + (`cli_configuration_fixture_compiles` at the 300 s cap with the four-slot + compiler pool fully held by peer worktrees). Mitigation: before treating a red + `make test` as a defect, read the queue census from its log (compile-slot + waits, sccache-unreachable notices, `build-limits:` admission failures) and + confirm the diff against `origin/main` does not touch the failing test, its + nextest policy, or the crate it builds. Re-run rather than repair; the + repository deliberately keeps this budget at five periods. + +## Progress + +- [x] (2026-09-27) Renamed the branch to + `7-1-2-options-carrying-manifest-loader-entry-point` and set its upstream. +- [x] (2026-09-27) Reconnaissance of the loader, query mode, stdlib + registration, and test infrastructure. +- [x] (2026-09-27) Drafted this ExecPlan. +- [x] (2026-09-27) Expert-panel review (structure and contracts, failure modes + and verification, alternatives and viability); all three returned "revise". +- [x] (2026-09-27) Revised the plan to address every panel finding (see the + revision note). +- [x] (2026-09-27) User decision on the file-reading filters: extend roadmap + 7.4.1 to root them at the sandbox. Roadmap 7.4.1 and design §5.5 updated in + the plan's pull request. +- [x] (2026-10-14) User approval of the plan and the listed deviations, given + by explicit instruction to proceed under the `sdlc-implementation` workflow. +- [x] (2026-10-25) The user explicitly reconfirmed approval before this + rebase; the header was set to `APPROVED` before history rewriting. +- [x] (2026-10-14) Rebase onto `origin/main` `486b876c` (64 commits ahead of the + plan's base `ebcedaef`); rehearsed in a scratch clone first, applied with + zero conflicts, pushed, and local/remote/tracking refs verified equal. +- [x] (2026-10-14) Plan corrections for the rebase: base commit, module paths + after `main`'s layout migration, and ADR-041 renumbered to ADR-055 (since + renumbered again to ADR-0060; see `Surprises & discoveries`). +- [x] (2026-10-14) Stage A baseline: the full gate set run sequentially by + `scrutineer` against the untouched branch at `0b2a366b`. Six gates pass; + `make test` is red from compiler-admission contention, not from this + candidate. See `Artefacts and notes`. +- [x] (2026-10-15) EP-M0 — characterization baseline. Seven new fixtures under + `tests/data/manifest_load_options/` (the four planned plus three recorded as + a deviation), plus the reused `tests/data/glob.yml`; the public suite + (`tests/manifest_load_options_tests.rs`, `tests/manifest_load_options/`) with + 12 rows over the eleven public entry points; the crate-internal rows in + `src/manifest/tests/wrapper_equivalence.rs` and + `src/stdlib/register/refusal_snapshots.rs`. Every snapshot reviewed by + reading before acceptance. No production file changed: the one source edit + outside tests is `stable_snapshot_settings` in + `src/snapshot_test_support.rs`, which `src/lib.rs:30` gates behind + `#[cfg(test)]`, and `src/stdlib/register.rs` gains only a `#[cfg(test)]` + child declaration. +- [x] (2026-10-15) EP-M0 gate repair. `make check-fmt` and + `make doc-coverage` pass at `71f26e1f`; `make lint` was **red** on a + `clippy::shadow_reuse` in `src/manifest/tests/wrapper_equivalence.rs`, and + the abort left every integration-test target behind it unlinted. Linting them + revealed eight findings in the EP-M0 suites, all repaired in `fbf221f0`; the + characterization snapshots are unchanged, which is the evidence the repair is + a refactor and not a behaviour change. See `Surprises & discoveries`. +- [x] (2026-10-15) Diagrammed the four `StdlibRegistration` modes in technical + design §4.3 (`04aa8366`), with the screen-reader description and caption the + style guide requires. +- [x] (2026-10-15) EP-M0 second gate repair. `make lint` was **red** at + `1d75efce` on two Whitaker `conditional_max_n_branches` findings that Clippy + cannot reach, and the abort left `yamllint`, `actionlint`, and every Python + lint stage unrun. Repaired in `9191699b` by extracting the two disjunctions + into named predicates on `Capabilities`. `make test` also timed out on an + admission-pool-bound row (see `Surprises & discoveries`). +- [x] (2026-10-15) EP-M0 gate re-run at `ca7ed7d4`: **eight of nine gates + green**, including the full `lint` cascade whose Python and YAML stages the + earlier abort had concealed. `make test` timed out on a third distinct + nested-Cargo row, which — with the rows all belonging to one serialized group + — is the evidence that the red is queue latency rather than the candidate. + `doctest` is unverified, not failed. See `Artefacts and notes`. +- [x] (2026-10-15) Documentation-delta gate re-run at `f1b4b03d` and again at + `87ff771f`, the head that carries the EP-M0 records and the corrections + above: **all five gates green** (`check-fmt` 2 s, `markdownlint` 29 s, + `spelling` 10 s, `nixie` 4 s, `test-workflow-contracts` 126-134 s). + `make spelling` did not rewrite `typos.toml`. The previous batch's greens do + not transfer to a changed head, which is why this was re-run rather than + assumed. At `87ff771f` the logs are + `/tmp/{check-fmt,markdownlint,spelling,nixie,test-workflow-contracts}-87ff771f.out`; + at `f1b4b03d` the same names carry that revision's suffix. **These five do + not cover the plan's Rust-read contracts** — see the gate-set correction + below. +- [x] (2026-10-15) EP-M1 read-only recon completed while the candidate was + frozen: the correction to the layout rationale (below), the Kani scope + entries, the placement rule, and the forced-edit inventory. EP-M1 itself + remains unstarted. +- [x] **Gate-set correction for plan edits (discharged 2026-10-31).** Running + the cheaper documentation gates is **not sufficient** for a diff that touches + this plan. *(2026-10-16 update: the batch at `301f2456` did run `make test` + against this plan and was green, so the deferral below is discharged for that + revision — but the F-1 edit re-opened the obligation, and the review-repair + edit above re-opens it again, so the item stays unchecked until the batch + covering the latest edit is green.)* + `tests/execplan_status_contract_tests.rs` reads every `docs/execplans/*.md` + at test time and asserts that the header carries exactly one line-initial + `Status:` field whose value is in the closed set + `DRAFT | APPROVED | IN PROGRESS | BLOCKED | COMPLETE` (and that + `docs/documentation-style-guide.md` documents the same set). It is the sole + Rust test that reads `docs/execplans/` from disk: + `tests/kani_scope_wrapper_e2e_tests.rs` names this plan only in its `//!` + header — its `open_ambient_dir` calls are `/tmp` and `/sys/fs/cgroup`. No + Markdown gate sees any of that — form, not a Rust contract — so `make test` + belongs in the plan-touching set. The current header satisfies both rules: + `grep -n '^Status:'` over the whole file returns exactly one hit, the header + field whose value is `IN PROGRESS`. The mid-document `Status: BLOCKED` inside + the `Design: stop and set …` bullet under + `## Tolerances (exception triggers)` is removed by two independent mechanisms + — `header_lines()` stops at the first `##` heading, and the filter is + `line.starts_with("Status:")`, which that bullet fails anyway because it + opens with a list marker and the word `Design`, not with `Status:`. The + obligation is discharged by the `61e6cf102` batch, whose `make test` was + green at 4833 passed / 7 skipped plus 166 doctests; see `Progress`. +- [x] (2026-10-15) The four queued EP-M1 plan corrections applied. The + placement bullet now prefers `restricted_helpers/tests.rs` and rests on the + destination the moved file already names (`refusal_snapshots.rs:25-26`), the + 18 `mod.rs`+`tests.rs`-only directories, and the retirement of the `#[path]` + at `register.rs:39` — **not** on file count, because an earlier draft's + premise that no `tests/mod.rs` holds one child is false: 8 of the 17 do. Two + further false claims were found and removed while applying them: that + `src/manifest/tests/` holds no `mod.rs` (it does, declared by a plain + `mod tests;` at `src/manifest/mod.rs:249`), and the escaped-backtick code + span whose quoting made mdtablefix emit a 94-column line it then accepted as + its own fixed point. Also recorded: EP-M0's split of + `tests/manifest_load_options/wrapper_equivalence.rs` into + `wrapper_equivalence/mod.rs` plus `wrapper_equivalence/path_rows.rs`, which + the plan did not carry. The two artefact paths that named the pre-split file + — in the OBL-1 statement and in `Stage B` — were corrected in the same edit, + so no citation in this plan now points at a line number a later reflow can + invalidate. +- [x] (2026-10-15) Plan-touching gate batch run against the frozen pair: **red, + 3 of 7**. Green: `check-fmt` (ruff 250 files; mdtablefix + `183 files left unchanged`, independently confirming the reflow repair), + `typecheck`, `doc-coverage` (65 validator tests; 98.87% against the 80% bar). + Three independent causes, none of them the plan text itself. (1) + `markdownlint` MD038, introduced by the plan edit — a code span written + `` `## ` `` whose trailing space markdownlint rejects; fixed to `` `##` ``. + (2) `lint` aborted at `lint-clippy` on `clippy::shadow_reuse` in + `tests/manifest_load_options/fixtures/canonical.rs:86`, where the + `StageSeam::Accepted` arm rebound `stages` over the parameter of the same + name; renamed to `notified`, so `lint-whitaker`, `lint-python`, and + `github-actions-lint` never ran and remain unverified. That file was last + touched by `847c19cc` and the shadow is present verbatim in it, so the red + **predated this candidate** and was never caused by the plan edits. (3) + `nixie` diagram 2, the `Figure 2` flowchart `04aa8366` added, whose + `Inject[Use the caller's env reader and clock]` label carries an apostrophe + that mermaid's lexer reads as a string delimiter — the bare label is + unterminated from that point. Quoted to `Inject["…"]`, matching the shape + already used at `netsuke-test-framework-technical-design.md:86`. All three + repaired; `make nixie`, `make markdownlint`, and a targeted + `cargo clippy --test manifest_load_options_tests` now exit 0 (the last after + 8m20s of compile-slot waiting), and repo-wide mdtablefix still reports 183 + unchanged. `make test` was red on + `build_module_slice_ui_tests::production_build_module_slice_has_expected_boundary` + timing out at 300 s under a saturated host, with 16 rows never run and the + doctest pass never reached — a skipped pass is not a pass, so both remain + pending rather than passed. +- [x] (2026-10-16) Repairs confirmed at full scope: **six of seven gates + green**. `check-fmt`, `typecheck`, `lint`, and `doc-coverage` exited 0, and + `markdownlint` and `nixie` cleared the two Markdown defects. The `lint` gate + is the one worth recording, because it went **past** `lint-clippy` for the + first time on this branch: all four stages ran — clippy, `lint-whitaker` over + both `netsuke-build` and `test_support`, `lint-python` (ruff, pylint at + 10.00/10, the df12 house lints, `ambrleaks`, and `interrogate` at 100%), and + `github-actions-lint` (yamllint plus actionlint). The `shadow_reuse` string + appears zero times in that log. Provenance held: all three frozen hashes were + identical at the start and the end of the batch, and `git status --porcelain` + was byte-identical at both ends. Repaired in `12e8d47c`. +- [x] (2026-10-16) The one red is the plan's own Risk `:433` case, now measured + rather than predicted: `make test` aborted on + `build_module_slice_ui_tests::production_build_module_slice_has_expected_boundary` + terminating at 300.009 s, with 3927 of 3928 run passing, 6 skipped, and 16 + never run. The queue census is 227 compile-slot waits, a peak of 341 ahead, + 574 sccache-unreachable notices, and **zero** `build-limits:` admission + failures — so admission never refused and this is queue latency. The changed + surface touches the failing test's package but not its test file or its + nextest policy, and the `manifest_load_options_tests` target passed 13 rows + against 0 failures. Disposition unchanged from `:1839-1842`: **re-run, do not + repair**, and do not widen the budget. The doctest pass is still unrun — a + fourth abort short-circuited it — so it remains unknown, not passed. +- [x] (2026-10-16) Milestone CLI review (`coderabbit review --agent` through + `scrutineer`) returned **three findings over 39 reviewed files**, of which + **two are unique** — the two minor findings are one defect worded twice on + the same lines. Both unique findings were verified against source before + repair, and both are repaired in `8cf507c2`. + - **major, the temporary-path filter in both of its copies** + (`src/snapshot_test_support.rs` and + `tests/manifest_load_options/fixtures/settings.rs`): the pattern required + **exactly one** separator between prefix components. A successful manifest + snapshot holds a Windows path **twice** — a diagnostic quotes it with the + single backslashes `temp_dir()` renders, while the canonical renderer + serializes the same path through `serde_json`, which **doubles** every + backslash. `temp_dir().display()` returns the raw (single) form, so a + pattern built from it could never match the serialized form, and it is the + serialized form that carries the run-specific workspace name. The filter now + emits a separator class matching **one or more** of either separator, and + trims a trailing separator first so two adjacent classes do not demand two + separators where the text has one. This is the **second** round on this + filter: `51fb1b49` widened a literal `/` to the class `[\\/]`, which is + "either separator" but still exactly one character — the round-one repair + was incomplete, and its own commit message recorded a green that was not + evidence. + - **minor, `docs/netsuke-test-framework-technical-design.md` (duplicate):** + the paragraph claimed `ManifestQuery` reads the process environment. It + cannot. `register_env_function` runs before the mode match + (`src/manifest/mod.rs:135`) and installs the real `env`; + `register_manifest_query` then registers `env` as a **refusal** over it, and + MiniJinja's `add_function` reaches `Arc::make_mut(..).insert(..)` — an + overwrite — so the refusal wins. `AmbientFull` is the only mode that reads + the process environment. That variant is planned for EP-M2 and is absent + from `src/`, so the correction phrases it as the design's future mode rather + than inventing implementation. +- [x] (2026-10-16) The filter repair is guarded by an oracle that can see the + defect. `temp_dir_filter_pattern` is now a pure function of its **argument**, + because the defect is invisible on Linux: a test that called `temp_dir()` + could only ever guard the platform that recorded the snapshot. That + extraction is what lets the Windows spelling be asserted on any host. The + `rstest` carries five cases — a diagnostic Windows path with single + backslashes, a serialized Windows path with doubled ones, a Unix path, a + trailing separator, and a sibling directory that merely shares the prefix + (`/tmpfoo/ws`, which must be left alone). Non-vacuity was proved by reverting + the pattern **one clause at a time** and confirming that each revert fails + **exactly one** row and leaves the other four passing: dropping the + one-or-more quantifier fails only the JSON-escaped row, and removing the + trailing-separator trim fails only the trailing-separator row. Two clauses, + two disjoint failures — so no row is carrying the other's assertion and none + is tautological. Round one had no such check, which is why it recorded a + green while the defect was live. The rows use `ensure!` and return + `anyhow::Result` rather than asserting, because `clippy::panic_in_result_fn` + is denied workspace-wide (`Cargo.toml:223`) and an assertion inside a + `Result`-returning test trips it. +- [x] (2026-10-16) A **third** false claim sat next to the code under repair, in + the integration fixture's module header: it explained the filter by saying + the loader "puts that workspace's root into the manifest it returns". + `NetsukeManifest` has no root or path field — it carries `netsuke_version`, + `vars`, `macros`, `rules`, `actions`, `targets`, and `defaults` — and + `glob()` returns **relative** template paths. The filter's live justification + is error text: a path entry point is handed an absolute path and quotes it + back in an error. Corrected in the same commit, since it sat adjacent to the + changed lines. +- [x] (2026-10-16) Repair batch at `8cf507c2`: **all seven gates green**, and + the doctest pass **ran for the first time on this branch**. `check-fmt` (183 + files left unchanged), `typecheck` (`ty` clean), `lint` (all five stages + complete, `panic_in_result_fn` appearing zero times, so the abort that opened + this sequence is confirmed fixed), `doc-coverage` (98.87%, aggregate + 4988/5045, against the 80% bar), `markdownlint` (0 issues), and `nixie` (all + diagrams validated). `make test` aborted on its first attempt at a single 300 + s per-test timeout — `cli_configuration_fixture_compiles`, the same + nested-compile class as Risk `:433` — with 15 rows never run and the doctest + pass short-circuited, and **passed on the mandated re-run**: 3954 of 3954, + plus a doctest pass at 0 failures. That pass is 129 passed and 32 ignored + across the two targets: `netsuke` contributed 88 passed and 26 ignored, then + 2 compile-fail rows, then `test_support` contributed 39 passed and 6 ignored. + The announced `running 114` and `running 45` counts include the ignored rows, + so neither is a pass count. Provenance held: HEAD `8cf507c2` and all three + blob hashes identical at both ends of the batch, and `git status --porcelain` + empty at both. +- [x] (2026-10-16) Second milestone CLI review (`coderabbit review --agent` + through `scrutineer`) ran against the frozen clean tree at `cc7f0084` and + returned **one `trivial` finding over 39 reviewed files**, with + `outcome: completed` and no rate-limit text. The log is 3 JSONL lines — one + `review_context`, one `finding`, one `complete`, and **zero heartbeats** — at + `/tmp/coderabbit-7-1-2-options-carrying-manifest-loader-entry-point-cc7f0084.out` + (sha256 `c1a18871…`, 4276 bytes). Unlike the first review, its + `review_context` carries **no `baseCommit` field at all**, only + `baseBranch: "main"`; the review binds through the worktree path and the + branch, which is the reason a content edit voids it and the CLI must be + re-run rather than the log re-read. + - **trivial, `tests/manifest_load_options/fixtures/settings.rs:67-89` + (`[type:docstyle]`), "Remove the duplicated temporary-path filter."** — + asks that the pure `temp_dir_filter_pattern` be moved to a home both + snapshot crates can reach, for example `test_support`, with each crate + keeping its own `Settings` wiring. **Repaired**, on the remedy's own merits. + The finding's stated reason is false and is recorded as such: it claims "the + first repair round already missed one copy's defect", but `51fb1b49` + changed both files and left both holding the same incomplete pattern, so + nothing was missed. The duplication was nonetheless real and independently + triggers the `AGENTS.md` refactoring heuristic. + - **Why the remedy was accepted despite the false premise.** Two copies, each + guarded only by its own copy of the same five `rstest` cases, can drift + apart while both suites stay green — the guard travels with the copy, so + neither copy's tests can see the other. Consolidating removes the class of + defect instead of adding a check for it. Both copies' cases now run once, + against one builder, in the crate both snapshot trees already depend on. + - **Two entries in the plan's own `Constraints` were checked before the + move.** C7 ("no new crate dependency") is not violated: `regex` is already + in `Cargo.lock` at 1.13.1 as a dev-dependency of the root crate, so this + adds a second **consumer** of an already-resolved crate, not a new crate. + C10 (400-line cap) is satisfied: the new module is 128 lines, and both + edited files **shrank** — `src/snapshot_test_support.rs` 263 to 198, + `tests/manifest_load_options/fixtures/settings.rs` 144 to 55. + - **Non-vacuity was established by mutation, not by inspection.** Reverting + the shared builder's separator from the one-or-more class `[\\/]+` to the + single-character class `[\\/]` — that is, re-introducing the round-one + mistake exactly — fails **case 2 alone**, the JSON-escaped Windows row, with + `expected "{\"path\": \"[TMP]\"}"`, `got "{\"path\": \"C:\\\\Users\\\\…\"}"`; + the other four rows still pass. That signature is the same one the plan + already records for the original defect, which is the evidence that the + extracted oracle guards the real property rather than tautologically + agreeing with whatever the builder emits. +- [x] (2026-10-15) `847c19cc` ("Make the characterization rows record what they + claim") **changed 11 characterization snapshots** and added + `budget_exhaustion.snap` — all of `tests/snapshots/manifest_load_options/`. + This is the one commit after EP-M0's acceptance that re-accepted snapshot + text, and it is **not a C12 breach**: C12's operative clause is "From EP-M1 + onwards", and EP-M1 is unstarted; the commit touched **zero `src/` files** + (`git show --name-only` lists only `tests/`), so C1 is untouched and no + production behaviour is implicated; and the rows it fixed could not have been + meaningfully re-accepted, because three `_and_limits` rows passed + `exhausted_budget()`, so every fixture failed before parsing and all rendered + the identical "budget exhausted at Source (limit 8)" text — one row's own doc + comment claimed it "still shows a successful load" while its body passed the + exhausted budget. A row that cannot move when ordinary loading changes is not + a baseline, and repairing it necessarily changes its output. + - **`847c19cc` — not EP-M0's acceptance commit `c4ca78b8` — is the C12 + baseline.** C12's text now says so. The distinction is load-bearing for a + future EP-M1 worker: diffing against `c4ca78b8` shows 13 files changed and + 445 insertions against 35 deletions, which would force a choice between + wrongly escalating a stop condition and wrongly concluding C12 is already + broken. Diffing against `847c19cc` is clean, and has been across the 11 + subsequent commits up to and including this one. +- [x] (2026-10-16) The milestone CodeRabbit review at `925f803e` returned one + `minor` finding on `docs/netsuke-test-framework-technical-design.md:247`, and + it is **valid on both of its claims** — verified against the document's own + prose rather than accepted on the reviewer's word, then repaired. + - First claim: `Mode -->|AmbientFull or Full| Build`, with + `Build --> Inject["Use the caller's env reader and clock"]`, sent + `AmbientFull` through caller injection. The screen-reader paragraph three + lines above the fence says the opposite — "`AmbientFull` reads the process + environment" at `:237-239` — and the Decision log at `:642` states + "`AmbientFull` is the only mode that reads the process environment", so the + diagram contradicted its own surrounding text. + - Second claim: `RestrictedTest` fanned out to `Inject` **and** `Overlay` as + two parallel edges both reaching `Expand`, which reads as either step being + sufficient. The registration list at `:214-225` orders them — overlays are + step 5 and expansion is step 6 — so the `Test` path is now drawn as + `RestrictedTest → TestEnv → Overlay → Expand`. + - Repair: the mode branch now has four arms, `AmbientFull` and `Full` are + separate nodes, `AmbientFull` reads the process environment, and `Full` and + `Test` each take the caller's reader and clock (still two nodes, because + `Test` alone continues to overlay registration). The prose and the Figure 2 + caption were already correct and are unchanged; only the figure was wrong. + - **The quoted-label trap applies to any edit here.** Two labels carry an + apostrophe, so they are quoted — the unquoted form is what `04aa8366` + shipped and `make nixie` caught (see the Progress entry for that commit). + New edges were also checked for undefined and orphan nodes, and the diagram + was parsed directly with `merman-cli` before the gate ran. + - The edit keeps the file's own 80-column tightness: the first draft reached + 98 columns in the fence, which MD013 permits (`code_block_line_length` is + 120) but which no line in this document had used at either the base or the + pre-edit revision. Splitting the branch arms removed the need. + - `make nixie` passed on the repaired revision ("All diagrams validated + successfully!") and the read-only mdtablefix probe reports the file + unchanged. **The repair voids the `925f803e` review**, so the milestone + review must be re-run against the new head; this entry records the finding + as repaired, not as closed. +- [x] (2026-10-16) Gate batch and milestone review at `52992f4c`, the + review-repair head: **nine of nine gates green**, then the re-run milestone + review, which returned **one `minor` finding** on + `docs/netsuke-test-framework-technical-design.md:406` — valid, and repaired + in the next commit. + - The nine gates ran sequentially under the exclusive gate-runner against the + frozen revision, with the tree verified clean before and after: `check-fmt` + 2 s, `typecheck` 884 s, `lint` 2679 s (`RESULT: PASSED (minimum: 100.0%, + actual: 100.0%)`), `doc-coverage` 771 s (98.87% against the 80% bar), + `test` 445 s, `markdownlint` 29 s, `nixie` 1 s, `spelling` 9 s, + `test-workflow-contracts` 144 s. Evidence: `/tmp/pr807-batchD-52992f4c/`. + - `make test` reached green on its **third** invocation, and the first two are + instrumentation, not candidate, failures — the runner's own chunks were + bounded and it killed in-flight tests. Attempt 1 (`…/attempt-2/test.out`) is + a genuine `TIMEOUT [300.015s]` on + `production_build_module_slice_has_expected_boundary` with 18 `SIGTERM` + rows from the chunk bound; attempt 2 is a `SIGTERM [111.879s]` on + `packaged_manifest_retains_build_script_sources` while it was inside its own + 600 s budget, so that attempt was neither a nextest timeout nor a failure of + the test. The brief's "re-run exactly once if it times out" did not fit this + host: the passing run needed to be uninterrupted. **Neither red attempt is + evidence about the candidate.** + - **Timeout census, corrected by counting rather than recollection.** Across + the full-suite `make test` invocations preserved for this branch, split on + the runner's `test invocation` separators and deduplicated by content + SHA-256: **16 distinct invocations, 8 of which ended in a nextest timeout, + 5 green, and 3 killed by the runner's own chunk bound** (instrumentation, + not evidence about the candidate). The three timed-out tests are + `production_build_module_slice_has_expected_boundary` (5 times, at 300 s), + `cli_configuration_fixture_compiles` (2 times, at 300 s), and + `packaged_manifest_retains_build_script_sources` (once, at 600 s) — all in + the `nested-cargo-builds` group (`max-threads = 1`), so every one is a + nested-Cargo test whose wall clock is decided by the shared compile pool. + The earlier figure carried in the PR body draft and the ledger — "five of + six runs, four test names" — was written from recollection and is wrong on + both numbers. The count needs ANSI escapes stripped and an anchor on + `TIMEOUT [` rather than the bare word, because a bare grep also matches test + *names* containing "timeout", and `SLOW [>Ns]` lines are heartbeats that a + **passing** run emits. + - The passing run's `Summary` is `3949 tests run: 3949 passed (1 slow), 6 + skipped`, and **both halves of `make test` genuinely ran** — `Doc-tests + netsuke` (114 announced, 88 passed, 26 ignored, plus 2 compile-fail rows) + then `Doc-tests test_support` (46 announced, 40 passed, 6 ignored). The + announced counts include the ignored rows, so neither is a pass count. This + discharges the "doctest unverified" caveat the earlier batches carried. + - `markdownlint` reports `Linting: 183 files` and then `Summary: 0 issues in + 0 files`. The second line is the tool's own summary format against those 183 + files, not a zero-file run — read the two lines together, because the + summary alone looks like a vacuous pass. + - The ExecPlan contract is confirmed *inside* the gate rather than assumed: + `netsuke-build::execplan_status_contract_tests` ran with 10 PASS rows + including `every_execplan_header_status_is_within_the_closed_set`. No test + output names this plan, so "the contract passed" is the strongest available + evidence that it read this file; the contract enumerates + `docs/execplans/*.md` from disk. + - **The provenance brief dispatched to the gate runner was wrong about the + diff surface, and the runner caught it.** The brief described the candidate + as "docs-only — exactly two files"; that describes `HEAD~1..HEAD`. Against + the stated base + `6a6fed3a`, `git merge-base` is `486b876c` — the branch is *not* based on + `origin/main` — and the surface is **44 paths, 40 of them non-Markdown** + (`src/manifest/tests/*`, `src/stdlib/register.rs`, `test_support/*`, + `tests/manifest_load_options/*`, 15 insta snapshots). The nine-gate set was + therefore right on scope grounds alone, independent of the ExecPlan + argument. A base-versus-branch-tip confusion produced a false description of + the change surface, exactly as it did for the `nixie` verdict above. + - The re-run review covered the **whole 44-path surface**: the tool's own + `reviewedFiles` list is set-identical to + `git diff --name-only 6a6fed3a...52992f4c`, so nothing was skipped, and it + warned about no sampling or truncation. Evidence: + `/tmp/pr807-review-52992f4c/coderabbit.out` (sha256 `fa06c749…`), exit 0. + - The finding, verified against source rather than accepted on the reviewer's + word: `:406` read "The test registration therefore supplies sandbox-rooted + adapters for all three groups", in the present tense, while `:417-422` + (added by this branch) says the adapters "land in stages", that the test + registration "refuses all three groups" until they do, and that roadmap + 7.4.1 "replaces the refusals with the sandbox-rooted adapters". The adapters + genuinely do not exist: `StdlibRegistration` (`src/manifest/mod.rs:96`) + declares only `Full` and `ManifestQuery`, and no sandbox-rooted adapter + appears in `src/stdlib/`. The sentence is base text (`:367` in `6a6fed3a`, + then "for both helpers"), but this branch edited that same line to "all + three groups" while adding the staging paragraph, so the contradiction is + now ours to fix. **It was also the section's only present-tense claim that + the adapters exist** — every other adapter statement is staged (`:417`, + `:421`, `:425`), while `:378` describes command helpers that *do* exist + today. Repaired by "supplies" → "will therefore supply", one word. + - Repair scope note: the finding needed no re-derivation of the design's + substance, so no Decision-log entry is owed; the staged framing this branch + added is unchanged and now reads consistently at both ends. +- [x] (2026-10-16) Repair batch at `301f2456` ("Share the temporary-path + snapshot filter"): **all nine gates green**, including the two the previous + batch could not reach. `make test` was red on **attempt 1** at the same + host-contention class as Risk `:433` — + `production_build_module_slice_has_expected_boundary` terminating at 300.007 + s, 3933/3949 run, 16 rows never run, doctest pass short-circuited — and + **passed on the mandated re-run**: 3949 of 3949, 6 skipped. The doctest pass + ran on the re-run and on neither attempt of any earlier batch: + `Doc-tests netsuke` 88 passed and 26 ignored, plus 2 compile-fail rows, then + `Doc-tests test_support` 40 passed and 6 ignored. The announced `running 114` + and `running 46` include the ignored rows, so neither is a pass count. + - `make test-workflow-contracts` was added to the dispatch **mid-batch** after + the first assignment omitted it. The omission's reasoning — "the diff + changes no file under `tests/workflow_contracts/`" — is invalid for this + candidate: the diff adds a `#[cfg(test)] #[path = ...] mod` child to + `src/stdlib/register.rs`, which `tools/kani/proof-scope.toml:161` names, + and `kani_proof_scope_test.py` re-derives that scope from the module + closure on every run. The gate ran last and was green (1271 passed, 3 + skipped; then 27 passed). **A gate whose subject is a derived whole-crate + property cannot be scoped by asking which files were edited** — the same + lesson the EP-M1 Kani note already carries. + - The red attempt was preserved byte-for-byte under `.attempt1` before the + re-run overwrote the canonical path, and the census confirms the class: 52 + compile-slot waits, peak 98 ahead, 574 sccache-unreachable notices, and + **0** `build-limits:` admission failures. The zero is what makes this queue + latency rather than an admission refusal. All five cases of the shared + filter passed in both attempts. +- [x] (2026-10-24) Rebased onto `origin/main` `8a961000` after `main` moved 15 + commits on, replaying all 29 branch commits (`486b876c..67894369`) with two + conflict stops, both in files where `main`'s documentation-layout migration + (`c8e58a0a8`) met this branch's substance. `range-diff` shows 29 commits with + exactly 2 changed, and each delta is solely the `ADR-010` → `ADR-0010` + renumber. The semantic audit found 550 of 550 target-only paths + byte-identical and no unexplained deletion. `Cargo.lock` needed no rebuild: + `cargo metadata --locked --offline --no-deps` is clean against `main`'s lock + plus this branch's single added `"regex",` edge in `test_support`. + Post-rebase repair of old-style citations and stale figures: see the entry + below. +- [x] (2026-10-25) Rebased the 30-commit branch range from `8a961000` onto + fetched `origin/main` `de81d5c3` with Git’s built-in `zdiff3` backend; no + rebase conflicts. The rebase range-diff matched all 30 replayed commits. All + 180 target-only paths are byte-identical and no branch deletion remains + against the target. Reapplied and reconciled the pre-existing plan + corrections. +- [x] (2026-10-30) Rebased the 32-commit range `de81d5c34..a24f4ab9` onto + fetched `origin/main` `7929a3fc` with Git's built-in `zdiff3` backend. The + host still selects Weave from the legacy global block in + `~/.config/git/attributes`, so the unattended rebase bypassed it with + `-c core.attributesFile=/dev/null`; no merge driver participated. There were + no conflicts: `range-diff` matched all 32 replayed commits, all 201 + target-only paths are byte-identical, and the only deletion against the + target is the intended move of `src/stdlib/register/query_helpers.rs`. All + eight gates pass at `dc6abe26`: `check-fmt` 2.7 s, `typecheck` 9 m 33 s, + `lint` 14 m 23 s, `doc-coverage` 98.22%, `test` 4826 passed / 7 skipped plus + 3 doctests, `markdownlint` 0 issues, `nixie` 208 diagrams, and + `test-workflow-contracts` 1520 passed / 3 skipped. Logs: + `/tmp/check-fmt-dc6abe26.out`, `/tmp/typecheck-dc6abe26.out`, + `/tmp/lint-dc6abe26.out`, `/tmp/doc-coverage-dc6abe26.out`, + `/tmp/test-dc6abe26.out`, `/tmp/markdownlint-dc6abe26.out`, + `/tmp/nixie-dc6abe26.out`, and `/tmp/test-workflow-contracts-dc6abe26.out`. + Force-pushed with lease from `925f803e`, and local, remote, and tracking refs + then all read `dc6abe26`. +- [x] (2026-10-30) EP-M1's first extraction, committed as `dc6abe26`: the + query refusals and the shared surface inventory moved to + `src/stdlib/restricted_helpers/`, the build registration derives its + file-test names from `SURFACE`, and the Kani scope names the two new source + files. The eight-gate batch above is its acceptance evidence; the refusal + text, registration shapes, and file-test names are unchanged. +- [x] (2026-10-30) EP-M1's second extraction, committed as `f6efa8513`: + `QueryEvaluation` and `classify_query_evaluation` moved to + `src/manifest/jinja_macros/query_classification.rs`, with the relocated + adapter's rationale rewritten for its new home. The parent falls from 394 to + 358 lines. `tools/kani/proof-scope.toml` gains the new path, because the + harness closure still reaches the classifier through + `evaluate_when_expression`; `kani_proof_scope_test.py` failed on the move + before the entry was added and passes with it. Its eight-gate batch is green + at `f6efa8513`: `check-fmt` 4 s, `typecheck` 4 m 40 s, `lint` 19 m 7 s, + `doc-coverage` 98.22%, `test` 4826 passed / 7 skipped plus 164 doctests, + `markdownlint` 0 issues, `nixie` every diagram validated, and + `test-workflow-contracts` 1520 passed / 3 skipped. Logs: + `/tmp/check-fmt-f6efa8513.out`, `/tmp/typecheck-f6efa8513.out`, + `/tmp/lint-f6efa8513.out`, `/tmp/doc-coverage-f6efa8513.out`, + `/tmp/test-f6efa8513.out`, `/tmp/markdownlint-f6efa8513.out`, + `/tmp/nixie-f6efa8513.out`, and `/tmp/test-workflow-contracts-f6efa8513.out`. +- [x] (2026-10-30) EP-M1 complete. The batch above is bound to `f6efa8513`; + the only tracked change after that revision is this Progress record and the + two checkbox lines, both prose and Markdown only, so the batch is not re-run. + `make check-fmt` and `make markdownlint` are re-run on the prose commit + instead. +- [x] (2026-10-30) EP-M1 milestone review via `coderabbit review --agent` + against `origin/main` (CLI 0.9.1): exit 0, `review_completed`, 51 files + reviewed, seven unique concerns, none major or critical. Six were verified + against source and repaired in `427c175d5`; one premise was rejected because + it claimed `uses_shell.yml` carried the same false coverage statement as + `uses_fetch.yml`, while that file phrases the claim conditionally. The + repairs: the `uses_fetch.yml` comment (no public row loads it, and why + `http://` is load-bearing), two stale test comments, a `register.rs` test + pinning inventory-to-dispatch coverage, the developers' guide's + replaced-mechanism name, a design-document status note recording that + §§4.2-4.3 are superseded by this plan's revision note, and two prose repairs + in this document. +- [x] (2026-10-31) Eight-gate batch for `427c175d5`: `check-fmt` 4 s, + `typecheck` 3 s, `lint` 97 s, `doc-coverage` 5349 s at 98.22%, `test` 3317 s + including the new `every_inventoried_file_test_has_a_build_predicate` row, + `nixie` 3 s, and `test-workflow-contracts` 167 s at 1520 passed / 3 skipped — + all exit 0. `markdownlint` was the one red gate, 34 s, reporting exactly two + MD049 issues in the new status-note sentence. Logs: + `/tmp/check-fmt-427c175d.out`, `/tmp/typecheck-427c175d.out`, + `/tmp/lint-427c175d.out`, `/tmp/doc-coverage-427c175d.out`, + `/tmp/test-427c175d.out`, `/tmp/markdownlint-427c175d.out`, + `/tmp/nixie-427c175d.out`, `/tmp/test-workflow-contracts-427c175d.out`. +- [x] (2026-10-31) The MD049 repair, `416c73560`, changes one emphasis + character in Markdown, and `4f57ddde0` regenerates `typos.toml` from the + shared dictionary (unrelated to this branch's work, committed separately). + Both are Markdown/config-only deltas, so only the gates sensitive to that + delta were re-run at `4f57ddde0`: `check-fmt` 7 s, `markdownlint` 43 s with 0 + issues, and `nixie` 3 s, all exit 0. `lint`, `test`, and `doc-coverage` are + unaffected by prose, which is the recorded gate sensitivity rather than an + assumption. Logs: `/tmp/check-fmt-4f57ddde0.out`, + `/tmp/markdownlint-4f57ddde0.out`, `/tmp/nixie-4f57ddde0.out`. +- [x] (2026-10-31) EP-M2 implemented and committed as `ecfeb6c67` + ("Add options-carrying manifest loader entry point"), 22 files changed. New: + `src/manifest/loader_options.rs` (`ManifestLoadOptions`, `StdlibRegistration`, + `HostHelperDisposition`, `from_str_with_options`, loader-owned + `env()`/`glob()` registration, `query_environment()`); + `src/manifest/tests/loader_options.rs` (the OBL-2 table); and + `tests/manifest_load_options_embedder.rs` (the OBL-12 substitute for the + `trybuild` fixture C7 forbids). Every `from_str*` and `from_path*` entry + point is now a wrapper, `from_path_with_options` is the crate-private core + deriving its registration from the opened workspace, and `evaluate_manifest` + splits into `build_template_environment` and `expand_and_render`. The stale + `ManifestLoadMode` citation in `docs/developers-guide.md` is corrected; EP-M5 + still owns the full narrative. +- [x] (2026-10-31) EP-M2 local verification before the gate batch: + `make lint-clippy` exit 0, the focused suites (both wrapper-equivalence + binaries, the OBL-2 rows, the stdlib refusal snapshots, the loader-options + doctests) 387 passed / 1607 skipped at exit 0, and `make check-fmt` clean at + 305 Rust files and 207 Markdown files. Logs: + `/tmp/epm2-lint-clippy-netsuke-7-1-2.out` and + `/tmp/epm2-focused-tests-netsuke-7-1-2.out`. +- [x] (2026-10-31) OBL-1 non-vacuity discharged as the obligation requires. + Baseline: 15 of 15 rows pass (`/tmp/epm2-obl1-baseline-netsuke-7-1-2.out`). + Seeded fault A — the legacy Boolean formatter symbol kept live but never + installed by `register_with_config` — failed 11 of the 15 rows and passed + only the four that never render a Boolean: both budget-exhaustion rows, + `path_entry_point_cannot_inject_a_clock`, and the crate-internal + `from_path_with_limits_row`. Seeded fault B — `render_manifest_with_budget` + routed through `RenderMode::ManifestQuery` — failed the same 11 rows and + passed the same four. Both were reverted with `git checkout --`, every + `*.snap.new` was deleted, and the baseline then re-ran green. Logs: + `/tmp/epm2-obl1-fault-a-netsuke-7-1-2.out` and + `/tmp/epm2-obl1-fault-b-netsuke-7-1-2.out`. Each fault's naive form is a + compile error under `-D warnings`; see `Surprises & discoveries`. +- [x] (2026-10-31) Eight-gate batch for `61e6cf102`: **seven green, one red**. + `check-fmt` 3 s, `typecheck` 1669 s, `lint` 4751 s, `doc-coverage` 2477 s at + 98.23% (6382/6497), `test` 1081 s at 4833 passed / 7 skipped plus 166 + doctests, `markdownlint` 25 s with 0 issues, `nixie` 2 s; + `test-workflow-contracts` red at 129 s with 1 failed / 1519 passed / 3 + skipped. Logs: + `/tmp/-netsuke-7-1-2-options-carrying-manifest-loader-entry-point-61e6cf102.out`. + The batch ran detached, one gate at a time, and took 2 h 51 m of wall clock, + 79 m of it in `lint` behind a compile queue that peaked near 70 requests; the + supervisor stayed healthy and no gate hit admission refusal. +- [x] (2026-10-31) The one red gate was a real contract failure, not host load: + `test_scope_covers_the_harness_closure` reported that the Kani harness + closure now reaches `src/manifest/loader_options.rs`, which + `tools/kani/proof-scope.toml` did not list. Repaired in `8a7a83718` by adding + the path in sorted position; the entry mirrors the closure rather than + widening the proof inputs, so the pull-request Kani decision keeps firing + exactly when a harness input changes. Re-ran `make test-workflow-contracts` at + `8a7a83718`: exit 0, 132 s, 1520 passed / 3 skipped. Log: + `/tmp/test-workflow-contracts-netsuke-7-1-2-options-carrying-manifest-loader-entry-point-8a7a83718.out`. + `make test` needed no re-run: no Rust test reads `proof-scope.toml` (the + three consumers are `scripts/kani_proof_scope.py` and the two workflow + contract modules). +- [x] (2026-10-31) The gate-set correction recorded below is discharged: the + batch above ran `make test` against this plan's current text at `61e6cf102`, + and the only later plan edit is this prose record. +- [ ] (2026-10-31) EP-M2 milestone CLI review is **blocked on CodeRabbit's + organization-wide quota**, not on the candidate. Two invocations of + `coderabbit review --agent --committed --base origin/main` against + `68cc2683f` (the second after the stated 51 s wait) both exited 1 with + `errorType: rate_limit`: "You've used all 10 included reviews currently + available", `onDemandReviewAvailable: false`. Zero files were reviewed, so + there are no concerns to verify. Logs: + `/tmp/epm2-coderabbit-review-68cc2683f.out` and + `/tmp/epm2-coderabbit-review-68cc2683f-2.out`. The review will be + re-requested once a slot frees; if it runs after EP-M3 lands it covers both + milestones and that scope will be recorded with its result. The repository's + own gates are green and are not affected by the quota. +- [x] (2026-10-31) Published `68cc2683f` over SSH as `leynos`; local head, the + refreshed `origin/7-1-2-options-carrying-manifest-loader-entry-point` + tracking ref, and the remote branch all read the same commit, and the branch + upstream is `origin/`. +- [x] (2026-10-31) EP-M3 implemented and committed as `b5691e198` ("Add the + test-mode stdlib registration"), 59 files changed. New: + `src/manifest/test_surface.rs` (`TestSurface`), the + `StdlibRegistration::Test(Box)` variant and + `ManifestLoadOptions::test`, `src/stdlib/restricted_helpers/test_mode.rs` + (`TestModeRefusal`, `register_for_test`, the test `glob()` refusal), the + `stdlib.test_mode.helper_unavailable` key in all 35 catalogues, and the first + two behavioural scenarios with their steps. `register_for_test` installs only + the inventory's available rows, refuses the rest with a typed source, and + refuses `now()` unless `config.clock()` is injected. The loader keeps + `env()`/`glob()` ownership: a test load answers `env()` from the surface's + reader and refuses `glob()` through `register_glob_refusal`. +- [x] (2026-10-31) EP-M3 local verification. The focused baseline was 35 + passed / 2627 skipped at exit 0 (`/tmp/epm3-baseline-netsuke-7-1-2.out`): + eleven `test_mode` rows, eight `restricted_helpers` rows, fourteen + wrapper-equivalence rows, the corpus test, the fidelity property, and both + scenarios. `cargo clippy --all-targets --all-features -- -D warnings` + finished with zero warnings (`/tmp/epm3-clippy-all-netsuke-7-1-2.out`), and + `make check-fmt` was clean at 305 Rust files and 207 Markdown files. +- [x] (2026-10-31) EP-M3 non-vacuity. Each seeded fault failed the row it + targets and was reverted before the baseline re-ran green. OBL-3a: registering + `scratch` in `src/stdlib/collections.rs` without an inventory row failed + `every_scanned_registration_is_inventoried`, naming `scratch` in + `src/stdlib/collections.rs` (`/tmp/epm3-obl3-fault-a-netsuke-7-1-2.out`). + OBL-3b: deleting the `digest` row failed + `refused_helpers_report_their_own_name` with + `unknown filter: filter digest is unknown` + (`/tmp/epm3-obl3-fault-b-netsuke-7-1-2.out`). OBL-4a: classifying test + refusals as query refusals failed both `when` rows, each load succeeding with + `conditional: true` (`/tmp/epm3-obl4-fault-a-netsuke-7-1-2.out`). OBL-4b: + dropping the typed source failed `refusals_carry_a_typed_source` with + `20 refusing rows lacked a typed source` + (`/tmp/epm3-obl4-fault-b-netsuke-7-1-2.out`). OBL-5: removing the legacy + Boolean formatter from the test registration failed the property with `true` + against `True` (`/tmp/epm3-obl5-fault-netsuke-7-1-2.out`). OBL-6: defaulting + the reader to the process environment failed + `test_load_without_a_reader_reads_no_process_environment`, which printed the + ambient `PATH` (`/tmp/epm3-obl6-fault-netsuke-7-1-2.out`). OBL-7: removing the + `is_system()` branch failed the default-clock row, whose manifest had + rendered an ambient timestamp (`/tmp/epm3-obl7-fault-netsuke-7-1-2.out`). + OBL-11: registering the real `shell` filter under test failed the corpus + aggregate, which reported no refusal from the `process` family (the plan's + `hash` seed is inapplicable; see `Surprises & discoveries`) + (`/tmp/epm3-obl11-fault-netsuke-7-1-2.out`). +- [x] (2026-11-01) EP-M3 lint repairs. The first batch was red on Whitaker's + `module_max_lines`, and each repair unmasked the next offender because the + suite stops at the first failing target: `keys.rs` (401 raw lines) split to + `manifest_keys.rs` in `a347e9828`; `fidelity_property` (476 lines) plus a + `bumpy_road_function` finding in `corpus_fidelity::compare` repaired in + `57b925375`; and `test_mode_tests` (448 lines) split into + `{mod,probe_rows,registration_scan,refusal_text}.rs` in that same commit. + Focused evidence: 7 surface-contract unit tests and 15 + `manifest_load_options` cases passed before the batch. +- [x] (2026-11-01) EP-M3 workflow-contract repairs, `e5b1641d4`. The batch at + `57b925375`/`8d7f0bdf0` left only `make test-workflow-contracts` red: + `test_scope_covers_the_harness_closure` required + `src/manifest/test_surface.rs` and + `src/stdlib/restricted_helpers/test_mode.rs` in the Kani scope, and + `test_repository_modules_follow_directory_layout` rejected the + `('stdlib/restricted_helpers', 'test')` group. Both `sources` entries were + added in sorted position and `test_mode_tests/` was renamed to + `surface_contracts/` (see the Decision log). Re-run: 1520 passed / 3 skipped. +- [x] (2026-11-01) EP-M3 nine-gate batch at `e5b1641d4`: **all nine green** in + 16 m 45 s against one unchanged HEAD and a clean tree. check-fmt 2 s, + typecheck 162 s, lint 201 s (aggregate, every phase verified), doc-coverage + 16 s at 98.27 %, test 133 s (4855 passed / 7 skipped, doctests included), + markdownlint 22 s, nixie 2 s, test-workflow-contracts 138 s (1520 passed / 3 + skipped), test-package-verify 329 s. Logs `/tmp/gate-…-e5b1641.out`; + per-gate status records `/tmp/netsuke-gates-e5b1641/gate.json`. The + batch's own suite report proves the two previously-red node IDs are unmarked, + so their absence from the `-q` dot output cannot hide a skip. +- [x] (2026-11-01) EP-M3 milestone CodeRabbit CLI review at `0070b8857`. + `coderabbit review --agent --committed --base origin/main` (CLI 0.9.2, + `origin/main` `da06ffb11`) returned `review_completed`, exit 0, in 572 s over + 128 files with six concerns: three minor, three trivial, none major or + critical. The organization-wide quota had recovered (2 of 10 remaining + afterwards). All six were verified against the current tree and repaired: two + plan-file citations (the nextest filter named `load_options`; RFC 0007 was + cited without its `rfc-` prefix), the stale "consume it in EP-M3" note on + `SURFACE`, a restated `is_loader_owned` predicate in the registration scan, + the substring-derived call-shape table in `refusal_text.rs`, and a `concat!` + failure message whose `{build}`/`{test}` captures never interpolated. Log + `/tmp/epm3-coderabbit-review-0070b8857.out`. +- [x] (2026-11-01) EP-M4 red stage. Added + `src/manifest/tests/overlays/{mod,namespaces,registration}.rs` (OBL-8, + OBL-9), scenario 3 with its steps, and the extended OBL-12 embedder fixture, + then ran `cargo check -p netsuke-build --lib --tests --all-features`. It + failed with `error[E0432]: unresolved import` for + `crate::manifest::{OverlayNameKind, TemplateOverlayError}` and + `crate::manifest::{OverlayCall, OverlayCallable, TemplateOverlays}`, plus one + `error[E0599]: no method named with_overlays found for struct TestSurface`, + exit 101. The shape differs from the plan's predicted + `unresolved import netsuke::manifest::TemplateOverlays`: the tests live + inside the crate and name `crate::` paths, and the missing builder method + surfaced as a separate `E0599` rather than an import error. Log: + `/tmp/epm4-check.out`; see `Surprises & discoveries`. +- [x] (2026-11-01) EP-M4 implementation. `src/manifest/test_surface.rs` became + the directory module `src/manifest/test_surface/{mod,overlays}.rs`: `mod.rs` + keeps `TestSurface` (now carrying `overlays` and the `with_overlays` + builder), and `overlays.rs` owns `TemplateOverlays`, `OverlayCallable`, + `OverlayCall`, `TemplateOverlayError`, `OverlayNameKind`, `OverlayNamespace`, + `overlay_namespace`, validation, and `register_overlays`. + `build_template_environment` computes the namespace before step 3, reuses it + for the existing reserved-name check, and registers the overlays last, after + the macros, so an overlay can replace any helper the earlier steps installed. + `StdlibRegistration::overlays()` replaced the `#[cfg(test)]` + `accepts_overlays` probe, and the decision table asserts it against all four + rows. The collision key `manifest.overlay.name_collision` landed in + `manifest_keys.rs` and all 35 catalogues in the same change. +- [x] (2026-11-01) EP-M4 green-stage compile. The first green attempt failed + on two `error[E0277]` sites of my own making: both test closures are typed + `Result` but used `?` on an `anyhow::Error` from + `.context(...)`. Replaced with + `ok_or_else(|| MiniJinjaError::new(ErrorKind::InvalidOperation, …))`, the + form a third closure in the same file already used. The re-run finished `dev` + in 16 m 11 s at exit 0 after a compile queue that peaked near 250 waiting + requests. Logs: `/tmp/epm4-check.out` (red), `/tmp/epm4-check2.out` (green). +- [x] (2026-11-01) EP-M4 focused verification, all green: + `--lib -E 'test(/manifest::tests::(loader_options|test_mode|overlays)/)'` ran + 27 passed / 2451 skipped (the twelve overlay rows are six namespace and six + registration cases); `--test manifest_load_options_embedder` ran 2 passed, + including the extended OBL-12 case; `-E 'test(/manifest_load_options/)'` ran + 18 passed / 4854 skipped, including + `manifest_load_options_a_template_overlay_stands_in_for_glob_under_test` + (scenario 3). Log: `/tmp/epm4-focused.out`. +- [x] (2026-11-01) EP-M4 non-vacuity, two seeded faults, both reverted before + the green re-run. Fault A disabled overlay validation + (`let _ = validate_overlay_names(…)`): 4 of 6 namespace rows failed, and the + two "may take this name" rows still passed, which is the intended + discrimination (`/tmp/epm4-fault-a-netsuke-7-1-2.out`). Fault B registered + the overlays before the stdlib instead of last: initially only + `overlay_which_decides_a_when` failed, because + `passthrough_overlay_returns_the_shadowed_result` and + `overlay_now_sees_the_refusing_stub` compared against, or reported a stack + produced by, the stdlib's own answer. Both were strengthened with an + `Arc` dispatch counter asserted to be exactly 1; re-seeding + fault B then failed three rows, and reverting it returned the twelve-row run + to 12 passed (`/tmp/epm4-fault-b2-netsuke-7-1-2.out`). + `overlay_glob_drives_a_foreach` still passes under the fault, correctly: the + loader registers `glob()` before the overlays in both orders, so the overlay + wins either way. +- [x] (2026-11-01) EP-M4 gate batch 1, run by the scrutineer over the + uncommitted EP-M4 tree at `3fd0db1ce` (content hash `d53b9854b908`, 87 m 13 s + wall clock). Seven of nine gates were green; three findings were real and + were repaired before the batch was re-run: + - `lint` (red, 3 findings, all in `src/manifest/test_surface/overlays.rs`): + `type_complexity` at the stored dispatch type, `map(_).unwrap_or_default()` + in `declared_variables`, and a `shadow_reuse` binding in + `register_overlays`. Fixed with a `Dispatch` type alias, `map_or_default`, + and renaming the let-else binding to `supplied`. + - `markdownlint` (red, spelling half): `src/manifest/test_surface.rs` was + staged as tracked while deleted on disk after the directory split. Fixed by + staging the deletion (`git add -A`). + - `test-workflow-contracts` (red, Kani proof-scope contract, both + directions): the split module left `tools/kani/proof-scope.toml` naming the + retired `src/manifest/test_surface.rs`. Retargeted to + `src/manifest/test_surface/mod.rs` and `.../overlays.rs` in alphabetical + position. + Each repair was re-verified on its own rather than by waiting for a second + full batch: + `cargo clippy -p netsuke-build --lib --all-features -- -D warnings` exited 0 + in 3 m 29 s with no warnings (`/tmp/lint-clippy-lib-…out`); + `make markdownlint` was clean over 207 files; and the proof-scope contract + passed 7/7 in 14.02 s under the gate's own `uv` environment. Two initially + alarming results were invocation artefacts, not defects: + `cargo clippy -p netsuke --lib` fails because the package is `netsuke-build`, + and a bare `uv run … pytest tests/workflow_contracts/…` fails at collection + with `NameError: name 'Path' is not defined` because the module imports + `Path` only under `typ.TYPE_CHECKING` and the Makefile's pinned `--with` set + is what supplies it. The batch logs are + `/tmp/gate{1..9}-netsuke-7-1-2-options-carrying-manifest-loader-entry-point-d53b9854b908.out`. + EP-M4 was committed as `ec80c6bc1` after the repairs. +- [x] EP-M1 — extractions (restricted helpers, query classifier). +- [x] EP-M2 — options entry point and thin wrappers. +- [x] EP-M3 — `StdlibRegistration::Test`. +- [x] EP-M4 — template overlays. +- [x] (2026-11-01) EP-M5 documentation. + `docs/netsuke-test-framework-technical-design.md` + now describes the implemented loader: §4.2 carries the four-variant enum and + the `SURFACE` inventory, §4.3 the options, `TestSurface`, overlay types, name + rules, and registration order, §5.5 the full refusing inventory with the + `which`/`expanduser` deviations, and §7 the globals-enumeration capture the + earlier text wrongly denied. Every cited anchor was re-derived from the + source. `docs/adrs/adr-0070-deny-by-default-test-mode-stdlib.md` was minted + and indexed in `docs/contents.md`; ADR-0050 gained Addendum E for the + telemetry scope of derived loads; the developers' guide gained a "Manifest + loader entry points" subsection; `docs/polonius.md` records + `ManifestLoadOptions<'a>`; `docs/netsuke-design.md` names the four load + modes; and roadmap 7.1.2 is ticked with all five of its bullets. +- [x] EP-M5 — documentation, ADR, and roadmap closure. +- [x] (2026-11-01) EP-M4 lint repair and second full gate batch. The first + batch's `clippy::map_unwrap_or` repair used `Option::map_or_default`, which + the repository's pinned nightly accepts but Whitaker rejects: Whitaker + compiles the crate on its own `nightly-2026-05-28`, where the method is still + unstable (E0658). `make lint` was therefore **red** at `0e23c1c04` on a tree + Clippy had passed. `declared_variables` now uses the let-else form its sibling + `declared_macros` already uses, which satisfies the lint without the + unstable method. Two missing spaces in EP-M5's own documentation + (`StdlibRegistration::records_expansion_and_budget_telemetry`, + `ManifestLoadOptions::deferred`) were repaired in the same commit, + `85d71d9b1`, after `make check-fmt`, `make lint-clippy` and + `make lint-whitaker` each exited 0 on that exact content. The nine-gate set + was then re-run by the scrutineer against the clean tree at `85d71d9b1` (tree + `72e02a6d`) and **all nine exited 0**: `check-fmt` 4 s, `typecheck` 1 264 s, + `lint` 184 s, `doc-coverage` 1 195 s, `test` 1 475 s, `markdownlint` 28 s, + `nixie` 4 s, `test-workflow-contracts` 148 s, `test-package-verify` 403 s; + batch wall clock 78 m 27 s (08:35:23–09:53:50). `make test` ran 4 869 nextest + cases (4 869 passed, 7 skipped) and then every doctest target; + `make test-workflow-contracts` ran 1 520 passed / 3 skipped and a further 27 + passed. Every gate log carries the same `HEAD` and tree breadcrumbs, so no + gate read a tree another gate had moved. Logs: + `/tmp/gate{1..9}-netsuke-7-1-2-options-carrying-manifest-loader-entry-point-85d71d9b1.out`. + +- [x] (2026-11-01) Branch rebased onto `origin/main` (`e5d57c58`), PR #807's + `CONFLICTING` mergeability resolved. The replay boundary was the last + inherited commit, `7929a3fc`; 57 commits were replayed and one was dropped. + `4f57ddde0` ("Regenerate typos.toml from the shared dictionary") became empty + because the refreshed `typos.toml` it wrote is byte-identical to + `origin/main`'s, so it was skipped rather than replayed as a no-op. Three + conflicts were semantic rather than textual: both branches split the same + localization key table, so `KEY_SOURCES`, the module doc comments, the + developers' and translators' guides, ADR-0056, and both build-audit test + oracles now name four tables, and `ALL_KEYS` in `src/localization/mod.rs` + grew from three tables to four in `e3b292e28`. `git range-diff` against + `7929a3fc..324b5eb90` shows exactly two rewritten commits, both the ones + carrying conflict resolutions, and `git diff --check origin/main HEAD` is + clean. The nine-gate batch recorded above binds to `85d71d9b1` only; it does + **not** cover the rebased tree. +- [x] (2026-11-01) Nine-gate batch green on the rebased candidate. The set was + run sequentially, one gate at a time, against `f31ee3907` (tree + `e1ac0892b08872bc25d4dd90ae808aecae95723d`) with a clean working tree, and + **all nine exited 0**: `check-fmt` 4 s, `typecheck` 1 639 s, `lint` 187 s, + `doc-coverage` 277 s, `test` 3 594 s, `markdownlint` 48 s, `nixie` 3 s, + `test-workflow-contracts` 317 s, `test-package-verify` 1 447 s. `make test` + ran 5 008 nextest cases (5 008 passed, one reported leaky, 7 skipped) and + then every doctest target; `make test-workflow-contracts` ran 1 666 passed / + 3 skipped and a further 28 passed; `make markdownlint` linted 213 files with + 0 issues and did not rewrite `typos.toml` (`current: typos.toml`). Every log + carries the same `HEAD` and `TREE` breadcrumbs, so no gate read a tree + another gate had moved. Logs: + `/tmp/gate{1..9}-netsuke-7-1-2-options-carrying-manifest-loader-entry-point-f31ee3907.out`. +- [x] (2026-11-01) Prose-delta re-gate for this record. Writing a gate result + into a tracked file moves `HEAD` past the revision the run verified, so the + loop is closed by stating the delta rather than by re-running the heavy + gates: the only tracked change between `f31ee3907` and the commit carrying + this bullet is prose in this one file, and its Rust, YAML, Python, and + catalogue content is byte-identical to what the batch passed. The gates that + can observe a Markdown delta were re-run against that commit — `check-fmt`, + `markdownlint`, `nixie`, `test-workflow-contracts`, and the plan's sole Rust + reader, `cargo nextest run --test execplan_status_contract_tests` — + `typecheck`, `lint`, `doc-coverage`, `test`, and `test-package-verify` were + not, because none of them reads `docs/execplans/`. +- [x] (2026-11-01) CodeScene repairs. The hosted Code Health review was failing + on four files introduced or changed by this branch, and each finding was + reproduced locally with `cs delta` before being believed, because Code + Duplication and String Heavy Function Arguments are measured only by the + hosted service. Every file now scores 10.00 under `cs check`, from a + reproduced baseline of 9.68, 9.09, 9.38, and 9.38: + - `src/manifest/mod.rs` (String Heavy Function Arguments, 41.7% of arguments + to its 7 functions). The three string entry points moved to the sibling + `src/manifest/str_loaders.rs`, mirroring the `path_loaders.rs` and + `parse_with_config.rs` convention; `mod.rs` re-exports them, so the public + path `netsuke::manifest::from_str` and its doctest are unchanged. + - `tests/manifest_load_options/fixtures/fixture.rs` (Code Duplication between + `injected_environment` and `process_environment`; Complex Method on + `Case::is_loadable`, cc 9). The capability constructors became builder + chains over `Capabilities::NONE`, and `is_loadable` now composes four + single-check const predicates with `&&`. + - `tests/manifest_load_options/wrapper_equivalence/path_rows.rs` (Code + Duplication across six rows). The seven observer-taking adapters became one + `ObservedEntryPoint` table with one case each, so the argument shape is + written once; the snapshot names are still written out, and the per-row + rationale now documents the table's variants. + - `src/manifest/tests/overlays/namespaces.rs` (Code Duplication between the + two accepted-overlay tests). They are one parameterized `rstest` with + `#[case::loader_helper]` and `#[case::fresh_name]`. + + The behaviour is unchanged: the `path_rows` snapshots keep their names and + contents, and no entry point's signature or forwarding changed. The nine-gate + set is re-run against the commit carrying this repair, so the earlier batch + is a historical record and not this candidate's evidence. +- [x] (2026-11-01) Kani proof scope covers the new module. Hosted CI failed + `tests/workflow_contracts/kani_proof_scope_test.py` on the repair candidate: + moving the string loaders made `src/manifest/str_loaders.rs` reachable from + `evaluate_when_expression`, and `tools/kani/proof-scope.toml` `sources` did + not list it. The path is added in sorted position beside + `src/manifest/render/mod.rs`. This is scope bookkeeping, not a proof change: + no Kani harness, contract, or model was modified, and the reachable set the + existing harnesses see is unchanged. The focused contract test passes (7 + passed), and the full nine-gate set is re-run against the commit carrying + this fix, because the batch that preceded it was stopped at gate 3 rather + than allowed to proceed on a tree CI had already shown to be red. +- [x] (2026-11-02) OBL-3's registration scan compares helper kind as well as + name. The hosted ExecPlan assessment found that + `surface_contracts/registration_scan.rs` reduced every registration to + `(path, name)`, so a new `add_filter("now", …)` would have satisfied the + inventory because `SURFACE` already lists the **function** `now`. The scanner + now carries the `HelperKind` implied by the marker + (`add_function`/`add_filter`/`add_test`) and both directions compare + `(kind, name)`; `is_loader_owned` names `HelperKind::Function` so the `env` + and `glob` exemptions cannot cover a same-named helper of another kind; and + `a_registration_is_not_inventoried_under_another_kind` pins the + counterexample (a filter named `now` is rejected, while the function and + filter rows of `which` both remain inventoried). The focused suite passes (8 + passed). The nine-gate set is re-run against the commit carrying this repair. +- [x] (2026-11-02) C12 evidence supplied for the hosted assessment. The + assessment could not find the characterization baseline `847c19cc` in its + checkout and reported C12 inconclusive. The commit is present in this + worktree, and + `git diff --exit-code 847c19cc HEAD -- tests/snapshots/manifest_load_options` + prints nothing and exits 0, which is exactly C12's condition: every + characterization snapshot is byte-identical to the baseline across the whole + branch. + +## Surprises & discoveries + +- Observation: Whitaker's `module_max_lines` stops the run at the first failing + target, so each repair unmasked the next offender and EP-M3 needed three + consecutive rounds: `keys.rs` at 401 raw lines, then `fidelity_property` at + 476 beside a `bumpy_road_function` finding in `corpus_fidelity::compare`, then + `test_mode_tests` at 448. Impact: one red report understates the work; + budget a serial loop until `make lint-whitaker` exits 0 rather than a single + repair. The house layout is `foo/mod.rs` plus a plain `mod child;`; `#[path]` + children are the retired pattern and `clippy::self_named_module_files` denies + the alternative. Date/Author: 2026-11-01, implementation agent. + +- Observation: `rust_module_layout_test.py` accepts a shared-prefix sibling + group only when the collision is coincidental, which is exactly what an + exception's rationale must argue. `test_mode.rs` beside `test_mode_tests/` + collides because the directory tests that file, so no truthful rationale + existed. Impact: the repair is a rename, not an allowlist entry; the module + is now `surface_contracts/`, declared `#[cfg(test)] mod surface_contracts;`. + Date/Author: 2026-11-01, implementation agent. + +- Observation: `kani_proof_scope_test.py` recomputes the harness closure from + the sources each proof reaches, so adding loader sources silently widens the + required scope. EP-M3's `test_surface.rs` and `test_mode.rs` were reachable + and unlisted. Impact: a milestone that adds a module reachable from a Kani + harness must add its `sources` entry, in sorted position, in the same commit. + Date/Author: 2026-11-01, implementation agent. + +- Observation: a gate batch can outlive the tool call that started it, and on + this host it did. The first dispatch of the `427c175d5` batch stalled at the + runner's 600 s no-progress watchdog while `make lint` waited behind a compile + queue several hundred deep; the watchdog terminated the runner and every gate + child with it, leaving `check-fmt` green, `typecheck` and `lint` truncated + mid-build. Impact: a batch this long cannot be run as one supervised tool + call under host saturation. The rerun was launched detached (`setsid nohup`, + one gate at a time, per-gate `tee` logs) and polled, which survived the same + queue and took 2 h 28 m of wall clock, 1 h 29 m of it in `doc-coverage` + alone. Evidence: the stalled `typecheck` log ends mid `Checking` line and the + stalled `lint` log mid compile-slot wait; the detached batch's logs each end + with `EXIT_CODE`. Date/Author: 2026-10-31, implementation agent. + +- Observation: the planned Kani-scope helper command was misidentified. In + this checkout, `make test-kani-scope-wrapper` exercises the systemd-run + wrapper; it neither derives proof-source paths nor prints additions and + removals. `tests/workflow_contracts/kani_proof_scope_test.py` recomputes the + harness closure and checks `tools/kani/proof-scope.toml`; validate scope + edits with `make test-workflow-contracts`. Correct the earlier instruction to + derive moved source paths from the contract's actual failure evidence rather + than relying on the unrelated wrapper test. + +- Observation: a gate report's "pre-existing base-red" verdict is only as good + as the revision it compared against, and this batch's `nixie` verdict named + the wrong one. The scrutineer called diagram 2 "byte-identical to base + `c16a2ab1`" and advised "leave the `nixie` diagram-2 failure out of that fix, + as it is pre-existing base-red and needs its own decision". `c16a2ab1` is the + **tip of this branch**, not the rebase base, so "identical to base" was + measured against a revision that already contained the defect. Against the + real base (`486b876c`) the design doc holds exactly one mermaid fence + (`:83`); diagram 2 is absent, and `git log -S` attributes it to `04aa8366` + "Diagram the loader's registration modes" — an EP-M0 commit on this branch. + Impact: had the advice been followed, a green `nixie` would have been + reported while `ci.yml:261` runs `make nixie`, leaving the branch unmergeable + on a defect the plan had by then recorded as not its own. The repair is one + pair of quotes, proven by minimal experiment: `A[it's fine]` fails while + `A["… it's fine"]` parses. Evidence: `git show 486b876c:` fence count, + `git log --reverse -S 'Register query helpers and refusals'`, `merman-cli` on + handwritten probes. Date/Author: 2026-10-16, implementation agent. + +- Observation: the EP-M1 moves touch `tools/kani/proof-scope.toml`, a file the + plan never mentions. Its `sources` list names + `src/stdlib/register/query_helpers.rs:162` and `src/stdlib/register.rs:161`; + the contract at `tests/workflow_contracts/kani_proof_scope_test.py` rejects + both a closure file missing from the scope (`:126-135`) and a scope entry + covering nothing (`:180-189`), and both rules are live in the file. Impact: + relocating `query_helpers.rs` *provably* forces an edit in the same commit — + the file ceases to exist, so its entry becomes dead and the new path, if the + harnesses reach it, is uncovered. The `jinja_macros` extraction is weaker: + `mod.rs` survives it and stays reached, so only the new child's coverage is + in question, and the contract decides that. Do not pre-emptively edit that + entry. Evidence: `wyvern` recon at `c4ca78b8`, both contract rules read and + confirmed live. Date/Author: 2026-10-15, implementation agent. + +- Observation: `glob` is a query refusal but absent from the EP-M0 refusal + inventory, so the OBL-10 baseline is incomplete. The stub file registers six + always-disabled helpers (`env`, `glob`, `fetch`, `shell`, `grep`, + `contents`), seven file tests, and ten host-dependent helpers — 23 rows + against the table's 22. `glob` is the omission, and it is also the one + refusal the `RestrictedSurface` inventory must eventually describe as a + *host* helper rather than a stub, since EP-M2 moves `env`/`glob` registration + into the loader. Impact: an incomplete oracle for a requirement that says + "every query refusal message"; the row must be added before EP-M1 relocates + the table. Evidence: `src/stdlib/register/query_helpers.rs:30-33` against + `src/stdlib/register/refusal_snapshots.rs` at `c4ca78b8`. Date/Author: + 2026-10-15, implementation agent. + +- Observation: `FILE_TESTS` is shared across the EP-M1 move boundary. + `src/stdlib/register/query_helpers.rs:16` imports it to build the file-test + stubs, and `src/stdlib/register.rs:252-264` consumes it to register the real + tests. It is currently a private `const` in the parent, so the move forces a + visibility change or subsumption by the new `SURFACE` inventory — which is + the point of `SURFACE`: one list, so a test added to one surface cannot stay + silently unregistered on the other. Evidence: `wyvern` recon at `c4ca78b8`. + Date/Author: 2026-10-15, implementation agent. + +- Observation: `make lint`'s reported error count is a **lower bound**, and the + EP-M0 gate run proved it again in a new way. `make lint` at `71f26e1f` + aborted on one `clippy::shadow_reuse` in + `src/manifest/tests/wrapper_equivalence.rs`. A focused + `cargo clippy -p netsuke-build --lib --tests` then found **eight** findings, + seven of them in the `tests/` integration crate. `cargo clippy` was walking + targets in order and stopped at the failing lib-test target, so the + integration targets behind it were never linted at all — not clean, + unvisited. Impact: do not treat a green `make lint` *stage* as coverage of + every target unless the whole gate is green, and expect repeat lint cycles + after any change that adds a test target. Evidence: + `/tmp/clippy-focused-.out` (the eight findings, exit 2) versus + `/tmp/clippy-focused2-.out` (clean afterwards, exit 0); repaired in + `fbf221f0`. The pre-fix `make lint` log is **not** available as evidence: the + next gate run reused the same `tee` filename and overwrote it. A log named + only by gate and branch is a reused label, not a permanent record — copy a + log worth citing to a name that carries its revision. Date/Author: + 2026-10-15, implementation agent. + +- Observation: Whitaker's `conditional_max_n_branches` counts **leaf Boolean + operands**, not `&&`/`||` operators, and Clippy has no equivalent rule, so + `clippy` stays green on a construct only `lint-whitaker` rejects. The form + `hazards.reads_environment && !(seams.environment || refusing_helpers)` is + three leaves against a ceiling of two, even though it reads as a single + conjunction. Impact: a Clippy-clean target is not a Whitaker-clean target, + and the 1977-second `lint-whitaker` stage is the only place this surfaces — + after the whole Clippy stage has run. Its remedy is stated at + `docs/whitaker-users-guide.md:407`: extract the sub-predicate. Doing so is + also a readability win here, since it names *why* a refusing stub answers a + hazard. Evidence: `/tmp/lint-whitaker-failure-1d75efce.out` (two errors at + `fixture.rs:156` and `:161`) versus `/tmp/whitaker-focused-.out` + (clean, exit 0); repaired in `9191699b`. Date/Author: 2026-10-15, + implementation agent. + +- Observation: `make test` at `1d75efce` failed on nextest's own 300-second cap, + not on an assertion: + `command_env_ui_tests::cli_configuration_fixture_compiles` ran + `TERMINATING [>300.000s]` / `TIMEOUT [300.008s]`, leaving 15 tests unrun and + `doctest` **never executed**. The same log holds 107 + `waiting for a compile slot` lines, and `.config/nextest.toml:57-70` + documents this exact row as bound to the compiler-admission pool. The red is + therefore evidence about host load, not about this candidate — and this is + the third distinct identity to trip the cap across EP-M0's runs. Impact: the + timeout is not actionable in the candidate; re-run on a quieter host for the + doctest evidence rather than raising the limit. Note also that `test-nextest` + running before `doctest` means a timeout leaves the doctest suite + *unverified*, not merely failing. Date/Author: 2026-10-15, implementation + agent. + +- Observation: the strongest evidence that a wall-clock gate red belongs to the + host rather than the candidate is **role inversion between sibling tests** + (three different rows timed out across three runs), but **the weaker version + of that claim is the true one**, and the difference matters. All three + victims sit in `.config/nextest.toml`'s `nested-cargo-builds` group, which + spawns child Cargo builds and is serialized, so its members queue behind one + another: `cli_configuration_fixture_compiles` timed out twice, then + `production_build_module_slice_has_expected_boundary` — which had *passed* at + 168.114 s in the Stage A baseline — timed out instead. **What the third run + does *not* show is that the earlier victim passed.** It does not appear in + the log because `test-nextest` fails fast and left 16 rows unscheduled; a + passing row always prints (`PASS` rows, 3926, equal the reported passed + count, 3926). So the correct statement is "the set of victims moved", not + "the two siblings swapped outcomes". Even the weaker form is + load-attributable — a candidate defect cannot change *which* member of a + serialized group reaches a fixed wall-clock bound — but it is a claim about + scheduling order, and it must not be inflated into a claim about the other + row's verdict. Impact: do not read "absent from the log" as "passed"; check + whether the runner failed fast first. Two further corollaries: a `TIMEOUT` + row is not a `FAIL` row (no assertion failed, but unscheduled rows exist), + and the doctest half never runs, leaving it *unverified* rather than failed. + Evidence: `/tmp/test-FAIL-ca7ed7d4.out`; the census read 256 compile-slot + waits with a queue depth of 240 ahead and 1108 sccache-unreachable notices, + and zero admission failures. Date/Author: 2026-10-15, implementation agent. + +- Observation: **a plan can cite the wrong mechanism and still reach the right + decision**, which is the harder version of a stale citation to catch because + nothing downstream contradicts it. EP-M1's plan asserted that the flat + `restricted_helpers.rs` form "fails `prefixed_files_beside_directory()`" and + that this forced the directory form. Running both functions of + `tests/workflow_contracts/rust_module_layout_test.py` against synthetic trees + shows it does not: `prefixed_files_beside_directory` globs + `f"{directory.name}_*.rs"`, so it catches an underscore-prefixed *sibling* + (`restricted_helpers_tests.rs`) and never a bare `restricted_helpers.rs` + beside `restricted_helpers/`. A flat file plus a `#[path]`-declared child + passes both functions — as the live `register.rs` + `register/` pair does + today. What actually forbids the flat form is prose: + `docs/repository-layout.md:409-419`. Impact: cite the rule rather than the + function, and when a plan names a specific contract function, run that + function — it is a twenty-line pure-Python port with no dependencies, and + `pytest` is unavailable outside the uv environment, so importing the module + directly is not an option. Evidence: a verbatim port of both functions run + against three synthetic trees (directory-module, flat-plus-`#[path]`-child, + flat-plus-sibling) and validated against the live tree, where both checks + report zero violations — matching the contract's own green run. Reproduce the + port from `rust_module_layout_test.py:23-84`; it needs only `pathlib`, + `itertools`, and `collections`. Date/Author: 2026-10-15, implementation agent. +- Observation: the technical design's line citations are stale, and the loader + has grown since the design was written: `ManifestEnvironment`, + `ManifestBudgetLimits`, the manifest root, a private `ManifestParse` bundle, + and a second private mode enum, `ManifestLoadMode`, all exist. Evidence: + `src/manifest/mod.rs`, `src/manifest/query.rs` at `486b876c`. Impact: + `ManifestLoadOptions` carries the environment bundle and budget limits, and + `ManifestLoadMode` is replaced (C5). + +- Observation: `realpath`, `size`, `contents`, `linecount`, `hash`, and + `digest` read the ambient filesystem relative to the process working + directory, not through `StdlibConfig`'s workspace root. Design §5.5 names only + `glob()` and the file tests as ambient bypasses, and roadmap 7.4.1 provides + sandbox adapters only for those two. Evidence: + `src/stdlib/path/filters.rs::register_filters`, + `src/stdlib/path/path_utils.rs::current_dir_utf8`. Impact: test mode refuses + all six (C4). Because UX design §8.6 makes filters unmockable in the first + version, a manifest using them could never be tested. Resolved by the user: + roadmap 7.4.1 now roots these filters at the sandbox (see `Decision log`). + +- Observation: manifest-query registration does not install the legacy Boolean + formatter that `register_with_config` installs. Evidence: + `src/stdlib/register.rs`. Impact: test registration installs it, or Boolean + interpolation would differ from a build and break I4. + +- Observation: MiniJinja 2.24.0 `add_function` is `add_global` with a function + value, `add_global` is a map insert, and functions share one namespace with + globals. `Environment::globals()` and `Value::call(&State, &[Value])` are + public, so an overlay *can* capture and call the function it replaces. Design + §7's statement that MiniJinja "offers no such retrieval" is wrong for + functions (filters and tests remain unlistable). Evidence: + `minijinja-2.24.0/src/environment.rs` (`add_function`, `add_global`, + `globals`, `pub(crate) fn get_filter`), `minijinja-2.24.0/src/value/mod.rs` + (`Value::call`). Impact: overlays receive the shadowed function now (spy + passthrough), and the surface inventory must be source-scanned rather than + read from the environment. EP-M5 corrects design §7. + +- Observation: a query refusal inside `when` does not drop an entry; it keeps + it with `conditional: true`, and the classifier runs in every mode. Evidence: + `src/manifest/expand/mod.rs` (`WhenResolution::Conditional`), + `src/manifest/jinja_macros/mod.rs::classify_query_evaluation`. Impact: a + misclassified test refusal would silently change the manifest; hence the + query-only gate. + +- Observation: loader telemetry other than expansion reports and budget + exhaustion (template-render and macro metrics, the `env()` lookup counter) + fires in every mode, including query mode. Evidence: + `src/manifest/jinja_macros/telemetry.rs`, `src/manifest/env/reader.rs`. + Impact: "telemetry-free" was too broad; the predicate is named for exactly + what it gates. + +- Observation: `WallClock::is_system` is documented as "recorded for + diagnostics", and `with_clock(system_clock())` yields `is_system == false`. + Evidence: `src/stdlib/time/clock/mod.rs`. Impact: test mode treats any clock + supplied through `with_clock` as explicit injection (C4); the field's + documentation is updated to say it is a policy input. + +- Observation: the base had gone stale between planning and implementation. The + plan was written against `ebcedaef`; `origin/main` had advanced to + `486b876c`, 64 commits ahead, and the merge base was `ebcedaef` itself, so + every one of those 64 commits was invisible to the plan's own review rounds. + Evidence: `git rev-list --count HEAD..origin/main` = 64; + `git merge-base HEAD origin/main` = `ebcedaef`. Impact: the rebase was + rehearsed in a scratch clone before the worktree was touched (free, no + working-tree risk), and it applied with zero conflicts because this branch's + own diff is three Markdown files only. + +- Observation: the module-layout migration landed on `main` and moved the very + seams EP-M1 names. Evidence: + `git diff --stat ebcedaef..486b876c -- src/stdlib/ src/manifest/` shows 160 + files changed. Reconciled as follows. `src/stdlib/register.rs` fell from 398 + to 315 lines because `src/stdlib/register/query_helpers.rs` (new, 151 lines) + already holds `manifest_query_operation_error`, + `MANIFEST_QUERY_DISABLED_HELPER_MARKER`, `is_manifest_query_disabled_error`, + and the stub registrations — EP-M1's first extraction is **partly done + upstream**, under a different path than the plan's + `src/stdlib/restricted_helpers/mod.rs`. Under `src/manifest/`, + `budget_adapter.rs` became `budget/adapter.rs`, `env_reader.rs` became + `env/reader.rs`, `env_policy/` became `access_policy/`, `expand_tests.rs` + became `expand/tests/`, `render.rs` became `render/mod.rs`, and `loading.rs` + and `workspace.rs` were added. Impact: the plan's paths in Table 1, + `Plan of work`, and `Concrete steps` were corrected to the post-migration + names; the EP-M1 outcome now says "relocate the existing stub cluster", not + "create it". + +- Observation: the same migration landed a layout contract, and **three of the + plan's new module paths violate it**. Evidence: + `tests/workflow_contracts/rust_module_layout_test.py` groups siblings by the + first `_`-separated token of the module name + (`module_name.partition("_")[0]`) and treats a directory as a module only + when it holds `mod.rs`; a group of two or more siblings is an + `unrecorded prefix group`, and `DISPARATE_PREFIX_EXCEPTIONS` is `{}`. Probed + against this tree with the contract's own functions: + `src/stdlib/register_query_snapshots.rs` forms `('stdlib', 'register')` with + `register.rs`; `src/stdlib/restricted_helpers.rs` plus + `restricted_helpers_tests.rs` forms `('stdlib', 'restricted')`; and + `src/manifest/load_options.rs` forms `('manifest', 'load')` with the + pre-existing `load_stage.rs`. Impact: the three paths were renamed to + `src/stdlib/register/refusal_snapshots.rs`, + `src/stdlib/restricted_helpers/{mod.rs,tests.rs}`, and + `src/manifest/loader_options.rs`; each was re-probed clean. The rename is a + placement decision, not a scope one: every artefact, row, and milestone keeps + its identity, and EP-M1's move of the snapshot rows into the new module is + unchanged. Three further citations named a module by its bare stem where the + module is a directory with a `mod.rs`, so they were corrected to the + `/mod.rs` form: `src/manifest/render/`, `src/manifest/tests/budget/`, and + `src/stdlib/time/clock/`. Two more were wrong for unrelated reasons: + `src/ninja_gen_property_tests/ninja_oracle.rs` is really + `src/ninja_gen/property_tests/ninja_oracle.rs` (the same migration), and + `src/value/mod.rs` is a path inside the `minijinja` crate, not this one, and + now says so. + +- Observation: **ADR-041 is already spent**, and the reservation has since + moved twice. Evidence: `origin/main` publishes + `docs/adrs/adr-0041-canonical-recipe-shell-quoting-surface.md`. At the + 2026-10-14 rebase a sweep of every remote branch put the ceiling at **054** + (held by `docs/crate-decomposition-programme`), and a free number inside the + bracket was rejected because 044–053 were unclaimed on `main` yet bracketed + by open allocations; the plan reserved **ADR-055** on that basis. The + 2026-10-24 rebase re-swept and found the number **bracketed after all**: + `main` had merged `adr-0056-manifest-linting-under-netsuke-check.md`, leaving + 0055 between a merged 0056 and the still-open 0052–0054 allocations, and the + same rule that rejected 044–053 now rejects 0055. Impact: renumbered to + **ADR-0060**, above the highest allocation anywhere (`adr-0059-…` on + `module-slice-timeout-audit`), per `docs/documentation-style-guide.md`'s + no-renumbering-after-publication rule. Both sweeps must span the *four-digit* + `docs/adrs/adr-nnnn-` layout as well as the legacy `docs/adr-nnn-` one: a + `docs/adr-[0-9]{3}-` pattern silently misses every migrated record, and did + so on the first pass here. + +- Observation: the Stage A baseline's `make test` is red, and the cause is + compiler-admission contention on this host rather than the candidate. + `cli_configuration_fixture_compiles` hit the 300 s `terminate-after` cap + while dozens of `build-limits-rustc` processes from peer worktrees held a + four-slot pool, and its sibling nested-Cargo fixture passed at 168 s in the + same run. Evidence: the baseline table in `Artefacts and notes`; the + repository already documents this test's timeout as queue-bound and refuses + to widen it. Impact: the doctest half of `make test` is unverified at the + baseline and is re-run later. This is a standing hazard for every milestone + gate in this plan, not a one-off: the same test can be expected to time out + again while the host is loaded, so a red `make test` needs the queue census + (slot waits, sccache notices, admission failures) read before it is treated + as a defect. + +- Observation: the plan's governing documents moved substantially on `main`. + Evidence: `git diff --stat ebcedaef..486b876c` gives `developers-guide.md` + 2862 changed lines, `roadmap.md` 293, `netsuke-design.md` 284, `contents.md` + 59, `polonius.md` 32, `adr-008` 32, the technical design 27, `adr-018` 8, RFC + 0007 9. Impact: every EP-M5 anchor is re-read from the rebased tree before + editing, and every patch is applied by quoted context rather than by line + number. The rebase preserved both intents — roadmap 7.4.1 keeps this plan's + file-reading-filter extension *and* `main`'s new 7.6 items; technical design + §5.5 keeps the file-reading-filter paragraph. + +- Observation: a filter that rewrites a path inside snapshot text must be + written against the **serialized** text, not against the path as the + operating system spells it, and "match either separator" is not the same + property as "match the escaped form". Evidence: reverting `separator` to + `[\\/]` fails exactly the JSON-escaped row while every plain row still + passes, so the two properties are independently observable rather than one + standing in for the other. Impact: the same trap applies to any future filter + that redacts a path reaching a snapshot through `serde_json` — the doubled + form, not the raw form, is what decides whether a run-specific name survives + into a checked-in file. A sweep for the class confirms these were the only + two path-rewriting snapshot filters in the tree; every other `add_filter` + call is a MiniJinja template filter, a different API on a different object. + Those two copies were separate functions — `src/snapshot_test_support.rs` + serves `src/snapshots/` and + `tests/manifest_load_options/fixtures/settings.rs` serves + `tests/snapshots/` — and they are now one shared builder, per the decision + recorded below. Likewise the `ManifestQuery` claim was swept: it is named in + 10 places across five documents outside this plan — `developers-guide.md`, + `roadmap.md`, ADR-0050 (then numbered 018), RFC 0007, and the technical + design — and the corrected paragraph was the only site asserting the process + environment. + +- Observation: the review's two minor findings were one defect reported twice, + and a raw finding count therefore overstated the work by a third. Evidence: + both cite the same file, the same line range, and the same remedy, differing + only in wording and in the suggested diff. Impact: a finding count must be + de-duplicated by (file, lines, defect) before it is quoted as a measure of + how much was wrong, or the record implies work that no one did. + +- Observation: a review finding can name a real defect while stating a false + reason for it, and the reason is checkable on its own. Evidence: the second + milestone's review filed one `trivial` finding asking that the duplicated + temporary-path filter be consolidated into `test_support`, justified by "the + first repair round already missed one copy's defect". That premise is false: + `git show --stat 51fb1b49` records **both** files changed by that commit, and + both blobs at that revision carry the identical + `settings.add_filter(&format!(r#"{prefix}[\\/][^\s"']*"#), "[TMP]");`. Round + one did not miss a copy; it applied the same incomplete pattern to both, + which is exactly why "either separator" failed identically on each. Impact: + the premise and the remedy are separate verdicts. Here the remedy was + independently sound — `AGENTS.md` lists duplicated code as a refactoring + trigger, and two copies each guarded only by their own copy of the same cases + can drift apart while both stay green — so the finding was repaired on its + own merits with the false premise recorded rather than inherited. A + reviewer's stated *reason* being wrong is not by itself a reason to reject + the *request*; the two are verified separately, against source. + +- Observation: the scrutineer's closing recommendation — add a `slow-timeout` + override for `cli_configuration_fixture_compiles` — is one the repository has + already considered and refused, in the very file it would be written in. + `.config/nextest.toml:51-70` grants that test a group slot and then argues + that its budget stays at the profile's five periods because the child build + is awaited inside the test body, so the queue wait counts against the + allowance: a wider budget "would cover the delay, and would not remove the + contention that causes it". The same comment records the measured spread + (171.4 s quiet, 709.2 s on a cold tree, terminated at the cap on a contended + one) and the ordering that caps any per-test allowance at 720 s. Impact: the + recommendation is declined on the file's own evidence, and the standing + disposition stays **re-run, do not repair**. This is worth recording because + the suggestion will recur every time this test times out, and re-deriving the + refusal each time is wasted work. + +- Observation: the canonical gate-log path names the **branch**, not the run, so + a later local gate run silently destroys the earlier batch's evidence at the + same path. Evidence: after the seven-gate batch at `8cf507c2`, repairing two + Markdown defects and re-running `check-fmt`, `markdownlint`, `typecheck`, + `lint`, and `nixie` with the prescribed `tee` template overwrote all five + logs in place; the per-gate durations the batch had reported then had no + surviving log to cite. The fresh runs prove the exit codes and the counts + quoted above, but they do not prove the durations, so the durations were + removed rather than quoted from a narrative alone. Impact: a batch's numeric + detail is only durable if its logs are copied aside before any re-run, since + the template in `AGENTS.md` deliberately isolates work per branch rather than + per revision. Two defences, either sufficient: copy the batch's `.out` and + `.exit` files to a revision-suffixed name immediately on receipt, or quote in + the plan only figures the surviving logs still show. + +- Observation: `ManifestLoadOptions<'a>` is **invariant** in `'a`, because it + stores `Option<&'a mut dyn FnMut(ManifestLoadStage)>` and `&mut` is invariant + in its referent. Consequence: a constructor cannot return a value whose + lifetime outlives its own borrow unless it is generic in that lifetime, so + `manifest_query` is + `fn manifest_query<'options>() -> ManifestLoadOptions<'options>` rather than + returning a `'static`-flavoured value, and `ManifestParse` gained a second + lifetime (`<'a, 'name>`) so the manifest-name borrow is not forced to match + the options borrow. Evidence: the natural signature failed to compile before + the split. Impact: an options struct carrying a borrowed observer is + invariant by construction; plan for two lifetimes at the parse boundary + rather than reaching for a clone or a boxed closure. + +- Observation: an OBL-1 seeded fault that removes the only use of a helper is + observed as a **compile error**, not as failing rows. Evidence: deleting the + `register_legacy_boolean_formatter(env)` call made `-D dead-code` reject the + build with "function `register_legacy_boolean_formatter` is never used" (exit + 101), and routing every full render through `RenderMode::ManifestQuery` did + the same to `RenderMode::Full`. Each fault was then reseeded so the item + stayed used — `let _ = register_legacy_boolean_formatter;` and a temporary + `let _ = RenderMode::Full;` rider — which produced the intended 11 failing + rows. Impact: a seeded fault must keep its subject live, or the run proves + only that the compiler notices dead code. A first attempt that exits 101 is + not evidence about the rows and must be redesigned, not quoted. + +- Observation: the OBL-12 obligation asked for a `tests/ui` fixture compiled by + `trybuild`, which C7 forbids as a new dependency. The substitute is an + integration test in `tests/` that imports the public entry point from outside + the crate and loads a manifest. Evidence: + `tests/manifest_load_options_embedder.rs` passes and would fail to compile if + the surface were not `pub`. Limitation: it proves the surface is reachable + and compiles for an external crate; it cannot prove that some *other* item + stayed private. That negative is covered by the conformance check against the + plan's item list, not by this test. + +- Observation: the OBL-2 table's sixth column, "may carry overlays", has no + production consumer until EP-M4, so a non-`cfg` accessor would be dead code + under `-D warnings`. It is therefore `#[cfg(test)]` for now. Impact: EP-M4 + must remove the gate when the overlay decision is consumed; until then the + table still asserts a stated answer for every variant, which is the property + OBL-2 exists to protect. + +- Observation: both public constructors' doctests load a manifest against the + ambient workspace, so they run in `make test`'s doctest pass rather than + being illustrative only. Impact: a doctest added to this module is a + behavioural check with a real working-directory dependency; it must avoid + `env()`, `glob()`, and any helper whose result depends on where the test runs. + +- Observation: adding a new module can fail a gate that never mentions the + module's subject. Evidence: `src/manifest/loader_options.rs` is loaded by the + manifest loader, so the Kani harness closure reached it, and + `test_scope_covers_the_harness_closure` — a workflow-contract test, not a + Rust test — failed at `61e6cf102` until the path was added to + `tools/kani/proof-scope.toml`. The same class was already recorded for + EP-M1's relocated classifier. Impact: "which gate covers this file?" is the + wrong question for a derived whole-graph property. Ask which whole-repository + inventories the new file enters — harness closure, surface inventory, module + line-count caps, compiled-source roots — and run those gates even when the + file's own subject is unrelated to them. + +- Observation: the plan's item list and OBL-11 disagree about + `TestModeRefusal`'s visibility. The `Interfaces and dependencies` section + lists it among the crate-internal additions, but OBL-11's artefact + `tests/manifest_load_options/corpus_fidelity.rs` is an external integration + test that must downcast the error source and call `operation()`. Evidence: + the type is re-exported as `netsuke::stdlib::TestModeRefusal`, and the corpus + test compiles and passes against it. Impact: a public error type is now part + of the surface, and EP-M5 must reconcile it in the design. The alternative — + classifying refusals by marker text alone — would have made the test unable + to distinguish a refusal from any other error carrying the same text, which + is exactly what OBL-4 exists to prevent. + +- Observation: the OBL-3 and OBL-4 rows could not share the planned + `src/stdlib/restricted_helpers/tests.rs` artefact. Evidence: that file was + already close to the 400-line cap, and the inventory scan, reachable-probe, + typed-source, and locale rows add roughly 380 lines. They live in + `src/stdlib/restricted_helpers/surface_contracts/`, declared beside + `mod tests;`. Impact: the plan's artefact path names are corrected by this + entry; the coverage is unchanged. + +- Observation: there is no "clock absent" state to observe through the public + loader, so OBL-7's default-clock row measures the injected-clock branch + instead. Evidence: `StdlibConfig::from_current_dir()` always supplies a + clock, so a test load built from it carries the system clock; + `test_load_refuses_now_with_the_default_clock` therefore loads through the + default configuration and requires `now()` to be refused. The seeded fault + that drops the `is_system()` branch renders an ambient timestamp into the + manifest, which is the evidence the row measures the branch rather than the + helper's absence. Impact: the row proves "the ambient clock is refused", not + "a clockless load is refused"; the latter is not constructible. + +- Observation: OBL-11's stated non-vacuity seed is inapplicable to this + corpus. The plan asks for `register_for_test` to register the real `hash` + filter and for the corpus run to fail on the file using it, but no corpus + manifest evaluates `hash`. Evidence: grepping `tests/data/` for `hash` finds + no manifest use, and a probe run under the fault reported no new failure. + Substitute: register the real `shell` filter, which is the Process family's + only refusal in this corpus, so the aggregate fails with + `no corpus manifest refused from these families: ["process"]`. Impact: the + family aggregates are only as strong as the corpus's coverage of each family; + the substitution is decisive here, but a future family with no corpus user + needs its own fixture rather than the documented seed. + +- Observation: as with EP-M2, an OBL-3/4/6/7 seeded fault written naively is a + **compile error** under `-D warnings`, not a failing row. Evidence: removing + `disabled_env_reader`'s only import failed the build, and deleting the + refusal loop's only callers produced `dead_code` errors for + `register_filter_refusal` and `register_test_refusal`. Each fault was + reshaped to keep its subject live — the refusal loop stayed and the query + registration was appended after it, and the reader fault called + `super::process_env_reader()` instead of importing the default. Impact: a + first attempt that exits 101 says nothing about the rows and must be + redesigned, matching the EP-M2 finding. + +- Observation: a seeded registration-order fault exposed two vacuous EP-M4 + rows that the green run could not. With the overlays registered before the + stdlib, `passthrough_overlay_returns_the_shadowed_result` still passed, + because the stdlib's own `timedelta` produced the same rendered command the + comparison expected, and `overlay_now_sees_the_refusing_stub` still found a + `now` refusal in the error chain, because the stub itself had answered. + Impact: an equality-or-error assertion against a helper that exists without + the overlay cannot distinguish "the overlay ran" from "the overlay never + ran". Repaired by making each closure bump an `Arc` and + asserting exactly one dispatch; the re-seeded fault then failed three rows. + Date/Author: 2026-11-01, implementation agent in EP-M4. + +- Observation: the EP-M4 red transcript does not match the plan's predicted + error text. The plan expected + `error[E0432]: unresolved import netsuke::manifest::TemplateOverlays`; the + run produced three `E0432` lines naming `crate::manifest::…` paths and one + `E0599` for the missing `TestSurface::with_overlays`. Impact: the red + transcript is evidence of absence, not a fixed string; a future plan should + predict the crate-internal path shape and treat a missing builder as its own + failure rather than part of the import error. Date/Author: 2026-11-01, + implementation agent in EP-M4. + +- Observation: a lint repair can pass every gate on the pinned toolchain and + still fail the Whitaker stage, because Whitaker builds the crate with its own + older nightly. Impact: `clippy::map_unwrap_or` and its "obvious" replacement + are not interchangeable here — `Option::map_or_default` is unstable below the + pin. Evidence: `make lint` exited 2 at `0e23c1c04` with + `error[E0658]: use of unstable library feature 'result_option_map_or_default'` + at `src/manifest/test_surface/overlays.rs:273`, while `make lint-clippy` was + green on the same bytes. Action: use the let-else form the neighbouring + function already used, and keep the comment that records why. Date/Author: + 2026-11-01, implementation agent in EP-M4. + +- Observation: the branch no longer merges cleanly into `origin/main` + (`e5d57c58`). Both sides split the same localization key table: this branch + moved `MANIFEST_*` into `src/localization/manifest_keys.rs`, while `main` + moved `CLAP_ERROR_*` into `clap_error_keys.rs` and added a hand-assembled + `ALL_KEYS` union in `src/localization/mod.rs`. Impact: PR #807 reports + `mergeable: CONFLICTING` and needs a rebase whose `mod.rs` merge must be + audited by hand, because a clean merge would leave `ALL_KEYS` covering three + tables while four exist. `tests/build_l10n_keys_tests.rs` and + `tests/build_l10n_audit_tests.rs` are the oracles for that resolution. + Date/Author: 2026-11-01, implementation agent in EP-M4. + +## Decision log + +- Decision: `TestModeRefusal` and its `operation()` accessor are public, + re-exported as `netsuke::stdlib::TestModeRefusal`. Rationale: OBL-11 is an + external integration test and must name the type; the plan's crate-internal + listing was written before that artefact was fixed. EP-M5 records the type in + the design's surface. Date/Author: 2026-10-31, implementation agent in EP-M3. + +- Decision: the EP-M3 inventory and refusal rows live in + `src/stdlib/restricted_helpers/surface_contracts/`, not in the planned + `tests.rs`. Rationale: the existing `tests.rs` is near the 400-line cap, and + the new rows are a coherent unit about the test surface rather than more + query-refusal cases. Date/Author: 2026-10-31, implementation agent in EP-M3. + +- Decision: those rows live in + `src/stdlib/restricted_helpers/surface_contracts/`, a directory module, not + in a `test_mode_tests/` directory. Rationale: the module-layout contract + rejects a shared `test` prefix between `test_mode.rs` and a sibling directory + unless the collision is coincidental, and this one is not — the directory + tests that file. Renaming keeps both the contract and its docstring truthful, + where an exception entry would have had to argue a coincidence the code + contradicts. The declaration is `#[cfg(test)]`, so no production path moves. + Date/Author: 2026-11-01, implementation agent in EP-M3. + +- Decision: OBL-11's non-vacuity seed is the real `shell` filter rather than + the documented `hash` filter. Rationale: no corpus manifest uses `hash`, so + the documented seed cannot make the aggregate fail; `shell` is the Process + family's only refusal and does. Date/Author: 2026-10-31, implementation agent + in EP-M3. + +- Decision: `register_for_test` decides `now()` from + `config.clock().is_system()` rather than from a separate flag on + `TestSurface`. Rationale: "was a clock injected" is already expressible in + the configuration's clock provider, and a second source of truth could + disagree with the one the template actually reads. Date/Author: 2026-10-31, + implementation agent in EP-M3. + +- Decision: `StdlibRegistration` stays a crate-private enum, extended to + `AmbientFull`, `Full(Box)`, `ManifestQuery`, and + `Test(Box)`. The public surface is `ManifestLoadOptions` with + constructors `full(config, environment)` and `test(surface)`; crate-private + `ambient(environment)` and `manifest_query()` serve the wrappers. Rationale: + the roadmap asks to extend the existing enum, not to publish it. Keeping it + private avoids exposing `ManifestQuery` (crate-internal today) and freezing + variant payloads before roadmap 7.4.1 settles sandbox needs. The lazy ambient + default becomes a named variant instead of `None`, so every mode is listed in + one enum (C5). There is no public `Default`: a default silently selecting the + ambient stdlib and process environment would be a trap. Date/Author: + 2026-09-27, planning agent after panel review. + +- Decision (Deviation from design §4.3): overlays live in the test surface, + not in `ManifestLoadOptions`, and `Test` carries `Box` + (`StdlibConfig`, optional `EnvReader`, `TemplateOverlays`) instead of + `Box`. Rationale: an overlay is a capability grant (a `fetch` + overlay stands in for the network), so it belongs with the mode that decides + capabilities (C5). The build path then cannot carry overlays at all, which + makes the loader-level half of I7 hold by construction. The design's + "ordinary parameter, not a test-only `cfg`" requirement still holds (C9). + Roadmap 7.1.3's spike runs under a test surface, which loses nothing because + it pins MiniJinja behaviour. Alternative considered: the design's options + field, accepted in every mode; rejected because it lets a caller overlay a + build and splits capability decisions across two places. Date/Author: + 2026-09-27, planning agent after panel review. + +- Decision (Deviation from design §4.3): `ManifestLoadOptions` has private + fields and constructors, carries a `ManifestEnvironment` (reader plus access + policy) instead of `Option`, and carries `ManifestBudgetLimits`. + Rationale: the codebase bundles reader and policy so no caller injects one + without the other; every production load is budgeted (ADR-0050); private + fields let 7.1.3 add macro substitutions without breaking callers. For a test + load, the reader lives in the `TestSurface` (owned `EnvReader`, default + `disabled_env_reader`, policy `EnvAccessPolicy::default()`), so query options + have no environment to contradict. Date/Author: 2026-09-27, planning agent + after panel review. + +- Decision: `env()` and `glob()` are registered once, at step 3, by the manifest + loader, according to `StdlibRegistration`: real for `AmbientFull` and `Full`; + refusing with the query text for `ManifestQuery`; for `Test`, `env()` is real + over the surface's reader and `glob()` refuses. The stdlib's restricted + registrations stop registering `env` and `glob`. Rationale: today the loader + registers them and query registration overwrites them, splitting one decision + across two modules (C5). The query stub text is unchanged (C2). Date/Author: + 2026-09-27, planning agent after panel review. + +- Decision: test mode's surface is deny-by-default, built from an inventory. + Real: lexical path filters, collection filters, `timedelta`, the legacy + Boolean formatter, `env()` over the surface's reader, and `now()` when the + config's clock was supplied through `with_clock`. Refusing: `glob`, `fetch`, + `shell`, `grep`, `contents`, `realpath`, `expanduser`, `size`, `linecount`, + `hash`, `digest`, `which` (function and filter), `command_available`, every + entry of `FILE_TESTS`, and `now()` with the default system clock. Refusing + stubs accept any arguments, so the widest real call shape still reaches the + refusal. Rationale: UX design §1 promises "no network, no wall-clock + dependency, no mutation of the project root"; design C4 makes an unmocked + impure call an error. The functions among these (`glob`, `which`, `fetch`, + `command_available`, `now`) remain replaceable by overlays; filters and tests + are not mockable in the first version (UX §8.6). Roadmap 7.4.1 later replaces + the `glob`, file-test, and file-reading filter stubs with sandbox-rooted + adapters. Recorded in ADR-0060. Date/Author: 2026-09-27, planning agent. + +- Decision (Deviation from design §5.5): `which` and `expanduser` refuse under + test, although §5.5's table implies they run against an empty `PATH` and a + missing home. Of `StdlibConfig`'s settings, test mode honours only the clock + in 7.1.2; the workspace root becomes relevant when 7.4.1 roots `glob`, the + file tests, and the file-reading filters at it. Rationale: `which` with an + empty `PATH` yields a misleading "not found" rather than a refusal pointing + at doubles, and `expanduser` exposes a host-shaped answer. Refusal keeps the + rule "unmocked impure call is an error" uniform. Date/Author: 2026-09-27, + planning agent after panel review. + +- Decision: test-mode refusals are MiniJinja `InvalidOperation` errors whose + message is the Fluent key `stdlib.test_mode.helper_unavailable` (argument + `operation`), beginning with the verbatim code + `[netsuke::jinja::test_mode::unavailable]` in every catalogue, and whose + source is a typed `TestModeRefusal { operation }`. Code recognizes them by + downcasting the source, as `src/manifest/budget/adapter.rs` does for budget + exhaustion. The query marker and its substring check are unchanged. + Rationale: a translated marker cannot be matched by substring in 34 of 35 + locales (C6); 7.3.2 and 7.5.5 need the operation name as data. Date/Author: + 2026-09-27, planning agent after panel review. + +- Decision: `classify_query_evaluation` runs only when the load is a + `ManifestQuery`; the flag reaches expansion from + `StdlibRegistration::classifies_query_refusals`. The classifier first moves to + `src/manifest/jinja_macros/query_classification.rs` (EP-M1). Rationale: + makes the silent-`conditional` failure structurally impossible outside query + mode, whatever a future stub's message says. Date/Author: 2026-09-27, + planning agent after panel review. + +- Decision: overlays are registered after manifest macros and before + `expand_foreach_with_budget`. Each overlay receives an `OverlayCall` exposing + the MiniJinja `State`, the positional and keyword arguments, and the shadowed + function (`Option`, captured from `Environment::globals()` at + registration). Names are validated first, against one namespace computed + before step 3: overlays may not use `env` (the environment seam is the + surface's reader, which keeps `EnvAccessPolicy` in force), any name beginning + `__netsuke_` (an overlay named `__netsuke_manifest_macro_imports` would erase + every macro import), a manifest `vars` key, or a manifest macro name. + Validation is skipped entirely when there are no overlays, so no other mode's + error order moves. A rejected overlay raises a typed + `TemplateOverlayError::NameCollision { name, kind }` with localized text under + `manifest.overlay.name_collision` and the code + `[netsuke::manifest::overlay::collision]`. Rationale: design §4.3 order; the + namespace computed up front is reused by 7.1.3, whose macro substitutions are + a separate field and a separate mechanism (design §5.4), so rejecting + function overlays on macro names does not block its fallback. A typed error + lets 7.5.5 tell a test-suite mistake from a subject-manifest failure. + Date/Author: 2026-09-27, planning agent after panel review. + +- Decision: `TemplateOverlays` ships with the `functions` map only; + `macro_substitutions` is added by roadmap 7.1.3. Rationale: a macro + substitution must also rewrite the macro-import prelude (design §5.4), which + 7.1.3 spikes. Private fields make the addition non-breaking. Date/Author: + 2026-09-27, planning agent. + +- Decision: the public options entry is string-based: + `from_str_with_options(yaml, name, options)`. Path loads go through a + crate-private + `query.rs::from_path_with_options(path, registration_for, options)`, where + `registration_for` is a closure turning the opened workspace into a + `StdlibRegistration`. `ManifestLoadMode` is deleted. Rationale: a path load's + `StdlibConfig` is derived from the workspace it opens, so a caller cannot + supply it up front; a closure keeps one list of modes (C5). Keeping the path + entry crate-private, beside `query.rs` as design §4.2 and §13 place it, + serves 7.5.3 (which validates subject paths before `open_manifest_workspace`) + without publishing an ambient-path API. The string entry leaves the manifest + root unset, so a `Full` load's `glob()` base is the process directory exactly + as `from_str` behaves today; the `Test` glob base arrives with 7.4.1's + sandbox adapter, taken from the surface's `StdlibConfig` workspace root. + Date/Author: 2026-09-27, planning agent after panel review. + +- Decision: the predicate gating expansion-report metrics and the + budget-exhaustion metric is `records_expansion_and_budget_telemetry`, + `pub(crate)`, false for `ManifestQuery` and `Test`. Other loader telemetry + continues to fire in every mode. Recorded as ADR-0050 Addendum E. Rationale: + those counters are described as "normal manifest loading" metrics; counting + test runs would distort usage evidence. The narrower name says exactly what + is gated. Date/Author: 2026-09-27, planning agent after panel review. + +- Decision: test mode renders with `RenderMode::Full`. + Rationale: a test must see exactly what a build renders (I4). Date/Author: + 2026-09-27, planning agent. + +- Decision: the differential corpus reuses `tests/data/*.yml` (for example + `glob.yml`, `jinja_env.yml`, `jinja_is.yml`) and adds only the fixtures no + existing file covers, under `tests/data/manifest_load_options/`: + `boolean_interpolation.yml`, `clock_now.yml`, `uses_fetch.yml`, and + `uses_shell.yml`. The harness walks a directory at run time, so 7.1.4 can + point it at `examples/`. Rationale: panel scope review; roadmap 7.1.4 owns the + `examples/` run and its RFC evidence. Date/Author: 2026-09-27, planning + agent after panel review. + +- Deviation: three further fixtures join the corpus, taking the new set to + seven. `pure.yml` is the baseline an ordinary load is read against; + `malformed.yml` pins the parse-failure path, which no existing fixture + exercises through the *loader* rather than through a schema check; and + `reads_env.yml` pins the injected-reader plumbing, which no existing + `jinja_env*.yml` fixture does because those are shaped for helper-level tests + rather than for a loader that supplies its own reader. Rationale: the corpus + is what the equivalence oracle compares, so a path with no fixture is a path + the refactor can change unobserved — the three additions close paths the + original four left open. `uses_shell.yml` is used only where the surface + refuses `shell`, so no row executes a command. Date/Author: 2026-10-15, + implementation agent under the `sdlc-implementation` workflow. + +- Decision: invariant I7 is discharged here at the loader level: the build + cannot carry overlays, and every wrapper's canonical output is unchanged. The + "manifest with a `tests` block" half depends on roadmap 7.2.1's schema field + and is deferred there. Date/Author: 2026-09-27, planning agent. + +- Decision: `ortho_config` is not used, and the users' guide is not changed. + Rationale: no command-line flag, configuration key, environment variable, or + user-visible behaviour changes; `netsuke test` wiring (roadmap 7.6) is where + `ortho_config` layering and localized help apply. The developers' guide gains + the loader-entry convention instead. Date/Author: 2026-09-27, planning agent. + +- Decision: a new ADR, ADR-0060, records the deny-by-default test surface and + the refusal-code contract; telemetry goes to ADR-0050 Addendum E; the options + shape goes to design §4.3 only. Rationale: the surface decision is hard to + reverse and spans network, process, filesystem, and clock, which ADR-0008 + (environment seams) does not cover. At the time of writing ADR-0039 and + ADR-0040 were claimed on unmerged branches; both have since merged, and the + allocation has been re-swept at each rebase. Re-check at every rebase. + Date/Author: 2026-09-27, planning agent after panel review. + +- Decision: the six file-reading filters (`contents`, `size`, `linecount`, + `hash`, `digest`, `realpath`) refuse under test in 7.1.2, and roadmap 7.4.1 + is extended to replace those stubs with sandbox-rooted adapters, alongside the + `glob()` and file-test adapters it already owned. Roadmap 7.4.1 and + technical design §5.5 were updated in this plan's pull request. Rationale: + filters cannot be doubled in the first version (UX §8.6), so without sandbox + adapters a manifest that reads or hashes a file could never be tested. The + alternatives, a new 7.4.x item or permanent refusal, were rejected. The 7.1.2 + implementation is unchanged by the decision: these filters refuse until 7.4.1 + lands. A related follow-on remains out of scope here (C2): query mode still + runs `FILE_TESTS` against the real filesystem, and the query stubs for `hash`, + `digest`, and `contents` reject keyword arguments before refusing. + Date/Author: 2026-09-27, decided by the user; recorded by the planning agent. + +- Decision: the temporary-path snapshot pattern moves to + `test_support::temp_path_filter` and both snapshot trees bind it, rather than + each keeping a private copy. Rationale: the two copies sat in crates that + cannot reach each other — one is `#[cfg(test)]` code inside the library, the + other is a separate integration crate — so an edit to either copy could not + be seen by the other copy's tests, and both suites would stay green while the + patterns diverged. Siting the pattern in `test_support` also lets the + extracted rows run once, in a crate both trees already depend on, instead of + the same five cases existing twice. **Scope and re-use policy:** the shared + unit owns the *pattern* only; each crate keeps its own `insta::Settings` and + its own `add_temp_dir_filter`, because snapshot directory and module-prefix + handling legitimately differ between the trees. Any future snapshot filter + that rewrites a path builds its pattern here and does not restate it. The + policy is recorded in `docs/developers-guide.md` under *The temporary-path + snapshot filter*. Rationale against the alternatives: a drift-detecting + contract test would have to read both copies as text and compare them, which + adds a check to preserve a duplication rather than removing it, and a sweep + confirmed these were the only two path-rewriting snapshot filters in the tree + (every other `add_filter` is a MiniJinja template filter, a different API on + a different object). Constraint check: C7 (no new crate dependency) holds — + `regex` was already resolved in `Cargo.lock` as a dev-dependency of the root + crate, so the change adds a second consumer, not a crate. This followed a + review finding whose stated reason was false (see `Surprises & discoveries`); + the decision stands on the duplication, not on that reason. Date/Author: + 2026-10-16, decided by the implementation agent as the disposition of a + review finding; recorded here for the approver. + +- Decision: `ManifestLoadOptions` has **no public `Default`**. The public + constructors are `full(config, environment)` and the crate-private `ambient`, + `manifest_query`, and `deferred`. Rationale: a default would have to pick an + ambient stdlib root, an ambient environment reader, and a registration, so an + embedder that wrote `..Default::default()` would silently inherit the process + state this milestone exists to make explicit. Omitting the impl makes that + mistake a compile error instead of a review note. Date/Author: 2026-10-31, + implementation agent in EP-M2. + +- Decision: the loader owns `env()` and `glob()` registration, driven by + `StdlibRegistration`'s dispositions, while the refusal *shapes* still come + from `restricted_helpers`. Rationale: whether a load may read the host is a + property of the load, not of the library surface (C5), so leaving these two + registrations inside the stdlib entry points would reintroduce a second mode + decision; reusing the inventoried stubs keeps every query refusal message + byte-identical (C2). Date/Author: 2026-10-31, implementation agent in EP-M2. + +- Decision: `ManifestLoadOptions::deferred` exists as a placeholder whose + registration is always replaced by `from_path_with_options`'s + `registration_for` closure, and its doc comment says so. Rationale: the + alternative, an `Option` field, would push an unreachable + `None` arm into the loader core for a value the core never observes. The cost + is one builder that must not be used directly, which the doc comment makes + explicit. Date/Author: 2026-10-31, implementation agent in EP-M2. + +- Decision: query loads share one process-wide `disabled_env_reader` behind a + `OnceLock` instead of constructing a reader per load. Rationale: the reader + is stateless, the query registration installs refusal stubs so it is never + consulted, and a `'static` reader lets `manifest_query` be generic in its + options lifetime (see `Surprises & discoveries` on invariance). Date/Author: + 2026-10-31, implementation agent in EP-M2. + +- Decision: `StdlibRegistration::accepts_overlays` is `#[cfg(test)]` until + EP-M4 consumes it. Rationale: the decision must be stated per variant now, so + that adding a variant without an overlay answer fails the table, but an + accessor with no production caller is dead code under the repository's + `-D warnings` policy. EP-M4 removes the gate when the overlay machinery needs + the value. Date/Author: 2026-10-31, implementation agent in EP-M2. + +- Decision: OBL-12 is discharged by an external-crate integration test rather + than a `tests/ui` fixture with `trybuild`, because C7 forbids a new + dependency. Rationale and limitation are recorded in + `Surprises & discoveries`; the compile reachability is proven, the privacy + negative is covered by the conformance check. Date/Author: 2026-10-31, + implementation agent in EP-M2. + +- Decision: the one stale `ManifestLoadMode::ManifestQuery` citation in + `docs/developers-guide.md` was corrected inside EP-M2 rather than deferred to + EP-M5. Rationale: this milestone deletes the type, so leaving a live guide + naming it would make EP-M2's own documentation false on merge. EP-M5 still + owns the section's full narrative and the design/ADR reconciliation. + Date/Author: 2026-10-31, implementation agent in EP-M2. + +- Decision: `TemplateOverlayError` derives `Debug` and hand-writes `Display` + instead of using `thiserror`. Rationale: the message must carry the localized + `manifest.overlay.name_collision` text with `name` and `kind` interpolated + through `LocalizedMessage::with_arg`, which `thiserror`'s attribute cannot + express; the same reason the repository's other localized errors implement + `Display` by hand. Date/Author: 2026-11-01, implementation agent in EP-M4. + +- Decision: `OverlayNameKind` renders hyphenated, untranslated tokens + (`environment-seam`, `loader-internal`, `manifest-variable`, + `manifest-macro`) into the collision message. Rationale: the token names the + namespace, not the prose around it, so translating it per catalogue would + make a diagnostic harder to match; only the surrounding sentence is + localized. Date/Author: 2026-11-01, implementation agent in EP-M4. + +- Decision: overlays reach the loader through `StdlibRegistration::overlays()` + and a `.cloned()` taken before `register_stdlib` consumes the registration. + Rationale: `register_stdlib` takes the registration by value, so the + capability must be read first; the clone copies an `Arc` handle per callable + and the name map, not the dispatch closures, which matches the Polonius-era + policy of stating a clone's cost rather than hiding it. A second capability + field on the registration enum would duplicate the mode that already decides + the grant. Date/Author: 2026-11-01, implementation agent in EP-M4. + +- Decision: `src/manifest/test_surface.rs` became the directory module + `src/manifest/test_surface/{mod,overlays}.rs`, and the overlay tests became + `src/manifest/tests/overlays/{mod,namespaces,registration}.rs`. Rationale: + the combined module exceeded the 400-line cap and the house layout is + `foo/mod.rs` plus plain `mod child;`; splitting by name-validation versus + dispatch keeps each file a single concern. Date/Author: 2026-11-01, + implementation agent in EP-M4. + +- Decision (Deviation from Stage D's stated filename): EP-M5 mints + `docs/adrs/adr-0070-deny-by-default-test-mode-stdlib.md`; the plan's + `adr-0060-…` serial is stale. Rationale: + `docs/adrs/adr-0060-readable-inline-recipe-progress.md` already exists on + this branch and on `origin/main`, so reusing `0060` would create the + duplicate-serial conflation the repository's ADR conventions forbid. A sweep + of all 83 `origin` heads found `adr-0069` the highest serial in use and + `adr-0070` free on every head. The deviation changes only the filename: the + decision, its scope, and its index entry in `docs/contents.md` are as + approved. Every earlier `ADR-0060` reference in this plan is a historical + planning record of the serial as it stood when written; the minted record is + ADR-0070. Date/Author: 2026-11-01, implementation agent in EP-M4. + +- Decision: the `Tolerances` scope ceiling is recorded as an accepted + exception, not a stop. Measured from the pull request's comparison base + `e5d57c58` to head `5c13d554b`, with the plan's own exclusions applied (this + plan, the 35 locale catalogues, every `.snap`, and YAML fixtures), the change + is **81 files and 6,603 net added lines**, above the 42-file and 2,400-line + triggers. Rationale: the triggers were computed from Table 1, which names + whole directories as single rows (`tests/manifest_load_options/*`, + `tests/bdd/…`, `docs/…`, `locales/*`), so a file-granularity count was never + commensurable with that inventory. Every changed file is either such a row + expanded into its files or a file the plan requires elsewhere: `build.rs`, + `build_l10n_audit/`, and the fourth key table + `src/localization/manifest_keys.rs` carry the two mandated Fluent keys, + `test_support/src/temp_path_filter.rs` shares the snapshot temporary-path + filter both snapshot crates need, and `tools/kani/proof-scope.toml` is the + reachability bookkeeping the Kani contract enforces. No capability outside + the approved scope was added, and the milestone counts, interfaces, and + dependencies are as planned. Date/Author: 2026-11-02, implementation agent in + the hosted assessment response. + +## Outcomes & retrospective + +Delivered in full. The loader now has one options-carrying entry point, +`from_str_with_options`, over a crate-private four-variant +`StdlibRegistration`; the pre-existing entry points are thin wrappers. A +`TestSurface` grants a deny-by-default stdlib whose availability is decided by +one `SURFACE` inventory, refuses withheld helpers with a localized diagnostic +carrying a typed `TestModeRefusal` source, and lets a test replace functions — +including `glob` — with `TemplateOverlays`, whose name validation and +registration order are pinned by tests. The build path is unchanged, and the +differential property over generated pure manifests holds between `Full` and +`Test`. + +Evidence: EP-M0's characterization snapshots, EP-M1's byte-identical +extractions, EP-M2's wrapper and entry-point suites, EP-M3's `Test` mode and +differential property, EP-M4's overlay namespace and registration tests with +two seeded faults proving non-vacuity, and EP-M5's documentation and roadmap +closure. Every milestone ended with the full gate set run by `scrutineer`; the +EP-M4 batch's three findings were repaired and re-verified (see `Progress`). + +Retrospective: + +- The plan's ADR serial was stale. Checking the target namespace before + claiming a number — the serial `0060` was already in use on `main` — cost one + sweep and prevented a duplicate-serial conflation. Recorded as a deviation, + and EP-M5 minted `0070`. +- A gate batch is cheap to start and expensive to repeat. After the batch + reported three findings, repairing and re-verifying each one directly was far + faster than another full cycle, and identified two alarming results as + invocation artefacts rather than defects. +- The design's claim that MiniJinja offers no way to retrieve a registered + function was wrong. Enumerating `Environment::globals` reaches it, which is + what makes a passthrough overlay possible; the documentation now says so and + roadmap 7.1.3 can build macro substitution on the same capture. + +Remaining work is outside this item: macro substitution (7.1.3), sandbox-rooted +adapters for `glob`, the file tests, and the file-reading filters (7.4.1), and +the runner slices that consume the surface. + +## Conformance basis + +Upstream artefacts, at `main` commit `486b876c`: + +- RFC 0007, `docs/rfcs/rfc-0007-netsukefile-testing-framework.md` — scope. +- Technical design, `docs/netsuke-test-framework-technical-design.md` (status + Draft): TD-4.2 (extend `StdlibRegistration`; reuse the refusal mechanism and + `disabled_env_reader`), TD-4.3 (options, overlays, registration order steps + 1–6, unconditional compilation), TD-5.2 (clock owned by `StdlibConfig`), + TD-5.5 (refusing stubs under test), TD-7 (spy passthrough premise), TD-I4, + TD-I5, TD-I7. +- UX design, `docs/netsuke-test-framework-ux-design.md`: UX-1 (deterministic + by default), UX-8.6 (what can be mocked). +- Roadmap item 7.1.2, `docs/roadmap.md`: RM-a (options and overlays, wrappers), + RM-b (`Test` mode), RM-c (reuse the diagnostic shape and + `disabled_env_reader`), RM-d (registration order), RM-e (differential tests + for I4 and I7). +- ADR-0008 (environment seams), ADR-0010 (glob capability scoping), + ADR-0050 (bounded template evaluation and query telemetry suppression). +- Governing standards: `AGENTS.md`, `docs/documentation-style-guide.md`. + +There is no terms-of-reference document for phase 7; RFC 0007 plays that role. +Proposed deviations from TD-4.3, TD-5.5, and TD-7 are recorded in +`Decision log` and require the approver's acceptance. + +Trace chains: + +```plaintext +RM-a, TD-4.3 -> EP-M2 -> OBL-1 (wrapper equivalence), OBL-2 (mode table) +RM-b, TD-4.2, UX-1 -> EP-M3 -> OBL-3 (inventory), OBL-4 (refusals), OBL-6 (env), OBL-7 (clock) +RM-c, TD-4.2 -> EP-M1, EP-M3 -> OBL-4, OBL-6, OBL-10 (query unchanged) +RM-d, TD-4.3, TD-7 -> EP-M4 -> OBL-8 (names), OBL-9 (order and passthrough) +RM-e, TD-I4 -> EP-M3 -> OBL-5 (fidelity property), OBL-11 (corpus) +RM-e, TD-I7 -> EP-M2, EP-M4 -> OBL-1, OBL-2 (build cannot carry overlays) +TD-I5 (part) -> EP-M3 -> OBL-4, OBL-6, OBL-7 +``` + +TD-I5 is advanced, not discharged: roadmap 7.5.1 owns the end-to-end I5 tests +through pipeline actions. + +## Verification plan + +### Axioms (assumed, not verified) + +- AXIOM-1. MiniJinja 2.24.0 replaces a global of the same name on + `add_function`/`add_global` (source-verified; OBL-9 exercises it). +- AXIOM-2. MiniJinja resolves globals when a template evaluates, so any global + registered before `expand_foreach_with_budget` is visible to every `foreach`, + `when`, and rendered field. +- AXIOM-3. A closure of shape `Fn(&State, Rest) -> Result` + receives every positional argument and keyword arguments as a trailing + `Kwargs` value; `Value::call` invokes a captured function value. +- AXIOM-4. A source attached with `minijinja::Error::with_source` survives to + the loader's `anyhow` chain and can be downcast (the budget code relies on + this; EP-M3 verifies it for refusals and stops under `Tolerances` if not). +- AXIOM-5. `serde_json_canonicalizer` output is deterministic for equal + values; `serde_json::Value` object equality ignores key order. +- AXIOM-6. `proptest`, `insta`, and `rstest` behave as documented. + +### Invariants and lemmas + +- OBL-1 — wrapper equivalence. + Statement: for each of the eleven public entry points and the crate entry + `from_path_for_manifest_query_with_limits`, over the fixtures declared + applicable to it, the canonical result (canonical JSON of the manifest on + success; the `{:#}` error chain on failure) equals the result before the + refactor. Method: characterization `insta` snapshots recorded in EP-M0 from + the unchanged code; re-checked at every later milestone under C12. Rationale: + the pre-refactor code is the only trustworthy oracle; comparing a wrapper + with the core afterwards would be equal by construction. Domain: one row per + entry point. Applicability: every entry uses only pure fixtures plus + `jinja_env.yml` with an injected reader; `from_str` and other + process-environment entries use only fixtures that never call `env()`; + entries accepting a `StdlibConfig` receive a fixed clock and an empty + `path_override`; no Full-mode row loads a fixture calling `fetch`, `shell`, or + `which`. A malformed-YAML fixture and a budget-exhausting fixture cover + error text. Artefact: public entries in + `tests/manifest_load_options/wrapper_equivalence/` (since EP-M0 split into + `mod.rs` for the string rows and `path_rows.rs` for the workspace-opening + ones); the crate-internal query entry in + `src/manifest/tests/wrapper_equivalence.rs`. Both use `insta::Settings` with + `set_snapshot_path` to fixed directories + (`tests/snapshots/manifest_load_options/`, + `src/snapshots/manifest_load_options/`), + `set_prepend_module_to_snapshot (false)`, and filters replacing + temporary-directory paths with `[TMP]`. Evidence: `make test` green at EP-M0; + afterwards + `git diff --exit-code -- tests/snapshots/manifest_load_options src/snapshots/manifest_load_options` + passes and no snapshot is pending. Non-vacuity: in EP-M2, temporarily delete + the `register_legacy_boolean_formatter` call in + `src/stdlib/register.rs::register_with_config`, and separately route + `render_manifest_with_budget` through `RenderMode::ManifestQuery` in + `src/manifest/render/mod.rs`; each must fail a named row. Revert both. EP-M2 + evidence: the baseline is 15 of 15 rows green. Fault A failed 11 named rows + and passed only the two budget-exhaustion rows, + `path_entry_point_cannot_inject_a_clock`, and `from_path_with_limits_row`; + fault B failed the same 11 and passed the same four, which is the expected + shape because those four never render a Boolean. Both faults were reverted + and the baseline re-ran green; logs are named in `Progress`. + +- OBL-2 — mode decision table. + Statement: for each registration (`AmbientFull`, `Full`, `ManifestQuery`, + `Test`), the `env()` disposition, `glob()` disposition, render mode, + `records_expansion_and_budget_telemetry`, `classifies_query_refusals`, and + "may carry overlays" are exactly as in `Decision log`. Method: exhaustive + `rstest` table over the four variants. Rationale: finite and small; the table + is exhaustive, so bounded model checking would add nothing. Artefact: + `src/manifest/tests/loader_options.rs`. Evidence: red (compile failure) + before EP-M2; green after. Non-vacuity: every row asserts all six outputs; + swapping the `Test` and `Full` arms of + `records_expansion_and_budget_telemetry` fails two rows. EP-M2 evidence: the + table covers the three implemented variants at `ecfeb6c67` and passes; the + `Test` row arrives with EP-M3, which is why EP-M2's acceptance evidence names + OBL-2 "without `Test` rows". The sixth column is `#[cfg(test)]` until EP-M4 + consumes it (see `Decision log`). + +- OBL-3 — surface inventory completeness. + Statement: every helper name registered anywhere under `src/stdlib/` appears + in `restricted_helpers::SURFACE` with a disposition for query and test mode; + every refusing entry is registered as a stub under test; no name resolves to + MiniJinja's unknown-function, -filter, or -test error under test. Method: a + source-scanning unit test reading `src/stdlib/**/*.rs` (via + `CARGO_MANIFEST_DIR` and `cap_std`) for `add_function("…"`, `add_filter("…"`, + and `add_test("…"` literals, compared with `SURFACE` including its file-test + rows; plus an `rstest` probe per inventory row loading a one-line manifest + through `from_str_with_options` in test mode. Rationale: MiniJinja cannot + list filters or tests, so the source is the only complete witness. Artefact: + `src/stdlib/restricted_helpers/tests.rs`. Evidence: red before EP-M3; green + after. Non-vacuity: add a scratch `env.add_filter("scratch", …)` to + `src/stdlib/collections` and confirm the scan fails naming it; delete the + `digest` stub and confirm its probe fails with `UnknownFilter`. Revert both. + +- OBL-4 — test-mode refusals are named, coded, typed, and never classified. + Statement: each refusing helper under test, called with the widest argument + shape its real counterpart accepts, raises an error whose source downcasts to + `TestModeRefusal { operation }` with the helper's name, whose text begins with + `[netsuke::jinja::test_mode::unavailable]` in every catalogue tested, and + which fails the load when raised inside `when`, including via a macro called + from `when`. Method: `rstest` over the refusing inventory; an `insta` + snapshot of the en-US text for one helper using the `en_localizer` fixture and + `normalize_fluent_isolates`; one row under a non-en-US locale asserting the + code; one behavioural scenario. Artefact: + `src/stdlib/restricted_helpers/tests.rs`, `src/manifest/tests/test_mode.rs`, + `tests/features/manifest_load_options.feature`. Evidence: red before EP-M3; + green after. Non-vacuity: seeded faults — (a) build the test stub with the + query marker text and remove the query-only gate: the `when` row must fail + because the load succeeds with `conditional: true`; (b) restore the gate but + keep the query text: the downcast row must fail. + +- OBL-5 — Full/Test fidelity on pure manifests (loader-level I4). + Statement: for every manifest built only from helpers real under test, with + one fixed clock injected into both configurations, the manifest (as + `serde_json::Value`), the `BuildGraph` (compared through its existing + `GraphView` projection), and the Ninja text from `Full` equal those from + `Test`. Method: `proptest` via a manually driven `TestRunner` with + `ProptestConfig { cases: 128, .. }`, as in + `src/ninja_gen/property_tests/ninja_oracle.rs`, over a grammar valid by + construction: one to four targets, optional `vars`, one optional macro, + `foreach` over literal lists of up to three strings, `when` comparisons, + Boolean interpolation, lexical path filters, collection filters, `timedelta`, + and `now()`. The closure increments per-construct counters. Rationale: + interactions (for example Boolean formatting inside a `foreach` item) are an + open input space; the oracle is the other mode. Artefact: + `tests/manifest_load_options/fidelity_property.rs`. Evidence: red before + EP-M3; green after, with every Full load succeeding (a Full failure is a + generator bug and fails the test) and every construct counter at or above 10 + after the run. Non-vacuity: the counters prove each construct was reached; + seeded fault — omit the Boolean formatter from `register_for_test` and + confirm a shrunk counterexample containing a Boolean interpolation. + +- OBL-6 — test mode reads no host environment by default. + Statement: a test load with no reader answers `env('PATH')` as not set + although `PATH` is set in the test process; with a surface reader, `env()` + returns the reader's value. Method: named unit tests; no environment mutation + (C8). Artefact: `src/manifest/tests/test_mode.rs`. Evidence: red before + EP-M3; green after. Non-vacuity: seeded fault — default the surface to + `process_env_reader()`; the `PATH` test must fail. + +- OBL-7 — `now()` under test. + Statement: with `with_clock(fixed_clock(t))`, `now()` returns `t`; with + `with_clock(system_clock())`, `now()` is real (explicit injection, recorded + in ADR-0060); with the default clock, `now()` refuses with the test code. + Method: three named unit tests. Artefact: `src/manifest/tests/test_mode.rs`. + Evidence: red before EP-M3; green after. Non-vacuity: seeded fault — register + `now()` unconditionally; the default clock row must fail. + +- OBL-8 — overlay names are validated. + Statement: an overlay named `env`, `__netsuke_manifest_macro_imports` (or any + `__netsuke_` name), a manifest `vars` key, or a manifest macro name fails the + load with `TemplateOverlayError::NameCollision` naming it and its kind; a + stdlib name (for example `glob`) or a fresh name is accepted. Method: + `rstest` table, one row per kind. Artefact: `src/manifest/tests/overlays.rs`. + Evidence: red before EP-M4; green after. Non-vacuity: removing the check + fails every rejection row, and the `__netsuke_manifest_macro_imports` row + additionally shows macros vanishing. + +- OBL-9 — overlay order, shadowing, and passthrough. + Statement: an overlay replaces the same-named refusing stub or real helper + and is visible to `foreach`, `when`, and rendered fields; + `OverlayCall::shadowed` is the function it replaced; empty overlays equal no + overlays. Method: named tests — an overlay `glob` drives a `foreach`; an + overlay `which` filters a target in `when`; an overlay used in a command + renders; a passthrough overlay on `timedelta` returns the shadowed function's + result; an overlay on `now` sees the refusing stub as its shadowed function; + a load with `TemplateOverlays::default()` equals one without overlays. + Artefact: `src/manifest/tests/overlays.rs`. Evidence: red before EP-M4; green + after. Non-vacuity: seeded fault — register overlays before the stdlib; the + `glob` test must report the test refusal instead. + +- OBL-10 — manifest query unchanged. + Statement: every query refusal message and every `help targets` output is + byte-identical to before. Method: EP-M0 snapshots of each query refusal (one + row per helper), written with a fixed `set_snapshot_path` so EP-M1's move + does not rename them, plus the existing `tests/runner_help_targets_tests.rs` + and `tests/features/help_targets.feature`. Artefact: EP-M0 places the rows in + `src/stdlib/register/refusal_snapshots.rs` (a `#[path]` test child of + `register.rs`); EP-M1 moves them unchanged into + `src/stdlib/restricted_helpers/tests.rs`. Evidence: green at EP-M0 and under + C12 afterwards. Non-vacuity: change one character of the query marker; the + rows must fail. + +- OBL-11 — corpus fidelity-or-refusal. + Statement: for every corpus manifest, loading with the same injected reader + and fixed clock in both modes either gives equal results (canonical-equal + manifests, or identical error text) or the test load fails with a + `TestModeRefusal` whose operation is in the refusing inventory; a test load + never succeeds with a different manifest. Method: one test walking + `tests/data/` and `tests/data/manifest_load_options/` at run time with + `cap_std`, asserting per file and then asserting that the run saw at least + one equal-result file and at least one refusal from each family (network, + process, filesystem, host lookup, clock). Artefact: + `tests/manifest_load_options/corpus_fidelity.rs`. Evidence: red before EP-M3; + green after. 7.1.4 later adds `examples/`. Non-vacuity: the aggregate family + assertion; seeded fault — make `register_for_test` register the real `hash` + filter; the corpus run must fail on the file using it. + +- OBL-12 — public surface compiles for an embedder. + Statement: an external crate can build `ManifestLoadOptions::full`, + `ManifestLoadOptions::test`, a `TestSurface` with overlays, and call + `from_str_with_options`; it cannot name `StdlibRegistration`. Method: a + `trybuild` compile-pass fixture following + `tests/ui/manifest_environment_embedder_pass.rs`, registered in the existing + UI harness. Artefact: `tests/ui/manifest_load_options_embedder_pass.rs`. + Evidence: red before EP-M2 (fixture fails to compile); green after EP-M4. + Non-vacuity: the fixture exercises every public constructor and accessor. + +Telemetry wiring: `src/manifest/tests/expansion_telemetry.rs` and +`src/manifest/tests/budget/mod.rs` already observe the full and query paths; +EP-M3 adds one `DebuggingRecorder` row showing a test load emits no expansion +or budget metric. + +### Methods deliberately not used + +- Kani. The only finite decision logic (OBL-2) is four rows, which the table + enumerates exhaustively; there is no arithmetic, `unsafe`, or bounded state + machine for a model checker to strengthen. +- Verus. No repository-owned lemma over an unbounded domain is introduced. + Fidelity (OBL-5) is a property of MiniJinja rendering, a third-party + interface treated as an axiom; proving it would require modelling MiniJinja. + Differential testing against the real engine is the proportionate method. +- `loom`/`shuttle`. No concurrency is introduced. +- `cargo-mutants`. Not required; the seeded faults above are the targeted + mutation evidence. + +## Milestones and plateaus + +Each milestone ends with the full gate set run sequentially by `scrutineer`: +`make check-fmt`, `make typecheck`, `make lint`, `make doc-coverage`, +`make test`, plus `make markdownlint` and `make nixie` whenever Markdown +changed. Never commit a red gate. Red evidence is observed, recorded in +`Artefacts and notes`, and committed together with its green change. Feature +files land in the same commit as their steps (strict compile-time validation). +After each milestone, perform the conformance check listed and update +`Progress`. + +### EP-M0 — characterization baseline + +- Outcome: canonical snapshots of every wrapper and of every query refusal + exist and pass against the unchanged code. +- Requirements: RM-e, TD-I7 (oracle for OBL-1, OBL-10). +- Acceptance evidence: gates green; snapshots committed; no production file + changed. +- Conformance check: tests only; snapshot paths are fixed by + `set_snapshot_path`. +- Recovery: delete the new tests and snapshots. +- Remaining gaps: everything else. +- Compatibility decision: none. + +### EP-M1 — extractions + +- Outcome: query stubs and the surface inventory live in + `src/stdlib/restricted_helpers/mod.rs` behind a private `RestrictedSurface` + (initially `ManifestQuery` only); `classify_query_evaluation` and + `QueryEvaluation` live in + `src/manifest/jinja_macros/query_classification.rs`. The query-refusal rows + move unchanged to `src/stdlib/restricted_helpers/tests.rs`. Behaviour is + byte-identical. The directory form, not the flat `restricted_helpers.rs` the + earlier draft named, is required — but by the **layout rule**, not by the + contract function the earlier draft cited. + `docs/repository-layout.md:409-419` states that a shared name prefix + represents a module hierarchy, that its children go under a directory module + using `mod.rs`, and that `#[path]` is kept "only when a specific requirement + still needs it". An earlier draft attributed the prohibition to + `prefixed_files_beside_directory()`, which was false: that function's glob is + `f"{directory.name}_*.rs"` and so catches only an underscore-prefixed + *sibling* (`restricted_helpers_tests.rs`), never a bare + `restricted_helpers.rs` beside `restricted_helpers/`. Running both contract + functions against synthetic trees confirms the flat-plus-`#[path]` shape + passes them (the live `register.rs` + `register/` pair is the same shape and + passes today); `sibling_prefix_groups` is the function that would reject a + true sibling, and even it accepts a `#[path]` child. The directory form is + still the right choice; cite the rule, not the function. +- Requirements: RM-c (mechanism made reusable); C10 headroom. +- `SURFACE` content, enumerated read-only at `71f26e1f` so the move can be + checked against it: + - **Refused under query — 23 entries.** From + `src/stdlib/register/query_helpers.rs`: the six always-disabled helpers + (`env`, `glob`, `fetch`, `shell`, `grep`, `contents`); the seven + `FILE_TESTS` members (`dir`, `file`, `symlink`, `pipe`, `block_device`, + `char_device`, `device`); and the ten host-dependent helpers (`which` — as + both a filter and a function, two registrations — `command_available`, + `now`, `realpath`, `expanduser`, `size`, `linecount`, `hash`, `digest`). + Re-verified 2026-10-15 against the EP-M0 oracle, + `src/stdlib/register/refusal_snapshots.rs`, whose 23 rows are numbered + `=== 00 env … ===` through `=== 22 device … ===` and are the cheapest place + to re-count. + - **Permitted under query — 11 entries**, grouped by the registration + function that adds them inside `register_manifest_query` + (`src/stdlib/register.rs:165`). The order below is the order each function + registers in; the four functions themselves run in the order listed: + - `path::register_query_filters` → `register_lexical_filters`: + `basename`, `dirname`, `with_suffix`, `relative_to`. + - `collections::register_filters`: `uniq`, `flatten`, `compact`, + `group_by`. + - `recipe_text::register_filters`, quoting for the host-default dialect: + `shell_quote`, `shell_join`. + - `time::register_query_functions`: `timedelta`. + The count is the *names* the enumeration lists, re-verified against source + on 2026-10-15. An earlier draft said 15, which its own name list + contradicted; the name list was right and the number wrong. That draft also + said "in registration order" over an alphabetized list, which the grouping + above replaces with an order that is actually the source's. + - **Trap:** `realpath` and `size` are *not* dual-surface. They appear only in + `register_filters` (the build surface); reading them as query-permitted + comes from letting a `grep -A30` run past the end of + `register_query_filters` into `register_filters`. The query side is + lexical-only by construction, because `register_query_filters` calls + `register_lexical_filters` alone. +- Acceptance evidence: gates green; C12 diff clean; both source files well + under 400 lines. +- Conformance check: refactor only; no public change. +- Recovery: revert the refactor commits (one per extraction). +- Remaining gaps: the test surface. +- Compatibility decision: none (private code). +- **The extraction invalidates a stated rationale, so carry a replacement.** + `classify_query_evaluation`'s doc comment + (`src/manifest/jinja_macros/mod.rs:124-126`) argues for keeping the adapter + *where it is*: "Keeping the adapter here confines that implementation detail + to the Jinja boundary; manifest expansion consumes only [`QueryEvaluation`]." + Moving it to `query_classification.rs` contradicts that sentence, so the + relocated code needs a rationale for the new home rather than a copy of the + old one. The parent is at **394 lines** against AGENTS.md's 400-line cap — + six lines of slack — so this extraction is load-bearing for C10 and the + parent cannot absorb the code instead. `QueryEvaluation`'s own doc block + moves with it and carries the same issue. +- **Wiring the `is_manifest_query_disabled_error` re-export.** Three hops must + survive the move: `src/stdlib/mod.rs:33` re-exports it from `register`, + `register.rs:29` re-exports it from `query_helpers`, and + `src/manifest/jinja_macros/mod.rs:129` calls it through `crate::stdlib`. The + move only forces re-pointing `register.rs:29`; the other two are by-name and + keep working. The EP-M0 refusal rows in `register/refusal_snapshots.rs:44` + import `super::{is_manifest_query_disabled_error, register_manifest_query}` + and move to `restricted_helpers/tests.rs` in the same commit, so their + `super::` path changes too. +- **`FILE_TESTS` visibility.** It is a private `const` in `register.rs` today + and is imported by `query_helpers.rs:16` for the stubs and consumed at + `register.rs` by the real registrations. The plan's decision stands: either + widen its visibility or subsume it in `SURFACE`, which is preferable because + `SURFACE` is the single list that stops a test reaching one surface and + staying unregistered on the other. +- **Kani scope must move in the same commit.** `tools/kani/proof-scope.toml` + lists `src/stdlib/register/query_helpers.rs` at **:162** and + `src/stdlib/register.rs` at `:161`. The scope is a derived closure of the + `#[kani::proof]` harnesses and + `tests/workflow_contracts/kani_proof_scope_test.py` rejects both a missing + file and an entry covering nothing a harness reaches, so the moved file's + entry must be re-derived. Run `make test-workflow-contracts` to validate the + updated list against that closure; `make test-kani-scope-wrapper` is an + unrelated systemd-run wrapper test and does not derive proof-source paths. + Read-only recon taken 2026-10-15 while a gate batch held the candidate + frozen; no `restricted_helpers` symbol existed in the tree at that point, so + EP-M1 is genuinely unstarted. +- **Placement of the new module**, from `docs/repository-layout.md:409-439`: a + shared name prefix means a directory module with `mod.rs` and plain + `mod child;` declarations (dropping the parent prefix from filenames). + `restricted_helpers/` therefore needs a `mod.rs`, and `config/` is the + canonical shape to copy: `mod.rs` plus `tests.rs` plus plain children, no + `#[path]`. + + The test file is `restricted_helpers/tests.rs`, **not** + `restricted_helpers/tests/mod.rs`. Both shapes are live and neither is + contract-enforced, so the choice needs a reason. Measured across `src/`: 38 + files are named `tests.rs`, 18 directories are named `tests/` of which 17 + hold a `mod.rs`, and 8 of those 17 hold a single child. File count therefore + does not decide it — a one-child `tests/mod.rs` is common. Three things do + decide it. First, the moved file already names its destination: + `refusal_snapshots.rs:25-26` records that "EP-M1 can move this file into + `src/stdlib/restricted_helpers/tests.rs`". Second, `tests.rs` is the majority + shape, and 18 directories under `src/` consist of nothing but `mod.rs` and + `tests.rs` — so the shape has ample precedent, `src/stdlib/config/` among it. + Third, `refusal_snapshots.rs` is reached by `#[path]` today + (`register.rs:39`), so a plain `mod tests;` retires that declaration, the + direction `docs/repository-layout.md:418-420` asks for. The snapshot is + indifferent either way: `stable_snapshot_settings("manifest_load_options")` + pins `src/snapshots/manifest_load_options/` and + `set_prepend_module_to_snapshot(false)` drops the module path from the name, + so `refusals.snap` keeps its directory and its name under any placement. A + second test file under the module is the change that would earn a + `tests/mod.rs`. + + A justified `#[path]` additionally needs a comment stating the reason and an + entry in the support-module ownership inventory; note that today's + `register/query_helpers.rs` has **no** inventory entry, which is a + pre-existing gap rather than one this milestone must close. + +- **Fate of `src/stdlib/register/`**, which the placement bullet above settles: + the directory holds exactly two files, `query_helpers.rs` and + `refusal_snapshots.rs`, and both move under EP-M1. The `#[path]` declarations + at `register.rs:27` and `:39` are therefore deleted, the emptied directory is + removed, and `register.rs` stays a flat file with no children — **no `mod.rs` + conversion is forced on it**. Two items move with the code: + `MANIFEST_QUERY_DISABLED_HELPER_MARKER` (`register.rs:52`) belongs to the + query-disabled vocabulary and follows it into `restricted_helpers/mod.rs`, and + `FILE_TESTS` (`register.rs:228`/`:240`) must stay reachable by both the real + registrations (`register.rs:253`) and the moved stubs, which is the plan's + stated case for subsuming it in `SURFACE` rather than widening its visibility. + +### EP-M2 — options entry point and thin wrappers + +- Outcome: `ManifestLoadOptions`, `from_str_with_options`, the + `AmbientFull`/`Full`/`ManifestQuery` registration with its query methods, + `env`/`glob` registration by the loader, the query-only classifier gate, + crate-private `from_path_with_options`, and every existing entry point as a + wrapper. `evaluate_manifest` is split into `build_template_environment` and + an expansion-and-render helper. +- Requirements: RM-a (options, wrappers), TD-4.3, C3, C5. +- Acceptance evidence: OBL-1 (C12 diff clean), OBL-2 (without `Test` rows), + the `from_str_with_options` doctest, and OBL-10 green. +- Conformance check: only listed public items added; no existing signature + changed; `ManifestLoadMode` deleted. +- Recovery: revert the milestone's commits; EP-M0 and EP-M1 stand alone. +- Remaining gaps: `Test`, overlays. +- Compatibility decision: the wrappers are the roadmap's stated requirement + (about 70 callers across tests, benches, and the runner), not a shim. + +### EP-M3 — `StdlibRegistration::Test` + +- Outcome: `TestSurface` (without overlays), `ManifestLoadOptions::test`, + `stdlib::register_for_test`, `TestModeRefusal`, the Fluent key in all 35 + catalogues, the test default reader, and the first two behavioural scenarios + with their steps. +- Requirements: RM-b, RM-c, TD-4.2, TD-5.2, TD-5.5 (as amended), UX-1; + advances TD-I5. +- Acceptance evidence: OBL-2 (all rows), OBL-3, OBL-4, OBL-5, OBL-6, OBL-7, + OBL-11 green; telemetry row green; locale audit green. +- Conformance check: refusing set equals the inventory; C12 diff clean. +- Recovery: revert the milestone's commits; EP-M2 stands alone. +- Remaining gaps: overlays. +- Compatibility decision: none. + +### EP-M4 — template overlays + +- Status: implemented and locally verified on 2026-11-01; see `Progress`. +- Outcome: `TemplateOverlays`, `OverlayCallable`, `OverlayCall`, + `TemplateOverlayError`, the collision key in all catalogues, registration + after macros and before expansion, the third behavioural scenario, and the + embedder fixture. +- Requirements: RM-a (overlays), RM-d, TD-4.3 step 5, TD-7, C9. +- Acceptance evidence: OBL-8, OBL-9, OBL-12 green; C12 diff clean. +- Conformance check: order matches TD-4.3 steps 1–6; no `cfg` gate. +- Recovery: revert the milestone's commits. +- Remaining gaps: macro substitution (7.1.3). +- Compatibility decision: none. + +### EP-M5 — documentation, ADR, and roadmap closure + +- Outcome: design §4.2, §4.3, §5.5, and §7 reconciled; ADR-0070 written and + indexed in `docs/contents.md` (the plan's `0060` serial was stale; see + `Decision log`); ADR-0050 Addendum E; developers' guide, + `docs/netsuke-design.md`, and `docs/polonius.md` updated; roadmap 7.1.2 and + its five bullets ticked; this plan set to `COMPLETE`. +- Requirements: design §15; AGENTS.md documentation rules. +- Acceptance evidence: Markdown gates and `make check-fmt` green. +- Conformance check: every deviation in `Decision log` appears in the design or + an ADR. +- Recovery: revert documentation commits. +- Remaining gaps: none for 7.1.2; the sandbox adapters belong to 7.4.1. +- Compatibility decision: none. + +## Plan of work + +### Stage A — orientation (no code changes) + +Read the files in `Context and orientation`. Run the full gate set on the +untouched branch through `scrutineer` and record the baseline. Re-confirm +ADR-0060 is free immediately before minting it, by sweeping `docs/adrs/` across +every remote branch: the number is only valid at the sweep, and `main` can take +it while this branch sits open. + +### Stage B — characterization (EP-M0) — complete + +Add the new fixtures under `tests/data/manifest_load_options/` (the four +planned plus three recorded as a deviation; see `Decision log`). Create +`tests/manifest_load_options_tests.rs` declaring `mod manifest_load_options;` +and `tests/manifest_load_options/mod.rs` declaring `wrapper_equivalence` (later +also `fidelity_property` and `corpus_fidelity`), each file under 400 lines. In +`wrapper_equivalence.rs`, write one `rstest` per public entry point over its +applicable fixtures, snapshotting canonical JSON or the error chain with the +insta settings from OBL-1. (That file outgrew the 400-line cap once the rows +became observable, so it was split during EP-M0 into +`wrapper_equivalence/mod.rs` plus `wrapper_equivalence/path_rows.rs`.) Path +entries copy fixtures into a `tempfile` directory. Add the crate-internal query +rows in `src/manifest/tests/wrapper_equivalence.rs` and the query-refusal rows +in `src/stdlib/register/refusal_snapshots.rs`. Review every new snapshot by +reading it, then accept it; this is the only acceptance the characterization +snapshots ever receive. The refusal rows record one document rather than one +snapshot per helper, so a reviewer reading for "does every row name the helper +it should?" can read them comparatively — twenty-two files differing only in +their leading word would hide the one thing worth seeing. The two internal +modules reached by `#[path]` are `register/query_helpers.rs` and +`register/refusal_snapshots.rs`; the reason is that their parent sits at the +400-line cap and they are split out to hold it. An earlier draft added that +neither `register/` nor `manifest/tests/` holds a `mod.rs`, which is false for +the second: `src/manifest/tests/mod.rs` exists and `src/manifest/mod.rs:249` +declares it with a plain `mod tests;`. Only `register/` is a `#[path]` case; +the fact that matters for EP-M1 is that `register/` stops being one, because +both its files leave and `register.rs` reverts to a flat file with no children. + +### Stage C — implementation (EP-M1 to EP-M4) + +EP-M1. Move the query stubs, `manifest_query_operation_error`, +`is_manifest_query_disabled_error`, and the marker from +`src/stdlib/register.rs` to `src/stdlib/restricted_helpers/mod.rs`. Introduce +`const SURFACE: &[HelperEntry]` (name, kind — function, filter, or test — and +dispositions for query and test) and a private +`enum RestrictedSurface { ManifestQuery }` whose stub registration iterates +`SURFACE`, producing today's exact text. Keep +`is_manifest_query_disabled_error` reachable through the existing +`src/stdlib/mod.rs` re-export. In a separate commit, move +`classify_query_evaluation` and `QueryEvaluation` to +`src/manifest/jinja_macros/query_classification.rs`. + +EP-M2. Red: add `src/manifest/tests/loader_options.rs` (OBL-2 rows for the +three existing modes), the `from_str_with_options` doctest, and the OBL-12 +fixture; observe compile failures. Green: create +`src/manifest/loader_options.rs` with `ManifestLoadOptions`, its constructors +and builders, `from_str_with_options`, and +`register_host_helpers(env, registration, environment, manifest_root)`, which +registers `env()` and `glob()` per C5. Rename the private +`StdlibRegistration::None`-equivalent to `AmbientFull` and add the query +methods. Remove `env`/`glob` from `RestrictedSurface::ManifestQuery`. Change +`ManifestParse` to hold `name`, `manifest_root`, and a `ManifestLoadOptions`. +Split `evaluate_manifest` into `build_template_environment` (steps 2–6) and +`expand_and_render` (steps 7–8). Thread `classifies_query_refusals` into the +expansion context and gate the classifier. Rewrite every `from_str*` entry and +`from_str_with_limits` as wrappers. Replace `ManifestLoadMode` and +`from_path_with_registration` with `from_path_with_options`. Replace both +`matches!(…, ManifestQuery)` sites. Run the OBL-1 seeded faults, record their +failures, and revert them. + +EP-M3. Red: add OBL-3, OBL-4, OBL-6, OBL-7 tests, the OBL-5 property, the +OBL-11 corpus test, the telemetry row, and scenarios 1–2 with their steps; +observe failures. Green: add `TestSurface` (config and optional reader) and +`StdlibRegistration::Test(Box)`; add `TestModeRefusal` and +`RestrictedSurface::Test`, whose stubs take `Rest` so any call shape +reaches the refusal; add `pub(crate) fn register_for_test(env, config)` in +`src/stdlib/restricted_helpers/mod.rs` installing the Boolean formatter, the +pure helpers, `now()` when `!config.clock().is_system()`, and the test stubs +from `SURFACE`. Update `WallClock::is_system`'s documentation. Add +`STDLIB_TEST_MODE_HELPER_UNAVAILABLE` to `src/localization/keys.rs` and the +message to all 35 catalogues in the same commit, following the translators' +guide (keep `netsuke test`, helper names, and the bracketed code untranslated; +begin `ar`, `fa`, and `he` messages with U+200F). + +EP-M4. Red: add `src/manifest/tests/overlays.rs` (OBL-8, OBL-9), scenario 3 +with its steps, and extend the OBL-12 fixture; observe failures. Green: create +`src/manifest/test_surface.rs` holding `TestSurface` (moved from EP-M3's +location if needed), `TemplateOverlays`, `OverlayCallable`, `OverlayCall`, +`TemplateOverlayError`, `overlay_namespace(doc)` (vars keys and macro names, +computed before step 3 and reused by the existing reserved-name check), and +`register_overlays`. Call validation and registration in +`build_template_environment` after macro registration. Add the collision key to +`keys.rs` and every catalogue in one commit. + +### Stage D — documentation and closure (EP-M5) + +Update `docs/netsuke-test-framework-technical-design.md`: §4.2 (current +anchors), §4.3 (options shape, `TestSurface`, overlays in the test surface, +`AmbientFull`, string entry plus crate-private path entry, deferred +`macro_substitutions`, overlay name rules), §5.5 (the full refusing inventory +and the `which`/`expanduser` deviation; the six file-reading filters are +already recorded there), and §7 (shadowed-function capture is possible). Write +`docs/adrs/adr-0070-deny-by-default-test-mode-stdlib.md` (surface, refusal code +and typed source, `is_system` as injection; serial `0070` rather than the +`0060` first written here, per the Decision log) and index it in +`docs/contents.md`. Add Addendum E to +`docs/adrs/adr-0050-bound-manifest-template-evaluation.md`. Add a "Manifest +loader entry points" subsection to `docs/developers-guide.md` (new callers use +`from_str_with_options`; do not add another `from_path_with_*` rung; how +overlays and the surface inventory work, and that a new stdlib helper needs a +`SURFACE` row). Record `ManifestLoadOptions<'a>` in `docs/polonius.md` (borrowed +`ManifestEnvironment` and stage observer; no clones). Add one sentence to the +manifest-loading section of `docs/netsuke-design.md` naming the load modes. +Tick roadmap 7.1.2 and its bullets. Run `make fmt`, then the Markdown gates. + +## Concrete steps + +Run everything from the repository root, +`/home/leynos/.lody/repos/github---leynos---netsuke/worktrees/0965d3fb-38f0-46dc-a907-0cd999d8a910`. +Gates are run by `scrutineer`, one at a time, each teeing to +`/tmp/$ACTION-netsuke-7-1-2-options-carrying-manifest-loader-entry-point.out`. + +Focused loops use the gate `RUSTFLAGS`, so they share the build cache with the +gates rather than invalidating it: + +```sh +export RUSTFLAGS="-D warnings -Zthreads=8 -Clink-arg=-fuse-ld=mold" +cargo nextest run --workspace --all-features --test manifest_load_options_tests \ + 2>&1 | tee /tmp/focus-netsuke-7-1-2.out +cargo nextest run --workspace --all-features --lib -E 'test(/manifest::tests::(loader_options|test_mode|overlays)/)' \ + 2>&1 | tee -a /tmp/focus-netsuke-7-1-2.out +``` + +Expected red transcripts (abridged): + +```plaintext +EP-M2: error[E0432]: unresolved import `netsuke::manifest::ManifestLoadOptions` +EP-M3: error[E0599]: no function or associated item named `test` found for struct `ManifestLoadOptions` +EP-M4: error[E0432]: unresolved import `netsuke::manifest::TemplateOverlays` +``` + +Expected green transcript after EP-M4 (abridged): + +```plaintext + Summary [ …s] N tests run: N passed, 0 skipped +``` + +## Validation and acceptance + +Behavioural specification, `tests/features/manifest_load_options.feature`, +reusing the existing steps "the manifest has targets named …", "parsing the +manifest fails", and "the error message contains …" +(`tests/bdd/steps/manifest/`), with new steps in +`tests/bdd/steps/manifest_load_options.rs` and new `TestWorld` slots for the +build result, test result, and overlays: + +```gherkin +Feature: Options-carrying manifest loading + + Scenario: A pure manifest loads identically in build and test modes + Given the manifest file "tests/data/manifest_load_options/boolean_interpolation.yml" + And a fixed clock at "2026-06-08T12:00:00Z" + When the manifest is loaded in build mode and in test mode + Then both loads produce the same Ninja file + + Scenario: Test mode refuses a process helper + Given the manifest file "tests/data/manifest_load_options/uses_shell.yml" + When the manifest is loaded in test mode + Then parsing the manifest fails + And the error message contains "[netsuke::jinja::test_mode::unavailable]" + And the error message contains "shell" + + Scenario: A template overlay stands in for glob under test + Given the manifest file "tests/data/glob.yml" + And a template overlay "glob" returning "glob_files/a.txt" and "glob_files/b.txt" + When the manifest is loaded in test mode + Then the manifest has targets named "a.out, b.out" +``` + +Scenarios 1 and 2 land in EP-M3 and scenario 3 in EP-M4, each with its steps. +They are acceptance tests: strict compile-time validation prevents committing a +feature before its steps, so their red stage is the missing-step compile error +observed before the steps are written. `tests/data/glob.yml` names each target +by rewriting `glob_files/.txt` to `.out`, so the overlay values +above yield `a.out` and `b.out`. + +Quality criteria: + +- Tests: `make test` green; every new test passes; no pre-existing snapshot or + scenario changed; C12 diff clean. +- Verification: OBL-1 to OBL-12 discharged with their non-vacuity evidence + recorded in `Artefacts and notes`. +- Lint and types: `make check-fmt`, `make typecheck`, `make lint` green. +- Documentation: `make markdownlint`, `make nixie` green; `make doc-coverage` at + or above 80 per cent. + +## Idempotence and recovery + +Every step is re-runnable, except that characterization snapshots are accepted +once (C12). Each milestone is a set of commits that can be reverted +independently, newest first. If a gate fails midway, fix forward within the +three-attempt tolerance or return to the last green commit with `git restore`. +Never use bare `git stash`; the stash is shared across worktrees. + +## Artefacts and notes + +Record here, as work proceeds: the baseline gate results; each red transcript; +each seeded-fault failure (OBL-1, OBL-2, OBL-3, OBL-4, OBL-5, OBL-6, OBL-7, +OBL-8, OBL-9, OBL-10, OBL-11); and the OBL-5 construct counters. + +### Stage A baseline (2026-10-14) + +Candidate frozen at `0b2a366b2bd4749fe8688112b8070d3071219b59`; +`git status --short` empty before the first gate and after every gate. Logs at +`/tmp/$ACTION-netsuke-7-1-2-options-carrying-manifest-loader-entry-point.out`. +Gates ran sequentially in the plan's order, under `scrutineer`'s exclusive +ownership. + +| Gate | Result | Evidence | +| ------------------- | ------ | ---------------------------------------------------- | +| `make check-fmt` | PASS | ruff 250 files formatted; mdtablefix 183 unchanged | +| `make typecheck` | PASS | 0 errors, 1144 s | +| `make lint` | PASS | clippy, whitaker, ruff, pylint, yamllint, actionlint | +| `make doc-coverage` | PASS | aggregate 4988/5045, 98.87% ≥ 80% | +| `make test` | RED | one nextest timeout at 300.012 s; doctest never ran | +| `make markdownlint` | PASS | spelling clean; 0 issues in 183 files | +| `make nixie` | PASS | all diagrams validated | + +The red gate, in its own words: + +```plaintext + TIMEOUT [ 300.012s] (3913/3928) netsuke-build::command_env_ui_tests cli_configuration_fixture_compiles + Summary [ 481.703s] 3913/3928 tests run: 3912 passed (1 slow), 1 timed out, 6 skipped +error: test run failed +make: *** [Makefile:230: test-nextest] Error 100 +``` + +This is load attribution, and the awaiting call is **inside** the test body: +`tests/command_env_ui_tests.rs:86-94` spawns `cargo check` and awaits it with +`.output()?`, so nextest's per-test clock covers the admission-queue wait. A +wider allowance would cover the delay; what it cannot do is remove the +contention. `Tolerances`' `Iterations` trigger does not apply, because there is +no root cause in this candidate to fix, and `.config/nextest.toml` already +documents this exact signature for this exact test and deliberately keeps the +budget at five periods rather than "moving to an allowance the delay can still +exceed". Four independent checks agree the branch cannot be the cause: the diff +against `origin/main` is three Markdown files under `docs/`; a diff of the +failing test, its nextest policy, `src/`, and `tests/` between `origin/main` +and this head is empty; the gate log holds 60 compile-slot waits, 573 +sccache-unreachable notices and zero admission failures; and +`build-limits-status` read `capacity 4, held 4, healthy, sccache unreachable`, +with a foreign `build-limits-rustc` census of 61 to 80 processes across two +probes around the gate (the pool is shared, so the figure moves second to +second). Inside the same run, the sibling nested-Cargo fixture +`build_module_slice_ui_tests::production_build_module_slice_has_expected_boundary` +passed at 168.114 s, 132 s under the same cap, which measures the contention +rather than arguing it from history. + +**Disposition: OPEN, re-run rather than repaired.** A red gate is not committed +past; this one is re-run once, at a later milestone, when the host is quieter. +If it then passes, this record stands as the explanation of the first red. The +doctest half of `make test` is unverified by this baseline, not failed. + +### EP-M0 gate runs + +Candidate at `1d75efce`, `git status --short` empty before the first gate and +after every gate. Same log template and same sequential discipline as the Stage +A baseline. + +| Gate | Result | Evidence | +| ------------------------------ | ------ | ----------------------------------------------------- | +| `make check-fmt` | PASS | no reformat required | +| `make doc-coverage` | PASS | aggregate 4988/5045, 98.87% ≥ 80% (880 s) | +| `make test-workflow-contracts` | PASS | 1271 passed, 3 skipped, then 27 passed (131 s) | +| `make nixie` | PASS | "All diagrams validated successfully", Figure 2 incl. | +| `make markdownlint` | PASS | 0 issues in 183 files; spelling clean (22 s, 10 s) | +| `make lint` | RED | two `conditional_max_n_branches` at `lint-whitaker` | +| `make test` | RED | one nextest timeout at 300.008 s; doctest never ran | + +Two reds, two different causes, and neither is a defect the earlier records did +not predict: + +- **`make lint`.** Whitaker rejected the two multi-branch conditions in + `tests/manifest_load_options/fixtures/fixture.rs` at `:156` and `:161`. Both + were introduced by `fbf221f0`, which is also the commit whose clippy repair + produced the surrounding structs; the checks that would have caught it before + it was committed do not exist, because Clippy has no analogue of this rule. + Repaired in `9191699b`, then the `lint-whitaker` stage was re-run **in + isolation** against the repaired tree and exited 0 with zero diagnostics, + having compiled the previously failing `manifest_load_options_tests` target + through the dylint driver. Because `make lint` aborts at its first failing + stage, `yamllint`, `actionlint`, and every Python lint stage are **UNRUN** for + `1d75efce` and must be re-run against the repaired head. The eight clippy + findings that `fbf221f0` repaired and the two Whitaker findings here are the + same failure mode twice in one milestone: a stage that aborts conceals every + check behind it. + +- **`make test`.** `command_env_ui_tests::cli_configuration_fixture_compiles` + timed out at the repo's own 300-second cap: + + ```plaintext + TIMEOUT [ 300.008s] (3928/3943) netsuke-build::command_env_ui_tests cli_configuration_fixture_compiles + Summary [ 613.491s] 3928/3943 tests run: 3927 passed (1 slow), 1 timed out, 6 skipped + ``` + + This is the same row, the same signature, and the same load attribution as + the Stage A baseline, and every `src/` file this branch edits is test-only: + `src/stdlib/register.rs` gains a `#[cfg(test)]` child declaration, + `src/manifest/tests/mod.rs` gains a `mod` line inside a tests module, and + `src/snapshot_test_support.rs` is gated behind `#[cfg(test)]` at + `src/lib.rs:30`. No production item changed. The log again holds 107 + compile-slot waits and 574 sccache-unreachable notices and zero admission + failures. The SLOW row differs from the baseline's, which is what one expects + from queue order and not from a candidate. + +**Disposition: `lint` repair committed and its stage re-verified in isolation; +the full gate set must be re-run at `9191699b` before the milestone is called +green. The `make test` timeout is re-run, not repaired.** + +### EP-M0 gate re-run at `ca7ed7d4` + +The repaired head, with the plan records above included in the candidate. Same +log template, same sequential discipline, same frozen-tree rule. + +| Gate | Result | Evidence | +| ------------------------------ | ------ | ------------------------------------------------------- | +| `make check-fmt` | PASS | 3 s; 250 files formatted, 183 unchanged | +| `make typecheck` | PASS | 169 s; 0 errors | +| `make lint` | PASS | 814 s; **every stage reached** (see below) | +| `make doc-coverage` | PASS | 397 s; aggregate 4988/5045, 98.87% ≥ 80% | +| `make test` | RED | 1723 s; one nextest timeout at 300.011 s; doctest UNRUN | +| `make markdownlint` | PASS | 30 s; 0 issues in 183 files | +| `make nixie` | PASS | 1 s; "All diagrams validated successfully!" | +| `make spelling` | PASS | 16 s; `typos.toml` current, no rewrite | +| `make test-workflow-contracts` | PASS | 156 s; 1271 passed, 3 skipped; then 27 passed | + +**The lint blind spot is closed.** All four stages were reached and passed: +`lint-clippy` (`cargo doc`, `cargo clippy`), `lint-whitaker` for both +`netsuke-build` and `test_support` with **zero diagnostics**, then +`lint-workflow-scripts`, and finally `lint-python` through its full cascade — +Ruff, Pylint 10.00/10, the df12 house lints 10.00/10, `ambrleaks` clean, and +`interrogate` at 100%. `yamllint` and `actionlint` both ran and passed. Every +stage that the `1d75efce` abort concealed is now exercised. The stage that ran +1977 s under queue contention took 814 s here with the same content. + +**The `make test` red is load attribution, and this run supplies the proof the +earlier ones could only argue.** The timeout has moved to a **third** victim — +`build_module_slice_ui_tests::production_build_module_slice_has_expected_boundary` +at `TIMEOUT [300.011s]`, having grown through `SLOW` at 60/120/180/240 s. All +three victims across the three runs sit in the `nested-cargo-builds` group +(`.config/nextest.toml:99-101`), which spawns child Cargo builds and is +serialized, so its members queue behind one another; a candidate defect cannot +change which member of that group reaches a fixed wall-clock bound. +`command_env_ui_tests::cli_configuration_fixture_compiles` — twice the victim — +does not appear in this run's log, but **it cannot be claimed to have passed**: +`test-nextest` fails fast, and 16 of 3943 rows were never scheduled. Checked +against the runner's own accounting, every passing row prints a `PASS` line +(3926 rows for 3926 reported passes), so an absent row is an unscheduled row. +The census agrees: 256 compile-slot waits with a queue depth reaching **240 +ahead**, 1108 sccache-unreachable notices, and zero `build-limits:` admission +failures. The failing row is unchanged from `origin/main`. + +**Disposition of this run.** `doctest` remains **UNRUN** (not failed) and 16 +nextest rows were never scheduled, because `test-nextest` fails fast at +`Makefile:230`. `make test` is to be re-run once on a quieter host, which +executes `doctest` automatically as its second half. The timeout is not +repaired: `.config/nextest.toml:57-70` documents this signature for this group +and deliberately keeps the budget at five periods rather than "moving to an +allowance the delay can still exceed". EP-M0 is green on every gate whose +verdict the candidate can decide, with `make test`'s doctest half the one +outstanding item. + +## Interfaces and dependencies + +No new dependencies. At the end of EP-M4 these public items exist, re-exported +from `netsuke::manifest`: + +```rust +/// Inputs to one manifest load beyond the YAML text and its name. +pub struct ManifestLoadOptions<'a> { /* private fields */ } + +impl<'a> ManifestLoadOptions<'a> { + /// Load with the complete build stdlib and explicit environment inputs. + pub fn full(config: StdlibConfig, environment: ManifestEnvironment<'a>) -> Self; + /// Load with the deny-by-default test surface. + pub fn test(surface: TestSurface) -> Self; + /// Supply resource ceilings. + #[must_use] + pub fn with_budget_limits(self, limits: ManifestBudgetLimits) -> Self; + /// Observe loader stages. + #[must_use] + pub fn with_stage_observer(self, on_stage: &'a mut dyn FnMut(ManifestLoadStage)) -> Self; +} + +/// Load a manifest string with explicit options. +pub fn from_str_with_options( + yaml: &str, + name: &ManifestName, + options: ManifestLoadOptions<'_>, +) -> anyhow::Result; + +/// The capabilities granted to a manifest under `netsuke test`. +pub struct TestSurface { /* config, env_reader: Option, overlays */ } + +impl TestSurface { + /// Start from a stdlib configuration; `now()` is real only if its clock was injected. + pub fn new(config: StdlibConfig) -> Self; + /// Answer `env()` from this reader instead of refusing every lookup. + #[must_use] + pub fn with_env_reader(self, reader: EnvReader) -> Self; + /// Install template overlays. + #[must_use] + pub fn with_overlays(self, overlays: TemplateOverlays) -> Self; +} + +/// Test-supplied template functions, registered after stdlib and manifest +/// macros and before `foreach` expansion. +#[derive(Clone, Default)] +pub struct TemplateOverlays { /* functions: IndexMap */ } + +impl TemplateOverlays { + /// Add or replace a function overlay. + #[must_use] + pub fn with_function(self, name: impl Into, callable: OverlayCallable) -> Self; + /// Report whether no overlay is present. + pub fn is_empty(&self) -> bool; +} + +/// A callable standing in for a template function. +#[derive(Clone)] +pub struct OverlayCallable(/* Arc) -> Result + Send + Sync> */); + +impl OverlayCallable { + /// Wrap a dispatch closure. + pub fn new(dispatch: F) -> Self + where + F: Fn(&OverlayCall<'_, '_>) -> Result + + Send + + Sync + + 'static; +} + +/// One invocation of an overlay; private fields keep it extensible. +pub struct OverlayCall<'call, 'env> { /* state, args, shadowed */ } + +impl<'call, 'env> OverlayCall<'call, 'env> { + /// The template state at the call site. + pub fn state(&self) -> &'call minijinja::State<'call, 'env>; + /// Positional arguments, with keyword arguments as a trailing `Kwargs` value. + pub fn args(&self) -> &'call [minijinja::Value]; + /// The function this overlay replaced, if any. + pub fn shadowed(&self) -> Option<&'call minijinja::Value>; +} + +/// Why a set of overlays was rejected. +#[derive(Debug, thiserror::Error)] +pub enum TemplateOverlayError { + /// An overlay name is reserved or already used by the manifest. + #[error("…localized, begins with [netsuke::manifest::overlay::collision]…")] + NameCollision { name: String, kind: OverlayNameKind }, +} + +/// What an overlay name collided with. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OverlayNameKind { EnvironmentSeam, Internal, ManifestVariable, ManifestMacro } +``` + +One further public item is re-exported from `netsuke::stdlib` rather than +`netsuke::manifest`: `TestModeRefusal`, the error source a test load attaches +to every refusal, with `operation()` naming the refused helper. OBL-11's +external integration test downcasts to it, so it is public by requirement; see +`Surprises & discoveries`. + +Crate-internal additions: the `StdlibRegistration` variants `AmbientFull` and +`Test(Box)` and its methods +`records_expansion_and_budget_telemetry` and `classifies_query_refusals`; +`ManifestLoadOptions::{ambient, manifest_query}`; +`query::from_path_with_options`; `loader_options::register_host_helpers`; +`restricted_helpers::{SURFACE, RestrictedSurface, register_for_test}`; +`jinja_macros::query_classification`; and the Fluent keys +`stdlib.test_mode.helper_unavailable` and `manifest.overlay.name_collision`. + +Each overlay is installed in `build_template_environment` as follows, where +`shadowed` was read from `Environment::globals()` before registration: + +```rust +env.add_function(name, move |state: &State, args: Rest| { + callable.call(&OverlayCall::new(state, &args, shadowed.as_ref())) +}); +``` + +## Revision note + +2026-09-27, after the expert-panel review. What changed: overlays moved from +the options into a new `TestSurface` payload of the `Test` mode; +`StdlibRegistration` stays crate-private with a named `AmbientFull` variant and +no public `Default`; `env()`/`glob()` registration became a single loader +decision; refusals gained a verbatim bracketed code and a typed source, and the +query classifier is gated to query mode; overlays receive the shadowed function +through an extensible `OverlayCall`; `env` and `__netsuke_*` joined the +reserved overlay names; the crate-private path entry and closure factory +replaced `ManifestLoadMode`; snapshots became canonical with fixed paths and a +no-re-accept rule; OBL-1 was split by visibility, OBL-3 became a source scan, +OBL-5 uses counters and requires every Full load to succeed, OBL-11 walks the +corpus at run time, and a compile-pass fixture pins the public surface; the +behavioural feature shrank to three scenarios landing with their steps; the +corpus reuses `tests/data`; milestones fell from seven to six; tolerances were +recomputed from Table 1; the telemetry predicate was narrowed and moved to an +ADR-0050 addendum (then numbered 018). Why: all three panel reviews returned +"revise" with evidence-backed findings (factual corrections, non-vacuity gaps, +a translation-unsafe marker, a scope ceiling breached by construction, and +forward-compatibility of the overlay API). At that point, the plan still +awaited approval of the recorded deviations and an answer to the open question +on file-reading filters. The user settled that question on 2026-09-27 and +approved the plan and deviations on 2026-10-14. + +2026-09-27, after the user's decision on the open question. What changed: the +open question became a `Decision log` entry. Roadmap 7.4.1 now also roots the +`contents`, `size`, `linecount`, `hash`, `digest`, and `realpath` filters at +the sandbox, and technical design §5.5 names them as a third group of ambient +bypasses. Why: the user chose to extend 7.4.1 rather than add an item or accept +permanent refusal. Effect on remaining work: none on the 7.1.2 implementation, +which refuses these filters until 7.4.1 lands; EP-M5 no longer needs to add the +filters to §5.5, and has no remaining roadmap gap. At that point, approval of +the recorded deviations remained pending; the user approved them on 2026-10-14. + +2026-10-25, before the requested rebase and resumed implementation. What +changed: the header now says `APPROVED`, recording the user’s explicit +instruction; the existing approval of the listed deviations remains in force. +Why: establish approval before the history rewrite. Effect on remaining work: +the rebase completed onto `origin/main` at `de81d5c3`, and the header now +returns to `IN PROGRESS` as EP-M1 resumes; milestone scope and acceptance +criteria remain unchanged. + +2026-10-25, after rebasing. What changed: refreshed obsolete ADR identifiers, +documentation paths, and plan counts; recorded the new rebase and its audit. +Why: the target branch had advanced and moved the ADR layout since the prior +plan revision. Effect on remaining work: accepted implementation scope and +milestones are unchanged; candidate gates remain pending, and EP-M1 is next. diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 4f932f2ee..c66a3bb71 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -72,9 +72,13 @@ before execution, a critical requirement for compatibility with Ninja. Netsuke has two rendering policies. Full rendering evaluates every manifest field for `build`, `generate`, and normal manifest rendering, retaining the - full standard library and existing build semantics. Manifest-query rendering - is used by `netsuke help targets`: it evaluates discovery metadata and the - structural rule selectors required for graph validation, but leaves + full standard library and existing build semantics. Above the policy, the + loader selects one of four capability modes through `StdlibRegistration`: + the ambient full stdlib, a caller-configured full stdlib, the manifest-query + surface, and the deny-by-default test surface that registers pure helpers, + typed refusals, and test-supplied template overlays. Manifest-query + rendering is used by `netsuke help targets`: it evaluates discovery metadata + and the structural rule selectors required for graph validation, but leaves `command` and `script` recipe bodies unchanged. Build-only helpers in those skipped recipe bodies are therefore not evaluated during discovery. diff --git a/docs/netsuke-test-framework-technical-design.md b/docs/netsuke-test-framework-technical-design.md index 3d7ccc088..ec0841587 100644 --- a/docs/netsuke-test-framework-technical-design.md +++ b/docs/netsuke-test-framework-technical-design.md @@ -118,16 +118,18 @@ which alone holds scheduling state and writes the report._ ### 4.1. The pipeline today -The loader driver `from_str_named` (`src/manifest/mod.rs:109`) runs six stages: -read, `serde_saphyr` parse into a JSON value tree, MiniJinja environment -construction (strict undefined; `env()` and `glob()` registered from an injected -`EnvReader` and the glob expander; stdlib registered according to the selected +The loader driver `evaluate_manifest` (`src/manifest/mod.rs:160`), reached +through the telemetry-adapting wrapper `from_str_named` +(`src/manifest/budget/adapter.rs:13`), runs six stages: read, `serde_saphyr` +parse into a JSON value tree, MiniJinja environment construction (strict +undefined; `env()` and `glob()` registered from an injected `EnvReader` and the +glob expander; stdlib registered according to the selected `StdlibRegistration`; manifest `vars` exposed as globals), macro registration plus `expand_foreach` (which also evaluates `when`), `serde_json::from_value` deserialization into `NetsukeManifest`, and `render_manifest` string rendering. The fullest-parameterized entry point is `from_path_with_policy_and_env` -(`src/manifest/mod.rs:385`), which already accepts a `NetworkPolicy`, an -`EnvReader`, and a coarse `ManifestLoadStage` callback. +(`src/manifest/path_loaders.rs:114`), which already accepts a `NetworkPolicy`, +an `EnvReader`, and a coarse `ManifestLoadStage` callback. Two ordering facts drive the design. First, `foreach` and `when` evaluate against the raw value tree _before_ typed deserialization, so doubles for @@ -140,23 +142,26 @@ expansion. ### 4.2. The restricted-load precedent `netsuke help targets` established the pattern this framework extends. The -loader already selects its standard-library boundary through an enum -(`StdlibRegistration`, `src/manifest/mod.rs:99`) with two variants: -`Full(Box)` for builds, and `ManifestQuery` for side-effect-free -discovery. `src/manifest/query.rs` owns that boundary, and -`register_manifest_query` (`src/stdlib/register.rs:135`) implements it by +loader selects its standard-library boundary through an enum +(`StdlibRegistration`, `src/manifest/loader_options.rs:40`). At the end of the +execution plan (EP-M4) it has four variants: `AmbientFull` for the build path +rooted at the process working directory, `Full` for a caller-configured build, +`ManifestQuery` for side-effect-free discovery, and `Test` for the +deny-by-default test surface. `src/manifest/query.rs` owns the query boundary, +and `register_manifest_query` (`src/stdlib/register.rs:139`) implements it by registering the pure helpers and replacing `env`, `glob`, `fetch`, `shell`, `grep`, and `contents` with stubs that raise a located diagnostic naming the unavailable operation. -The test runner is a third load mode of exactly this shape, so it extends the +The test runner is a fourth load mode of exactly this shape, so it extends the existing enum rather than introducing a parallel mechanism: ```rust -enum StdlibRegistration { - Full(Box), - ManifestQuery, - Test(Box), // sandbox-rooted; impure helpers refuse +pub(crate) enum StdlibRegistration { + AmbientFull, // process-rooted build stdlib + Full(Box), // caller-configured build stdlib + ManifestQuery, // side-effect-free discovery + Test(Box), // deny-by-default; impure helpers refuse } ``` @@ -164,46 +169,98 @@ Three consequences follow, each replacing machinery this design would otherwise have invented: - The disabled-helper diagnostic already exists. - `manifest_query_operation_error` (`src/stdlib/register.rs:262`) is the - template for the "unavailable under test" messages in §5.5; the test mode - reuses the mechanism with its own message keys rather than intercepting - MiniJinja's unknown-function error. -- `disabled_env_reader` (`src/manifest/env/reader.rs:115`) already provides + `manifest_query_operation_error` + (`src/stdlib/restricted_helpers/query.rs:154`) is the template for the + "unavailable under test" messages in §5.5; the test mode reuses the mechanism + with its own message keys rather than intercepting MiniJinja's + unknown-function error. +- `disabled_env_reader` (`src/manifest/env/reader.rs:117`) already provides a reader that refuses every lookup. The test reader is that reader with the case's declared variables layered over it (§5.1). - `src/manifest/query.rs` is the precedent module for a capability-scoped non-build load, and the test runner's loader entry belongs beside it rather than in a new location. +Both restricted modes are driven from one inventory: `SURFACE` +(`src/stdlib/restricted_helpers/mod.rs:107`) records a query disposition and a +test disposition beside every helper whose availability depends on the load +surface. `register_for_test`, the query refusal stubs, and the real file-test +registration all consume it, and a contract test checks it in both directions, +so a new helper cannot be registered without stating what each mode does with +it (C5, §5.5). + ### 4.3. Loader options The loader gains an options-carrying entry point; the existing entry points -become thin wrappers over it with default options. +become thin wrappers over it with default options. As implemented in EP-M2 and +extended in EP-M3 and EP-M4: ```rust +/// Inputs to one manifest load beyond the YAML text and its name. pub struct ManifestLoadOptions<'a> { - pub registration: StdlibRegistration, - pub env_reader: Option, - pub overlays: Option, - pub on_stage: Option<&'a mut dyn FnMut(ManifestLoadStage)>, + registration: StdlibRegistration, + environment: ManifestEnvironment<'a>, + budget_limits: ManifestBudgetLimits, + on_stage: Option<&'a mut dyn FnMut(ManifestLoadStage)>, +} + +impl<'a> ManifestLoadOptions<'a> { + pub fn full(config: StdlibConfig, environment: ManifestEnvironment<'a>) -> Self; + pub fn test(surface: TestSurface) -> Self; + pub fn with_budget_limits(self, limits: ManifestBudgetLimits) -> Self; + pub fn with_stage_observer(self, on_stage: &'a mut dyn FnMut(ManifestLoadStage)) -> Self; + // crate-private: ambient, manifest_query, deferred, with_registration } -/// Test-supplied substitutions applied to the MiniJinja environment -/// after stdlib and manifest-macro registration, before foreach expansion. -pub struct TemplateOverlays { - pub functions: IndexMap, - pub macro_substitutions: IndexMap, +/// The capabilities granted to a manifest under `netsuke test`. +pub struct TestSurface { /* config, reader, overlays */ } + +impl TestSurface { + pub fn new(config: StdlibConfig) -> Self; + pub fn with_env_reader(self, reader: EnvReader) -> Self; + pub fn with_overlays(self, overlays: TemplateOverlays) -> Self; +} + +/// Test-supplied template functions, registered after stdlib and manifest +/// macros and before `foreach` expansion. +#[derive(Clone, Default)] +pub struct TemplateOverlays { /* functions: IndexMap */ } + +impl TemplateOverlays { + pub fn with_function(self, name: impl Into, callable: OverlayCallable) -> Self; + pub fn is_empty(&self) -> bool; } ``` +The fields are private and reached through constructors and builders. The +public entry is the string load, `from_str_with_options(yaml, name, options)`. +A path load needs a workspace before it can decide its stdlib configuration, so +it goes through the crate-private +`query::from_path_with_options(path, registration_for, options)`, where +`registration_for: FnOnce(ManifestWorkspace) -> Result` +derives the registration from the opened directory. +`ManifestLoadOptions::deferred` exists only so string and path loads can share +one builder chain: its registration is a placeholder that +`from_path_with_options` always replaces. + `registration` carries the stdlib boundary rather than a bare `StdlibConfig` plus a separate `NetworkPolicy`, because §4.2 already binds those together per mode: the network policy for a test load is a property of `StdlibRegistration::Test`, not an independently settable knob. This keeps one place where a load mode's capabilities are decided. `Test` boxes its payload -like `Full`, matching the existing constructors in `parse_with_config.rs` and -`query.rs` and keeping the variants near enough in size that the enum stays -cheap to move. +like `Full`, keeping the variants near enough in size that the enum stays cheap +to move, and `StdlibRegistration` itself stays crate-private so the four load +modes remain the loader's decision rather than an embedder's. + +`TestSurface` holds three things and no more: the `StdlibConfig` the surface +grants (including, via `StdlibConfig::with_clock`, the clock `now()` may read); +an `EnvReader` that `env()` answers from; and the `TemplateOverlays`. Keeping +the payload here rather than in the options means the registration enum never +grows a second capability field. `TestSurface::new` installs +`disabled_env_reader`, so with no injected reader every lookup reports "not +set" rather than consulting the process. The options' own `environment` is +ignored under `Test`: `StdlibRegistration::env_reader` returns the surface's +reader, and only the surface's. The structure is named `TemplateOverlays`, not `EnvOverlays`: in this codebase "env" means the process environment (ADR-0008, `EnvReader`), and these overlays @@ -212,22 +269,92 @@ deliberately does not appear here — it has exactly one owner, `StdlibConfig` (§5.2). `on_stage` keeps the existing `&mut dyn FnMut` shape from `from_path_with_policy_and_env`. -`OverlayCallable` wraps a double's dispatch closure (§7). Registration order -inside environment construction becomes: - -1. `env()` and `glob()` from the effective `EnvReader` and glob expander; -2. stdlib via `register_with_config` (with the test's `StdlibConfig`); -3. manifest `vars` globals; -4. manifest macros; -5. **overlays** — test doubles registered last so they shadow same-named - stdlib functions (MiniJinja `add_function` replaces an existing - registration), plus macro substitutions, which additionally rewrite the - macro-import prelude (§5.4); -6. `expand_foreach`, deserialization, rendering as today. +`TemplateOverlays` ships with the `functions` map only. Macro substitution +(§5.4) also has to rewrite the macro-import prelude rather than merely add a +global, so `macro_substitutions` is deferred to roadmap 7.1.3; private fields +make that addition non-breaking. + +`OverlayCallable` wraps a double's dispatch closure (§7): an +`Arc) -> Result + Send + Sync>`. Each +invocation is described by an `OverlayCall`, which carries the rendering +`State`, the argument slice (keyword arguments appended as one trailing +`Value`, so a forwarded call reproduces the original), and the function the +overlay replaced, when there was one. That last field is `shadowed()`: +MiniJinja offers no lookup by name, but registered functions are enumerable +through `Environment::globals`, so the loader captures the displaced function +before `add_function` replaces it and hands it to the overlay. An overlay can +therefore wrap a helper rather than only discard it, which is how a `Spy` +resolves to the real callable (§7). + +Overlay names are checked at load time, because only a load knows which `vars` +keys and macros the manifest defines: + +- An overlay may replace any name the stdlib registered, and `glob()` + specifically: replacing a withheld helper is the reason the mechanism exists. +- An overlay may **not** take `env` (the loader's environment seam), a name + with the `__netsuke_` prefix (loader internals), a key in the manifest's + `vars`, or a name the manifest's macros define. Each is a typed + `TemplateOverlayError::NameCollision` carrying the namespace it collided + with, rendered through the localized `manifest.overlay.name_collision` + message and the code `[netsuke::manifest::overlay::collision]`. +- Validation runs before any registration, so a rejected overlay leaves the + template environment untouched. + +Registration order inside environment construction is: + +1. `env()` and `glob()` from the effective `EnvReader` and glob expander, owned + by the loader because whether a load may read the host is a property of the + load (C5); +2. the stdlib surface via `register_stdlib` (with the test's `StdlibConfig`); +3. the manifest's claimed-name namespace is computed, before its `vars` are + registered, and is reused by the reserved-name check so the two cannot + disagree; +4. manifest `vars` globals; +5. manifest macros; +6. **overlays**, registered last so they shadow same-named stdlib functions + (MiniJinja `add_function` replaces an existing registration); +7. `expand_foreach`, deserialization, rendering as today. The overlay hook is compiled unconditionally: it is an ordinary parameter, not a test-only `cfg`, because the test runner is a production code path of the -shipped binary. +shipped binary (C9). + +For screen readers: The following flowchart follows one manifest load through +`from_str_with_options`, branching at the `StdlibRegistration` mode. The +`AmbientFull` and `Full` modes register the whole stdlib; `ManifestQuery` +registers the query helpers and their refusals, which are then classified; +`Test` registers the pure helpers and typed refusals, and applies the overlays. +Every branch converges on foreach/`when` expansion, then on rendering and +validation. The environment reader is not part of the branch: `AmbientFull` +reads the process environment, `Full` takes the caller's `ManifestEnvironment`, +`Test` takes the surface's injected reader, and `ManifestQuery` refuses `env()` +outright. + +```mermaid +flowchart TD + Start[from_str_with_options] --> Mode{StdlibRegistration mode} + Mode -->|AmbientFull| AmbientFull[Register full helpers] + Mode -->|Full| FullMode[Register full helpers] + Mode -->|ManifestQuery| RestrictedQuery[Register query helpers and refusals] + Mode -->|Test| RestrictedTest[Register pure helpers and typed refusals] + AmbientFull --> AmbientEnv["Read env from the process environment"] + FullMode --> FullEnv["Use the caller's env reader and clock"] + RestrictedTest --> TestEnv["Use the surface's env reader and clock"] + TestEnv --> Overlay[Check names and register overlays] + RestrictedQuery --> QueryClassify[Classify query refusals] + AmbientEnv --> Expand[Expand foreach and when] + FullEnv --> Expand + Overlay --> Expand + QueryClassify --> Expand + Expand --> Render[Render and validate manifest] +``` + +_Figure 2: Manifest loading paths by `StdlibRegistration` mode. Only the `Test` +mode applies overlays. `Full` takes an environment reader from +`ManifestLoadOptions::full`; `Test` takes one from its `TestSurface`, whose +`StdlibConfig` may also pin the clock `now()` reads through +`StdlibConfig::with_clock`. The expansion, rendering, and validation stages +after the branch are shared by every mode._ ### 4.4. Result views @@ -241,7 +368,7 @@ shipped binary. methods the UX design promises (`has_target`, `has_rule`, `target(name)` field access), exposed as a MiniJinja object. - `result.ninja` — the string from `ninja_gen::generate` - (`src/ninja_gen/mod.rs:106`), which is already deterministic for snapshot + (`src/ninja_gen/mod.rs:108`), which is already deterministic for snapshot tests. The IR types themselves are not exposed: the views are a stable assertion @@ -327,29 +454,75 @@ because the prelude is the half that actually decides which macro renders. ### 5.5. Stdlib configuration under test -The runner constructs a per-case `StdlibConfig` (`src/stdlib/config/mod.rs:21`) +The runner constructs a per-case `StdlibConfig` (`src/stdlib/config/mod.rs:26`) rooted at the sandbox: | Knob | Test value | | ----------------------------------- | -------------------------------------------- | | `workspace_root` | the case sandbox directory (`cap-std` `Dir`) | | `network_policy` | deny all | -| `path_override`, `pathext_override` | empty unless the case stubs `which` | +| `path_override`, `pathext_override` | empty; `which` refuses rather than searching | | `home_directory` | `HomeDirectory::Missing` | | command helpers | registered as refusing stubs (C3) | _Table 1: Per-case stdlib configuration._ -The command helpers, `fetch`, and the other impure entry points are registered -as stubs that refuse, following `register_manifest_query` (§4.2) rather than -being left unregistered. Refusing stubs beat omission: under strict-undefined -MiniJinja an absent function yields a generic unknown-function error, whereas a -registered stub raises a located diagnostic that names the operation and points -at the double syntax. The test mode supplies its own message keys through the -existing `manifest_query_operation_error` shape. +Every helper whose availability depends on the load surface has a +`TestDisposition` row in `SURFACE` +(`src/stdlib/restricted_helpers/mod.rs:107`), and `register_for_test` registers +exactly the refused rows as stubs. The implemented inventory is: + +| Kind | Refused under test | Available under test | +| -------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Function | `glob`, `fetch`, `which`, `command_available` | `env` (injected reader), `timedelta` | +| Filter | `which`, `shell`, `grep`, `contents`, `realpath`, `expanduser`, `size`, `linecount`, `hash`, `digest` | `basename`, `dirname`, `with_suffix`, `relative_to`, `shell_quote`, `shell_join`, `uniq`, `flatten`, `compact`, `group_by` | +| Test | `dir`, `file`, `symlink`, `pipe`, `block_device`, `char_device`, `device` | — | + +_Table 2: The `netsuke test` stdlib surface._ + +`now()` is a fifth disposition, `ClockInjected`: it is the real helper when the +surface's `StdlibConfig` carries an injected clock (§5.2) and a refusal +otherwise, because the ambient clock is a host read. `env()` is available but +reads only the surface's injected `EnvReader`, never the process. + +Refusing stubs beat omission: under strict-undefined MiniJinja an absent +function yields a generic unknown-function error, whereas a registered stub +raises a located diagnostic that names the operation and points at the overlay +syntax. The test mode supplies its own message key +(`stdlib.test_mode.helper_unavailable`, +`[netsuke::jinja::test_mode::unavailable]`) through the existing +`manifest_query_operation_error` shape, and every refusal carries a typed +`TestModeRefusal` source so a caller can tell a withheld capability from an +unknown name without matching on text. Because a real helper documents various +call shapes, each stub accepts any positional and keyword arguments; a narrower +signature would report an argument-count error instead of the refusal. + +Two refusals are deliberate deviations from the "just sandbox it" reading, and +both are recorded in ADR-0070: + +- `which` refuses even though `path_override` and `pathext_override` exist. + Those knobs choose where a build looks; a test load has no host path to look + in, and a sandbox-rooted resolver is not implemented yet. Running `which` + against an empty `PATH` would report a misleading "not found" instead of a + refusal that names the withheld capability, so refusal is uniform with the + rest of the surface. +- `expanduser` refuses rather than resolving `~`. The surface's + `home_directory` is `HomeDirectory::Missing`, so the real filter would fail + anyway, and a resolved home path would expose a host-shaped answer. The + refusal names the operation instead. + +Of the `StdlibConfig` settings, test mode honours only the clock in roadmap +7.1.2; the workspace root becomes load-bearing when 7.4.1 roots `glob`, the +file tests, and the file-reading filters at it. + +The functions among the refused rows (`glob`, `which`, `fetch`, +`command_available`, and a system-clocked `now`) remain replaceable by overlays +(§4.3); filters and tests are not mockable in the first version (UX design +§8.6), so a manifest that pipes through `shell` or calls `file` under test +fails with the refusal rather than a substituted answer. `workspace_root` alone does not achieve that scoping, and the design must not -pretend otherwise. Two filesystem helpers bypass it today: +pretend otherwise. Three groups of filesystem helpers bypass it today: - `glob()` is registered in `src/manifest/mod.rs` over `glob::expand_glob(&pattern)`, which takes no workspace root. Its matcher @@ -361,19 +534,33 @@ pretend otherwise. Two filesystem helpers bypass it today: `path::file_type_matches`, whose `parent_dir` helper calls `Dir::open_ambient_dir(.., ambient_authority())`. `register_file_tests` never receives `StdlibConfig` at all. +- The file-reading filters `contents`, `size`, `linecount`, `hash`, `digest`, + and `realpath` (`src/stdlib/path/filters.rs`) resolve their argument against + the process working directory rather than the configured workspace root. Left unaddressed, a fixture-built project would be globbed from the runner's working directory instead of its own sandbox, which is both host-dependent and the exact non-determinism this framework exists to remove. The test -registration therefore supplies sandbox-rooted adapters for both helpers: +registration will therefore supply sandbox-rooted adapters for all three groups: `glob()` resolves relative patterns against the case sandbox `Dir`, and the -file tests resolve their paths through the same handle, rejecting escapes -rather than falling back to ambient authority. These adapters are test-mode +file tests and file-reading filters resolve their paths through the same +handle, rejecting escapes rather than falling back to ambient authority. The +filters matter as much as the other two because filters cannot be replaced by +doubles in the first version (UX design §8.6): without an adapter, a manifest +that hashes or reads a file could never be tested. These adapters are test-mode components, not changes to the build path's behaviour, so ADR-0010's accepted limitation stands for builds while tests get the stronger guarantee they require. -With those adapters in place the visibility consequence is worth stating +The adapters land in stages, and until they do the test registration refuses +all three groups rather than falling back to ambient authority (roadmap 7.1.2). +Refusal is the interim behaviour, so a manifest under test that globs, tests a +file, or reads one gets a located diagnostic naming the withheld capability — +not a host-dependent answer. Roadmap 7.4.1 replaces the refusals with the +sandbox-rooted adapters for all three groups. The visibility consequence below +holds in both states, because a refusal reaches no file at all. + +Once those adapters are in place the visibility consequence is worth stating plainly: the project tree is _not_ visible to a manifest under test. A default-subject test sees only the files its fixtures and `given.fs` created. Invariant I4 (§12) is scoped accordingly, and invariant I5's @@ -467,10 +654,13 @@ entry followed by another, and a mismatch under both ordering modes. A `Mock` with no accepting entry yields a MiniJinja error carrying a structured payload, which the runner converts into the unmatched-call report with its suggested YAML stanza. A `Stub` falls back to its `default` or `Undefined`. A `Spy` -resolves to a handle the runner captured when it constructed the effective -callable — the runner holds these handles itself rather than retrieving -previously registered functions from the MiniJinja environment, which offers no -such retrieval. +resolves to a handle for the real callable. MiniJinja offers no lookup by name, +but registered functions are enumerable as environment globals, so a handle can +be captured rather than reconstructed: the loader's overlay registration (§4.3) +walks `Environment::globals` before `add_function` replaces a name and hands +the displaced function to the overlay as `OverlayCall::shadowed`. A spy built +on an overlay therefore delegates to the function it replaced instead of the +runner having to re-create it. Releasing the lock before invoking a spy's delegate is load-bearing, not an optimization. A spied callable may call another double — a spied macro whose @@ -953,7 +1143,10 @@ The minimum viable implementation, in dependency order: replacement semantics for shadowing, and the runner-side handle capture that spies depend on (§7). If shadowing fails, the fallback is filtering the macro out of `register_manifest_macros` via `TemplateOverlays`, which the - options structure already permits. + options structure already permits. Roadmap 7.1.2 settled both questions: + `add_function` replaces a registration, and the displaced function is + reachable by enumerating `Environment::globals` before replacement (§4.3, + §7). 2. Test-suite AST, parser, and discovery (I6). Localization keys for the dialect's diagnostics are defined from this phase onwards (§10). 3. Mock engine and overlays for functions and macro substitution (I1, I3). @@ -983,6 +1176,9 @@ reconciled: environment injection seams §5 relies on. - [ADR-0010](adrs/adr-0010-scope-the-glob-metadata-capability-to-the-literal-prefix.md), which governs the glob capability scoping the mock engine and sandbox rely on. +- [ADR-0070](adrs/adr-0070-deny-by-default-test-mode-stdlib.md), which records + the deny-by-default test stdlib surface, its refusal mechanics, and the + `which`/`expanduser` deviations §5.5 describes. - [Roadmap phase 7](roadmap.md), which tracks the phasing above (§14) as numbered deliverables. diff --git a/docs/polonius.md b/docs/polonius.md index 7d55a2faf..d7a4ceef4 100644 --- a/docs/polonius.md +++ b/docs/polonius.md @@ -63,6 +63,13 @@ well — the owned style was habit, so they carry no toolchain caveat: `Vec<&Utf8Path>` borrowed from the snapshot; the search loop reads borrowed directories and the paths are copied into the owned `ResolveError::NotFound` only at the error boundary. +- `src/manifest/loader_options.rs` — `ManifestLoadOptions<'a>` borrows its + `ManifestEnvironment<'a>` and its `&'a mut dyn FnMut(ManifestLoadStage)` + observer, and passes both through to `ManifestParse` by move rather than + cloning or reboxing them. The lifetime parameter is the caller's, so an + options value cannot outlive the reader or the observer it borrows; the enum's + `TestSurface` payload is owned and boxed so the loader consumes it without + borrowing from the caller, and the variants stay close in size. ## Refusals diff --git a/docs/roadmap.md b/docs/roadmap.md index db03a545a..c8dcb7199 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1512,17 +1512,17 @@ Objective: deliver the `netsuke test` command and YAML test dialect specified in - [x] Record the seam classification per [ADR-0008](adrs/adr-0008-environment-seam-taxonomy.md). -- [ ] 7.1.2. Introduce the options-carrying manifest loader entry point. See +- [x] 7.1.2. Introduce the options-carrying manifest loader entry point. See [technical design §4.3](netsuke-test-framework-technical-design.md). - - [ ] Add `ManifestLoadOptions` and `TemplateOverlays`, with existing + - [x] Add `ManifestLoadOptions` and `TemplateOverlays`, with existing entry points as thin wrappers. - - [ ] Extend `StdlibRegistration` with a `Test` mode beside `Full` and + - [x] Extend `StdlibRegistration` with a `Test` mode beside `Full` and `ManifestQuery` rather than adding a parallel boundary mechanism. - - [ ] Reuse the `manifest_query_operation_error` diagnostic shape and + - [x] Reuse the `manifest_query_operation_error` diagnostic shape and `disabled_env_reader` established by `netsuke help targets`. - - [ ] Register overlays after stdlib and manifest macros and before + - [x] Register overlays after stdlib and manifest macros and before `foreach` expansion. - - [ ] Add differential tests showing the build path is unchanged + - [x] Add differential tests showing the build path is unchanged (invariants I4 and I7). - [ ] 7.1.3. Spike MiniJinja overlay shadowing for macro substitution. @@ -1585,13 +1585,16 @@ Objective: deliver the `netsuke test` command and YAML test dialect specified in ### 7.4. Fixture engine -- [ ] 7.4.1. Add sandbox-rooted `glob()` and file-test adapters for the - test registration. Requires: 7.1.2. See +- [ ] 7.4.1. Add sandbox-rooted `glob()`, file-test, and file-reading filter + adapters for the test registration. Requires: 7.1.2. See [technical design §5.5](netsuke-test-framework-technical-design.md). - [ ] Resolve relative glob patterns against the case sandbox rather than the process working directory. - [ ] Resolve file-test paths through the sandbox handle instead of `open_ambient_dir`, rejecting escapes. + - [ ] Resolve the `contents`, `size`, `linecount`, `hash`, `digest`, and + `realpath` filters through the sandbox handle instead of the process + working directory, rejecting escapes and keeping the bounded-read limits. - [ ] Leave the build path's ADR-0010 behaviour unchanged. - [ ] 7.4.2. Implement the fixture lifecycle. See diff --git a/docs/translators-guide.md b/docs/translators-guide.md index e578e490d..97831e09d 100644 --- a/docs/translators-guide.md +++ b/docs/translators-guide.md @@ -160,9 +160,10 @@ Table 3: Message key domains and their purposes **Naming pattern:** `domain.subdomain.specific_message` The corresponding Rust constants are defined in `src/localization/keys.rs`, -with the `netsuke check` keys in `src/localization/check_keys.rs` and the clap -error keys in `src/localization/clap_error_keys.rs`, using UPPER_SNAKE_CASE -(e.g., `CLI_FLAG_FILE_HELP` maps to `cli.flag.file.help`). +with the `netsuke check` keys in `src/localization/check_keys.rs`, the clap +error keys in `src/localization/clap_error_keys.rs`, and the manifest-loading +keys in `src/localization/manifest_keys.rs`, using UPPER_SNAKE_CASE (e.g., +`CLI_FLAG_FILE_HELP` maps to `cli.flag.file.help`). ## 5. Variable usage @@ -446,10 +447,10 @@ Netsuke validates every registered locale at compile time via `build_l10n_audit/`: - **Metadata drift**: `Cargo.toml`'s locale list disagrees with the registry -- **Missing keys**: Keys in `keys.rs`, `check_keys.rs`, or - `clap_error_keys.rs` but not in the FTL file +- **Missing keys**: Keys in `keys.rs`, `check_keys.rs`, `clap_error_keys.rs`, + or `manifest_keys.rs` but not in the FTL file - **Orphaned keys**: Keys in the FTL file but in none of `keys.rs`, - `check_keys.rs`, and `clap_error_keys.rs` + `check_keys.rs`, `clap_error_keys.rs`, and `manifest_keys.rs` - **Variable mismatches**: A message interpolating different variables from the English source — a dropped `{ $path }` or a stray `{ $name }` diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index dcc7d2ae2..27c6ea17c 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = ‏[netsuke::jinja::env::args] { $details } manifest.env.default_not_string = يجب أن تكون قيمة default في env سلسلة نصية، وتم تلقّي { $kind }. manifest.vars.not_object = يجب أن يكون `vars` في ملف البيانات تخطيطًا أو كائنًا. manifest.vars.reserved_name = يُعدّ مفتاح `vars` المسمّى '{ $name }' في ملف البيانات محجوزًا لدالة قوالب مدمجة؛ أعد تسمية المتغيّر. +manifest.overlay.name_collision = ‏[netsuke::manifest::overlay::collision] يتعارض تراكب القالب '{ $name }' مع نطاق { $kind }؛ أعد تسمية التراكب. manifest.read_failed = تعذّرت قراءة ملف البيانات من { $path }. manifest.resolve_workspace_root = تعذّر تحديد جذر مساحة العمل. manifest.workspace_non_utf8 = مسار جذر مساحة العمل «{ $path }» ليس UTF-8 صالحًا. @@ -302,6 +303,10 @@ stdlib.command.output.mode.streaming = التدفّق stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = ‏[netsuke::jinja::test_mode::unavailable] { $operation } غير متاح ضمن `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = ‏[netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = ‏[netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index 0684d9dbc..dab5e7e39 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Hodnota default v env musí být řetězec; typ obdržené hodnoty: { $kind }. manifest.vars.not_object = Položka `vars` manifestu musí být mapování nebo objekt. manifest.vars.reserved_name = Klíč `vars` '{ $name }' v manifestu je vyhrazen pro vestavěného pomocníka šablon; přejmenujte proměnnou. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Překryv šablony '{ $name }' koliduje s oborem { $kind }; přejmenujte překryv. manifest.read_failed = Manifest v { $path } se nepodařilo přečíst. manifest.resolve_workspace_root = Kořen pracovního prostoru se nepodařilo určit. manifest.workspace_non_utf8 = Kořenová cesta pracovního prostoru „{ $path }“ není platné UTF-8. @@ -300,6 +301,10 @@ stdlib.command.output.mode.streaming = proudové zpracování stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } není dostupné v rámci `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index e06a5603a..81c6c550d 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Rhaid i default env fod yn llinyn, derbyniwyd { $kind }. manifest.vars.not_object = Rhaid i `vars` y maniffest fod yn fap neu'n wrthrych. manifest.vars.reserved_name = Mae'r allwedd `vars` '{ $name }' yn y maniffest wedi'i chadw ar gyfer cynorthwyydd templed mewnol; ailenwch y newidyn. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Mae'r troshaeniad templed '{ $name }' yn gwrthdaro â'r gofod enw { $kind }; ailenwch y troshaeniad. manifest.read_failed = Methwyd â darllen y maniffest o { $path }. manifest.resolve_workspace_root = Methwyd â phennu gwraidd y gweithle. manifest.workspace_non_utf8 = Nid yw llwybr gwraidd y gweithle ‘{ $path }’ yn UTF-8 dilys. @@ -302,6 +303,10 @@ stdlib.command.output.mode.streaming = ffrydio stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] Nid yw { $operation } ar gael o dan `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index c8d7e49fe..7ba601867 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = default i env skal være en streng, modtog { $kind }. manifest.vars.not_object = Manifestets `vars` skal være en tilknytning eller et objekt. manifest.vars.reserved_name = Manifestets `vars`-nøgle '{ $name }' er reserveret til en indbygget skabelonhjælper; omdøb variablen. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Skabelonoverlejringen '{ $name }' er i konflikt med navnerummet { $kind }; omdøb overlejringen. manifest.read_failed = Manifestet i { $path } kunne ikke læses. manifest.resolve_workspace_root = Roden af arbejdsområdet kunne ikke bestemmes. manifest.workspace_non_utf8 = Rodstien for arbejdsområdet "{ $path }" er ikke gyldig UTF-8. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = strømning stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } er ikke tilgængelig under `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index 6e8ff84ce..d6a324641 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Der default von env muss eine Zeichenkette sein, empfangen wurde { $kind }. manifest.vars.not_object = `vars` im Manifest muss eine Zuordnung bzw. ein Objekt sein. manifest.vars.reserved_name = Der `vars`-Schlüssel '{ $name }' im Manifest ist für eine integrierte Vorlagenfunktion reserviert; benennen Sie die Variable um. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Die Vorlagenüberlagerung '{ $name }' kollidiert mit dem Namensraum { $kind }; benennen Sie die Überlagerung um. manifest.read_failed = Das Manifest unter { $path } konnte nicht gelesen werden. manifest.resolve_workspace_root = Das Wurzelverzeichnis des Arbeitsbereichs konnte nicht ermittelt werden. manifest.workspace_non_utf8 = Der Wurzelpfad des Arbeitsbereichs „{ $path }“ ist kein gültiges UTF-8. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = Streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } ist unter `netsuke test` nicht verfügbar. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index e0799a382..84cfbe93c 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -148,6 +148,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Το default του env πρέπει να είναι συμβολοσειρά· τύπος τιμής που ελήφθη: { $kind }. manifest.vars.not_object = Το `vars` του δηλωτικού πρέπει να είναι αντιστοίχιση ή αντικείμενο. manifest.vars.reserved_name = Το κλειδί `vars` '{ $name }' του μανιφέστου είναι δεσμευμένο για ενσωματωμένη βοηθητική συνάρτηση προτύπων· μετονομάστε τη μεταβλητή. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Η επικάλυψη προτύπου '{ $name }' συγκρούεται με τον χώρο ονομάτων { $kind }· μετονομάστε την επικάλυψη. manifest.read_failed = Δεν ήταν δυνατή η ανάγνωση του δηλωτικού από { $path }. manifest.resolve_workspace_root = Δεν ήταν δυνατός ο προσδιορισμός της ρίζας του χώρου εργασίας. manifest.workspace_non_utf8 = Η ριζική διαδρομή του χώρου εργασίας «{ $path }» δεν είναι έγκυρο UTF-8. @@ -299,6 +300,10 @@ stdlib.command.output.mode.streaming = συνεχής ροή stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] Το { $operation } δεν είναι διαθέσιμο στο `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index 2e1a3c08f..b645b694d 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = env default must be a string, received { $kind }. manifest.vars.not_object = Manifest `vars` must be a map/object. manifest.vars.reserved_name = Manifest `vars` key '{ $name }' is reserved for a built-in template helper; rename the variable. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Template overlay '{ $name }' collides with the { $kind } namespace; rename the overlay. manifest.read_failed = Failed to read manifest at { $path }. manifest.resolve_workspace_root = Failed to resolve workspace root. manifest.workspace_non_utf8 = Workspace root path '{ $path }' is not valid UTF-8. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } is unavailable under `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index c517a33e6..4d61e6e30 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -150,6 +150,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = env default must be a string, received { $kind }. manifest.vars.not_object = Manifest `vars` must be a map/object. manifest.vars.reserved_name = Manifest `vars` key '{ $name }' is reserved for a built-in template helper; rename the variable. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Template overlay '{ $name }' collides with the { $kind } namespace; rename the overlay. manifest.read_failed = Failed to read manifest at { $path }. manifest.resolve_workspace_root = Failed to resolve workspace root. manifest.workspace_non_utf8 = Workspace root path '{ $path }' is not valid UTF-8. @@ -301,6 +302,10 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } is unavailable under `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index e56d355e5..a253521b6 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -148,6 +148,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = El default de env debe ser una cadena, se recibió { $kind }. manifest.vars.not_object = `vars` del manifiesto debe ser un mapa u objeto. manifest.vars.reserved_name = La clave `vars` '{ $name }' del manifiesto está reservada para una función auxiliar de plantillas integrada; cambie el nombre de la variable. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] La superposición de plantilla '{ $name }' entra en conflicto con el espacio de nombres { $kind }; cambie el nombre de la superposición. manifest.read_failed = No se pudo leer el manifiesto en { $path }. manifest.resolve_workspace_root = No se pudo resolver la raíz del espacio de trabajo. manifest.workspace_non_utf8 = La ruta raíz del espacio de trabajo '{ $path }' no es UTF-8 válido. @@ -299,6 +300,10 @@ stdlib.command.output.mode.streaming = transmisión stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } no está disponible en `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 535f869ff..50a5357a5 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = El default de env debe ser una cadena, se recibió { $kind }. manifest.vars.not_object = `vars` del manifiesto debe ser un mapa/objeto. manifest.vars.reserved_name = La clave `vars` '{ $name }' del manifiesto está reservada para una función auxiliar de plantillas integrada; renombre la variable. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] La superposición de plantilla '{ $name }' entra en conflicto con el espacio de nombres { $kind }; renombre la superposición. manifest.read_failed = No se pudo leer el manifiesto en { $path }. manifest.resolve_workspace_root = No se pudo resolver la raíz del workspace. manifest.workspace_non_utf8 = La ruta de la raíz del workspace '{ $path }' no es UTF-8 válida. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } no está disponible en `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index 78fe22909..4e7e4fd04 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = ‏[netsuke::jinja::env::args] { $details } manifest.env.default_not_string = مقدار default در env باید رشته باشد، اما { $kind } دریافت شد. manifest.vars.not_object = ‏`vars` در مانیفست باید نگاشت یا شیء باشد. manifest.vars.reserved_name = کلید `vars` با نام '{ $name }' در مانیفست برای یک کمک‌کننده داخلی قالب رزرو شده است؛ نام متغیر را تغییر دهید. +manifest.overlay.name_collision = ‏[netsuke::manifest::overlay::collision] هم‌پوشانی قالب '{ $name }' با فضای نام { $kind } تعارض دارد؛ نام هم‌پوشانی را تغییر دهید. manifest.read_failed = خواندن مانیفست از { $path } ممکن نشد. manifest.resolve_workspace_root = تعیین ریشهٔ فضای کاری ممکن نشد. manifest.workspace_non_utf8 = مسیر ریشهٔ فضای کاری «{ $path }» ‏UTF-8 معتبر نیست. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = جریان stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = ‏[netsuke::jinja::test_mode::unavailable] { $operation } در `netsuke test` در دسترس نیست. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = ‏[netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = ‏[netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index f08fdc72d..1dc4f9272 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = default env-funktiossa on oltava merkkijono, vastaanotettiin { $kind }. manifest.vars.not_object = Manifestin `vars` on oltava kuvaus tai objekti. manifest.vars.reserved_name = Manifestin `vars`-avain '{ $name }' on varattu sisäänrakennetulle mallineapufunktiolle; nimeä muuttuja uudelleen. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Mallineen ohitus '{ $name }' törmää nimiavaruuteen { $kind }; nimeä ohitus uudelleen. manifest.read_failed = Manifestia ei voitu lukea polusta { $path }. manifest.resolve_workspace_root = Työtilan juurta ei voitu selvittää. manifest.workspace_non_utf8 = Työtilan juuripolku ”{ $path }” ei ole kelvollista UTF-8:aa. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = virtaus stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } ei ole käytettävissä komennossa `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index e82ae2ac6..bd7b18cf1 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -148,6 +148,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = L'argument default de env doit être une chaîne, reçu { $kind }. manifest.vars.not_object = `vars` du manifeste doit être une table ou un objet. manifest.vars.reserved_name = La clé `vars` '{ $name }' du manifeste est réservée à une fonction utilitaire de gabarit intégrée ; renommez la variable. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] La superposition de gabarit '{ $name }' entre en conflit avec l'espace de noms { $kind } ; renommez la superposition. manifest.read_failed = Impossible de lire le manifeste depuis { $path }. manifest.resolve_workspace_root = Impossible de résoudre la racine de l'espace de travail. manifest.workspace_non_utf8 = Le chemin racine de l'espace de travail « { $path } » n'est pas de l'UTF-8 valide. @@ -299,6 +300,10 @@ stdlib.command.output.mode.streaming = flux stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } n’est pas disponible avec `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 7962fc8af..5937807a8 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Feumaidh default env a bhith na shreang; fhuaras { $kind }. manifest.vars.not_object = Feumaidh `vars` an fhoirm-liosta a bhith na mhapadh no na oibseact. manifest.vars.reserved_name = Tha an iuchair `vars` '{ $name }' sa mhanifest glèidhte do chuidiche teamplaid na broinn; thoir ainm ùr air a' chaochladair. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Tha an tar-lùbadh teamplaid '{ $name }' a' bualadh an raon-ainm { $kind }; thoir ainm ùr air an tar-lùbadh. manifest.read_failed = Cha b' urrainnear am foirm-liosta a leughadh o { $path }. manifest.resolve_workspace_root = Cha b' urrainnear freumh an raoin-obrach a dhearbhadh. manifest.workspace_non_utf8 = Chan eil slighe freumh an raoin-obrach “{ $path }” na UTF-8 dhligheach. @@ -300,6 +301,10 @@ stdlib.command.output.mode.streaming = sruthadh stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] Chan eil { $operation } ri fhaighinn fo `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index 6ce2e8db3..9b72587d2 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = ‏[netsuke::jinja::env::args] { $details } manifest.env.default_not_string = הערך default של env חייב להיות מחרוזת, התקבל { $kind }. manifest.vars.not_object = השדה `vars` של המניפסט חייב להיות מיפוי או אובייקט. manifest.vars.reserved_name = מפתח `vars` בשם '{ $name }' במניפסט שמור לפונקציית עזר מובנית של תבניות; שנה את שם המשתנה. +manifest.overlay.name_collision = ‏[netsuke::manifest::overlay::collision] שכבת-העל של התבנית '{ $name }' מתנגשת עם מרחב השמות { $kind }; שנה את שם שכבת-העל. manifest.read_failed = לא ניתן היה לקרוא את המניפסט מ‑{ $path }. manifest.resolve_workspace_root = לא ניתן היה לקבוע את שורש סביבת העבודה. manifest.workspace_non_utf8 = נתיב השורש של סביבת העבודה „{ $path }” אינו UTF-8 תקין. @@ -300,6 +301,10 @@ stdlib.command.output.mode.streaming = הזרמה stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = ‏[netsuke::jinja::test_mode::unavailable] { $operation } אינו זמין תחת `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = ‏[netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = ‏[netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index 9e6017937..9032071e9 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = env का default स्ट्रिंग होना चाहिए, { $kind } प्राप्त हुआ। manifest.vars.not_object = मैनिफ़ेस्ट का `vars` प्रतिचित्रण अथवा वस्तु होना चाहिए। manifest.vars.reserved_name = मैनिफ़ेस्ट की `vars` कुंजी '{ $name }' अंतर्निहित टेम्पलेट सहायक के लिए आरक्षित है; चर का नाम बदलें। +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] टेम्पलेट ओवरले '{ $name }' नामस्थान { $kind } से टकराता है; ओवरले का नाम बदलें। manifest.read_failed = { $path } से मैनिफ़ेस्ट नहीं पढ़ा जा सका। manifest.resolve_workspace_root = कार्यक्षेत्र की जड़ निर्धारित नहीं की जा सकी। manifest.workspace_non_utf8 = कार्यक्षेत्र का मूल पथ “{ $path }” मान्य UTF-8 नहीं है। @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = धारा stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] `netsuke test` के अंतर्गत { $operation } उपलब्ध नहीं है। + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 3a293c73b..51d1999a2 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Az env default értékének karakterláncnak kell lennie, ezt kaptuk: { $kind }. manifest.vars.not_object = A jegyzék `vars` mezőjének leképezésnek vagy objektumnak kell lennie. manifest.vars.reserved_name = A manifest `vars` kulcsa, '{ $name }', egy beépített sablonsegéd számára fenntartott; nevezze át a változót. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] A(z) '{ $name }' sablonfedés ütközik a(z) { $kind } névtérrel; nevezze át a fedést. manifest.read_failed = A jegyzéket nem sikerült beolvasni innen: { $path }. manifest.resolve_workspace_root = A munkaterület gyökerét nem sikerült meghatározni. manifest.workspace_non_utf8 = A munkaterület gyökérútvonala („{ $path }”) nem érvényes UTF-8. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = folyamatos átvitel stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] A(z) { $operation } nem érhető el a `netsuke test` alatt. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index be48eaa52..5b001d617 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = default pada env harus berupa untai, menerima { $kind }. manifest.vars.not_object = `vars` pada manifes harus berupa pemetaan atau objek. manifest.vars.reserved_name = Kunci `vars` '{ $name }' pada manifes dicadangkan untuk fungsi bantu templat bawaan; ganti nama variabel tersebut. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Lapisan templat '{ $name }' bertabrakan dengan ruang nama { $kind }; ganti nama lapisan tersebut. manifest.read_failed = Manifes di { $path } tidak dapat dibaca. manifest.resolve_workspace_root = Akar ruang kerja tidak dapat ditentukan. manifest.workspace_non_utf8 = Jalur akar ruang kerja "{ $path }" bukan UTF-8 yang sah. @@ -297,6 +298,10 @@ stdlib.command.output.mode.streaming = penstriman stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } tidak tersedia di bawah `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index b7ed41bdc..e8dc0e0c7 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -148,6 +148,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Il default di env deve essere una stringa, ricevuto { $kind }. manifest.vars.not_object = `vars` del manifest deve essere una mappa o un oggetto. manifest.vars.reserved_name = La chiave `vars` '{ $name }' del manifest è riservata a una funzione di supporto per i template integrata; rinomina la variabile. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] La sovrapposizione del template '{ $name }' è in conflitto con lo spazio dei nomi { $kind }; rinomina la sovrapposizione. manifest.read_failed = Impossibile leggere il manifest in { $path }. manifest.resolve_workspace_root = Impossibile risolvere la radice dell'area di lavoro. manifest.workspace_non_utf8 = Il percorso radice dell'area di lavoro «{ $path }» non è UTF-8 valido. @@ -299,6 +300,10 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } non è disponibile con `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index eed14edc0..3510404f7 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = env の default は文字列でなければなりません。{ $kind } を受け取りました。 manifest.vars.not_object = マニフェストの `vars` はマップまたはオブジェクトでなければなりません。 manifest.vars.reserved_name = マニフェストの `vars` キー '{ $name }' は組み込みのテンプレートヘルパー用に予約されています。変数名を変更してください。 +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] テンプレートオーバーレイ '{ $name }' は { $kind } 名前空間と衝突します。オーバーレイ名を変更してください。 manifest.read_failed = { $path } のマニフェストを読み取れませんでした。 manifest.resolve_workspace_root = ワークスペースのルートを特定できませんでした。 manifest.workspace_non_utf8 = ワークスペースのルートパス「{ $path }」は有効な UTF-8 ではありません。 @@ -297,6 +298,10 @@ stdlib.command.output.mode.streaming = ストリーミング stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } は `netsuke test` では使用できません。 + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index d841e9d68..6938f3357 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = env의 default는 문자열이어야 합니다. { $kind }을(를) 받았습니다. manifest.vars.not_object = 매니페스트의 `vars`는 매핑이나 객체여야 합니다. manifest.vars.reserved_name = 매니페스트의 `vars` 키 '{ $name }'은(는) 내장 템플릿 헬퍼용으로 예약되어 있습니다. 변수 이름을 바꾸십시오. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] 템플릿 오버레이 '{ $name }'이(가) { $kind } 네임스페이스와 충돌합니다. 오버레이 이름을 바꾸십시오. manifest.read_failed = { $path }의 매니페스트를 읽지 못했습니다. manifest.resolve_workspace_root = 작업 공간의 루트를 확인하지 못했습니다. manifest.workspace_non_utf8 = 작업 공간의 루트 경로 '{ $path }'은(는) 올바른 UTF-8이 아닙니다. @@ -297,6 +298,10 @@ stdlib.command.output.mode.streaming = 스트리밍 stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation }은(는) `netsuke test`에서 사용할 수 없습니다. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index d43e872d5..0fc1212d8 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = default i env må være en streng, mottok { $kind }. manifest.vars.not_object = `vars` i manifestet må være en tilordning eller et objekt. manifest.vars.reserved_name = Manifestets `vars`-nøkkel '{ $name }' er reservert for en innebygd malhjelper; gi variabelen et nytt navn. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Maloverlegget '{ $name }' kolliderer med navnerommet { $kind }; gi overlegget et nytt navn. manifest.read_failed = Manifestet i { $path } kunne ikke leses. manifest.resolve_workspace_root = Roten til arbeidsområdet kunne ikke bestemmes. manifest.workspace_non_utf8 = Rotstien til arbeidsområdet «{ $path }» er ikke gyldig UTF-8. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = strømming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } er ikke tilgjengelig under `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index 76e26024a..86da4412c 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = De default van env moet een tekenreeks zijn, ontvangen { $kind }. manifest.vars.not_object = De `vars` van het manifest moet een toewijzing of object zijn. manifest.vars.reserved_name = De `vars`-sleutel '{ $name }' in het manifest is gereserveerd voor een ingebouwde sjabloonfunctie; hernoem de variabele. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] De sjabloonoverlay '{ $name }' botst met de naamruimte { $kind }; hernoem de overlay. manifest.read_failed = Het manifest in { $path } kon niet worden gelezen. manifest.resolve_workspace_root = De hoofdmap van de werkruimte kon niet worden bepaald. manifest.workspace_non_utf8 = Het hoofdpad van de werkruimte ‘{ $path }’ is geen geldige UTF-8. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = streamen stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } is niet beschikbaar onder `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index 16540b6a6..c9e85900c 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Wartość default w env musi być łańcuchem znaków; typ otrzymanej wartości: { $kind }. manifest.vars.not_object = Pole `vars` manifestu musi być odwzorowaniem lub obiektem. manifest.vars.reserved_name = Klucz `vars` '{ $name }' w manifeście jest zarezerwowany dla wbudowanej funkcji pomocniczej szablonów; zmień nazwę zmiennej. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Nakładka szablonu '{ $name }' koliduje z przestrzenią nazw { $kind }; zmień nazwę nakładki. manifest.read_failed = Nie udało się odczytać manifestu z { $path }. manifest.resolve_workspace_root = Nie udało się ustalić katalogu głównego obszaru roboczego. manifest.workspace_non_utf8 = Ścieżka główna obszaru roboczego „{ $path }” nie jest prawidłowym UTF-8. @@ -300,6 +301,10 @@ stdlib.command.output.mode.streaming = strumieniowanie stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } jest niedostępne w `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index d587ab3f1..f32493e72 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -148,6 +148,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = O default de env deve ser uma string, recebido { $kind }. manifest.vars.not_object = `vars` do manifesto deve ser um mapa ou objeto. manifest.vars.reserved_name = A chave `vars` '{ $name }' do manifesto está reservada para uma função auxiliar de template integrada; renomeie a variável. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] A sobreposição de template '{ $name }' entra em conflito com o espaço de nomes { $kind }; renomeie a sobreposição. manifest.read_failed = Não foi possível ler o manifesto em { $path }. manifest.resolve_workspace_root = Não foi possível resolver a raiz do workspace. manifest.workspace_non_utf8 = O caminho da raiz do workspace "{ $path }" não é UTF-8 válido. @@ -299,6 +300,10 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } não está disponível em `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 33a468f77..72bc48302 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -148,6 +148,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = O default de env tem de ser uma cadeia de carateres, recebido { $kind }. manifest.vars.not_object = `vars` do manifesto tem de ser um mapa ou objeto. manifest.vars.reserved_name = A chave `vars` '{ $name }' do manifesto está reservada a uma função auxiliar de modelo integrada; mude o nome da variável. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] A sobreposição de modelo '{ $name }' entra em conflito com o espaço de nomes { $kind }; mude o nome da sobreposição. manifest.read_failed = Não foi possível ler o manifesto em { $path }. manifest.resolve_workspace_root = Não foi possível resolver a raiz da área de trabalho. manifest.workspace_non_utf8 = O caminho de raiz da área de trabalho «{ $path }» não é UTF-8 válido. @@ -299,6 +300,10 @@ stdlib.command.output.mode.streaming = fluxo contínuo stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } não está disponível em `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 8162f15e7..f150a29d8 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Valoarea default din env trebuie să fie un șir de caractere, s-a primit { $kind }. manifest.vars.not_object = Câmpul `vars` al manifestului trebuie să fie o mapare sau un obiect. manifest.vars.reserved_name = Cheia `vars` '{ $name }' din manifest este rezervată pentru o funcție ajutătoare de șabloane integrată; redenumiți variabila. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Suprapunerea de șablon '{ $name }' intră în conflict cu spațiul de nume { $kind }; redenumiți suprapunerea. manifest.read_failed = Manifestul din { $path } nu a putut fi citit. manifest.resolve_workspace_root = Rădăcina spațiului de lucru nu a putut fi determinată. manifest.workspace_non_utf8 = Calea rădăcină a spațiului de lucru „{ $path }” nu este UTF-8 valid. @@ -299,6 +300,10 @@ stdlib.command.output.mode.streaming = flux continuu stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } nu este disponibil în `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 994a7d0b4..2b34cfb54 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Значение default в env должно быть строкой, получено { $kind }. manifest.vars.not_object = Поле `vars` манифеста должно быть отображением или объектом. manifest.vars.reserved_name = Ключ `vars` '{ $name }' в манифесте зарезервирован для встроенной вспомогательной функции шаблонов; переименуйте переменную. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Наложение шаблона '{ $name }' конфликтует с пространством имён { $kind }; переименуйте наложение. manifest.read_failed = Не удалось прочитать манифест по пути { $path }. manifest.resolve_workspace_root = Не удалось определить корень рабочего пространства. manifest.workspace_non_utf8 = Корневой путь рабочего пространства «{ $path }» не является корректным UTF-8. @@ -300,6 +301,10 @@ stdlib.command.output.mode.streaming = потоковая передача stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } недоступен в `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index 01918d858..c516c7a7f 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = default i env måste vara en sträng, tog emot { $kind }. manifest.vars.not_object = Manifestets `vars` måste vara en mappning eller ett objekt. manifest.vars.reserved_name = Manifestets `vars`-nyckel '{ $name }' är reserverad för en inbyggd mallhjälpare; byt namn på variabeln. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Mallöverlägget '{ $name }' krockar med namnutrymmet { $kind }; byt namn på överlägget. manifest.read_failed = Manifestet i { $path } kunde inte läsas. manifest.resolve_workspace_root = Arbetsytans rot kunde inte fastställas. manifest.workspace_non_utf8 = Arbetsytans rotsökväg ”{ $path }” är inte giltig UTF-8. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = strömning stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } är inte tillgänglig under `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 632cd7490..47a3deeda 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = ค่า default ของ env ต้องเป็นสายอักขระ แต่ได้รับ { $kind } manifest.vars.not_object = `vars` ของไฟล์รายการต้องเป็นการจับคู่หรือวัตถุ manifest.vars.reserved_name = คีย์ `vars` '{ $name }' ของมานิเฟสต์ถูกสงวนไว้สำหรับฟังก์ชันช่วยเทมเพลตในตัว โปรดเปลี่ยนชื่อตัวแปร +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] โอเวอร์เลย์เทมเพลต '{ $name }' ขัดแย้งกับเนมสเปซ { $kind } โปรดเปลี่ยนชื่อโอเวอร์เลย์ manifest.read_failed = อ่านไฟล์รายการที่ { $path } ไม่สำเร็จ manifest.resolve_workspace_root = ระบุรากของพื้นที่ทำงานไม่สำเร็จ manifest.workspace_non_utf8 = เส้นทางรากของพื้นที่ทำงาน “{ $path }” ไม่ใช่ UTF-8 ที่ถูกต้อง @@ -297,6 +298,10 @@ stdlib.command.output.mode.streaming = การส่งเป็นสาย stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } ไม่พร้อมใช้งานภายใต้ `netsuke test` + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index f71b472a9..73c402125 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = env default değeri bir dizge olmalıdır, { $kind } alındı. manifest.vars.not_object = Bildirimin `vars` alanı bir eşleme ya da nesne olmalıdır. manifest.vars.reserved_name = Manifestteki `vars` anahtarı '{ $name }' yerleşik bir şablon yardımcı işlevi için ayrılmıştır; değişkeni yeniden adlandırın. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Şablon kaplaması '{ $name }', { $kind } ad alanıyla çakışıyor; kaplamayı yeniden adlandırın. manifest.read_failed = { $path } konumundaki bildirim okunamadı. manifest.resolve_workspace_root = Çalışma alanının kökü belirlenemedi. manifest.workspace_non_utf8 = "{ $path }" çalışma alanı kök yolu geçerli UTF-8 değil. @@ -298,6 +299,10 @@ stdlib.command.output.mode.streaming = akış stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } `netsuke test` altında kullanılamaz. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 64eef4b5f..8bae729a3 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Значення default у env має бути рядком, отримано { $kind }. manifest.vars.not_object = Поле `vars` маніфесту має бути відображенням або об’єктом. manifest.vars.reserved_name = Ключ `vars` '{ $name }' у маніфесті зарезервовано для вбудованої допоміжної функції шаблонів; перейменуйте змінну. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Накладення шаблону '{ $name }' конфліктує з простором імен { $kind }; перейменуйте накладення. manifest.read_failed = Не вдалося прочитати маніфест за шляхом { $path }. manifest.resolve_workspace_root = Не вдалося визначити корінь робочої області. manifest.workspace_non_utf8 = Кореневий шлях робочої області «{ $path }» не є коректним UTF-8. @@ -300,6 +301,10 @@ stdlib.command.output.mode.streaming = потокова передача stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } недоступний у `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index 152b187e4..b53faf8f2 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -147,6 +147,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = Giá trị default của env phải là chuỗi, nhưng nhận được { $kind }. manifest.vars.not_object = Trường `vars` của tệp kê khai phải là ánh xạ hoặc đối tượng. manifest.vars.reserved_name = Khóa `vars` '{ $name }' của tệp kê khai được dành riêng cho hàm trợ giúp mẫu tích hợp; hãy đổi tên biến. +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] Lớp phủ mẫu '{ $name }' xung đột với không gian tên { $kind }; hãy đổi tên lớp phủ. manifest.read_failed = Không đọc được tệp kê khai tại { $path }. manifest.resolve_workspace_root = Không xác định được gốc của không gian làm việc. manifest.workspace_non_utf8 = Đường dẫn gốc của không gian làm việc “{ $path }” không phải UTF-8 hợp lệ. @@ -297,6 +298,10 @@ stdlib.command.output.mode.streaming = truyền luồng stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } không khả dụng trong `netsuke test`. + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index 29dc28b77..15f84353d 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -146,6 +146,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = env 的 default 必须是字符串,但收到了 { $kind }。 manifest.vars.not_object = 清单的 `vars` 必须是映射或对象。 manifest.vars.reserved_name = 清单的 `vars` 键 '{ $name }' 已保留给内置模板辅助函数;请重命名该变量。 +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] 模板覆盖 '{ $name }' 与 { $kind } 命名空间冲突;请重命名该覆盖。 manifest.read_failed = 无法读取 { $path } 处的清单。 manifest.resolve_workspace_root = 无法确定工作区根目录。 manifest.workspace_non_utf8 = 工作区根路径“{ $path }”不是有效的 UTF-8。 @@ -296,6 +297,10 @@ stdlib.command.output.mode.streaming = 流式 stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } 在 `netsuke test` 下不可用。 + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index 01a102d6c..3c90d87ee 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -146,6 +146,7 @@ manifest.env.args_error = [netsuke::jinja::env::args] { $details } manifest.env.default_not_string = env 的 default 必須是字串,但收到 { $kind }。 manifest.vars.not_object = 資訊清單的 `vars` 必須是對應或物件。 manifest.vars.reserved_name = 清單的 `vars` 鍵 '{ $name }' 已保留給內建範本輔助函式;請重新命名該變數。 +manifest.overlay.name_collision = [netsuke::manifest::overlay::collision] 範本覆蓋 '{ $name }' 與 { $kind } 命名空間衝突;請重新命名該覆蓋。 manifest.read_failed = 無法讀取 { $path } 的資訊清單。 manifest.resolve_workspace_root = 無法判定工作區的根目錄。 manifest.workspace_non_utf8 = 工作區根路徑「{ $path }」不是有效的 UTF-8。 @@ -296,6 +297,10 @@ stdlib.command.output.mode.streaming = 串流 stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Test-mode helper refusals. +stdlib.test_mode.helper_unavailable = [netsuke::jinja::test_mode::unavailable] { $operation } 在 `netsuke test` 下無法使用。 + + # Recipe-text shell quoting diagnostics. stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } diff --git a/src/localization/keys.rs b/src/localization/keys.rs index 6b3f77b42..fa42d532b 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -2,13 +2,15 @@ //! //! This file holds the general key table: the build-time localization audit //! parses its `define_keys!` invocation, and the macro itself is defined in -//! the parent module. The `netsuke check` keys live in `check_keys.rs` and -//! the clap error keys in `clap_error_keys.rs`; both are re-exported here, so -//! every key is still reached as `keys::NAME`. +//! the parent module. The `netsuke check` keys live in `check_keys.rs`, the +//! clap error keys in `clap_error_keys.rs`, and the manifest keys in +//! `manifest_keys.rs`; all are re-exported here, so every key is still reached +//! as `keys::NAME`. pub use super::check_keys::*; pub use super::clap_error_keys::*; -// The union of all three tables lives in the parent module, where the line +pub use super::manifest_keys::*; +// The union of all four tables lives in the parent module, where the line // budget allows it; re-exported here because `keys` is the name callers use. pub use super::ALL_KEYS; @@ -113,58 +115,6 @@ define_keys! { RUNNER_IO_DYNDEP_TEMP_COLLISIONS => "runner.io.dyndep.temp_collisions", RUNNER_IO_DYNDEP_TOO_LARGE => "runner.io.dyndep.too_large", RUNNER_IO_DYNDEP_RETENTION => "runner.io.dyndep.retention", - MANIFEST_PARSE => "manifest.parse", - MANIFEST_STRUCTURE_ERROR => "manifest.structure_error", - MANIFEST_YAML_PARSE => "manifest.yaml.parse", - MANIFEST_YAML_LABEL => "manifest.yaml.label", - MANIFEST_YAML_HINT_TABS => "manifest.yaml.hint.tabs", - MANIFEST_YAML_HINT_LIST_ITEM => "manifest.yaml.hint.list_item", - MANIFEST_YAML_HINT_EXPECTED_COLON => "manifest.yaml.hint.expected_colon", - MANIFEST_YAML_HINT_MAPPING_VALUES => "manifest.yaml.hint.mapping_values", - MANIFEST_YAML_HINT_INVALID_TOKEN => "manifest.yaml.hint.invalid_token", - MANIFEST_YAML_HINT_ESCAPE => "manifest.yaml.hint.escape", - MANIFEST_ENV_MISSING => "manifest.env.missing", - MANIFEST_ENV_INVALID_UTF8 => "manifest.env.invalid_utf8", - MANIFEST_ENV_BLOCKED => "manifest.env.blocked", - MANIFEST_ENV_ARGS_ERROR => "manifest.env.args_error", - MANIFEST_ENV_DEFAULT_NOT_STRING => "manifest.env.default_not_string", - MANIFEST_VARS_NOT_OBJECT => "manifest.vars.not_object", - MANIFEST_VARS_RESERVED_NAME => "manifest.vars.reserved_name", - MANIFEST_READ_FAILED => "manifest.read_failed", - MANIFEST_RESOLVE_WORKSPACE_ROOT => "manifest.resolve_workspace_root", - MANIFEST_WORKSPACE_NON_UTF8 => "manifest.workspace_non_utf8", - MANIFEST_PATH_NON_UTF8 => "manifest.path_non_utf8", - MANIFEST_PATH_MISSING_NAME => "manifest.path_missing_name", - MANIFEST_OPEN_WORKSPACE_FAILED => "manifest.open_workspace_failed", - MANIFEST_FOREACH_NOT_ITERABLE => "manifest.foreach.not_iterable", - MANIFEST_FOREACH_SERIALISE_ITEM => "manifest.foreach.serialise_item", - MANIFEST_BUDGET_EXCEEDED => "manifest.budget.exceeded", - MANIFEST_WHEN_EMPTY => "manifest.when.empty", - MANIFEST_WHEN_EVAL_ERROR => "manifest.when.eval_error", - MANIFEST_WHEN_TEMPLATE_ERROR => "manifest.when.template_error", - MANIFEST_TARGET_VARS_NOT_OBJECT => "manifest.target.vars_not_object", - MANIFEST_VARS_ENTRY_NOT_OBJECT => "manifest.vars.entry_not_object", - MANIFEST_FIELD_NOT_STRING => "manifest.field_not_string", - MANIFEST_EXPRESSION_PARSE_ERROR => "manifest.expression.parse_error", - MANIFEST_EXPRESSION_EVAL_ERROR => "manifest.expression.eval_error", - MANIFEST_MACRO_SIGNATURE_MISSING_IDENTIFIER => "manifest.macro.signature_missing_identifier", - MANIFEST_MACRO_SIGNATURE_MISSING_PARAMS => "manifest.macro.signature_missing_params", - MANIFEST_MACRO_COMPILE_FAILED => "manifest.macro.compile_failed", - MANIFEST_MACRO_SEQUENCE_INVALID => "manifest.macro.sequence_invalid", - MANIFEST_MACRO_REGISTER_FAILED => "manifest.macro.register_failed", - MANIFEST_MACRO_NOT_INITIALISED => "manifest.macro.not_initialised", - MANIFEST_MACRO_CALLER_INVALID => "manifest.macro.caller_invalid", - MANIFEST_MACRO_TEMPLATE_LOAD_FAILED => "manifest.macro.template_load_failed", - MANIFEST_MACRO_INIT_FAILED => "manifest.macro.init_failed", - MANIFEST_MACRO_MISSING => "manifest.macro.missing", - MANIFEST_GLOB_UNMATCHED_BRACE => "manifest.glob.unmatched_brace", - MANIFEST_GLOB_INVALID_PATTERN => "manifest.glob.invalid_pattern", - MANIFEST_GLOB_UNKNOWN_PATTERN_ERROR => "manifest.glob.unknown_pattern_error", - MANIFEST_GLOB_IO_FAILED => "manifest.glob.io_failed", - MANIFEST_GLOB_UNKNOWN_IO_ERROR => "manifest.glob.unknown_io_error", - MANIFEST_COMMAND_LIST_EMPTY => "manifest.command_list_empty", - MANIFEST_COMMAND_ISOLATION_RECIPE => "manifest.command_isolation_recipe", - MANIFEST_COMMAND_ISOLATION_SCALAR => "manifest.command_isolation_scalar", IR_RULE_NOT_FOUND => "ir.rule_not_found", IR_MULTIPLE_RULES => "ir.multiple_rules", IR_EMPTY_RULE => "ir.empty_rule", @@ -212,6 +162,7 @@ define_keys! { STDLIB_CONFIG_OPEN_WORKSPACE_ROOT => "stdlib.config.open_workspace_root", STDLIB_CONFIG_RESOLVE_CWD => "stdlib.config.resolve_cwd", STDLIB_CONFIG_CWD_NON_UTF8 => "stdlib.config.cwd_non_utf8", + STDLIB_TEST_MODE_HELPER_UNAVAILABLE => "stdlib.test_mode.helper_unavailable", STDLIB_FETCH_URL_INVALID => "stdlib.fetch.url_invalid", STDLIB_FETCH_DISALLOWED => "stdlib.fetch.disallowed", STDLIB_FETCH_FAILED => "stdlib.fetch.failed", diff --git a/src/localization/manifest_keys.rs b/src/localization/manifest_keys.rs new file mode 100644 index 000000000..1c5f509fc --- /dev/null +++ b/src/localization/manifest_keys.rs @@ -0,0 +1,62 @@ +//! Fluent message identifiers used while loading and validating manifests. +//! +//! Split from `keys.rs`, which sits at Whitaker's 400-line module cap, the same +//! way `check_keys.rs` is. The table keeps the same shape, so the build-time +//! localization audit reads it the same way, and `keys` re-exports every +//! constant, so callers still write `keys::MANIFEST_PARSE`. + +define_keys! { + MANIFEST_PARSE => "manifest.parse", + MANIFEST_STRUCTURE_ERROR => "manifest.structure_error", + MANIFEST_YAML_PARSE => "manifest.yaml.parse", + MANIFEST_YAML_LABEL => "manifest.yaml.label", + MANIFEST_YAML_HINT_TABS => "manifest.yaml.hint.tabs", + MANIFEST_YAML_HINT_LIST_ITEM => "manifest.yaml.hint.list_item", + MANIFEST_YAML_HINT_EXPECTED_COLON => "manifest.yaml.hint.expected_colon", + MANIFEST_YAML_HINT_MAPPING_VALUES => "manifest.yaml.hint.mapping_values", + MANIFEST_YAML_HINT_INVALID_TOKEN => "manifest.yaml.hint.invalid_token", + MANIFEST_YAML_HINT_ESCAPE => "manifest.yaml.hint.escape", + MANIFEST_ENV_MISSING => "manifest.env.missing", + MANIFEST_ENV_INVALID_UTF8 => "manifest.env.invalid_utf8", + MANIFEST_ENV_BLOCKED => "manifest.env.blocked", + MANIFEST_ENV_ARGS_ERROR => "manifest.env.args_error", + MANIFEST_ENV_DEFAULT_NOT_STRING => "manifest.env.default_not_string", + MANIFEST_VARS_NOT_OBJECT => "manifest.vars.not_object", + MANIFEST_VARS_RESERVED_NAME => "manifest.vars.reserved_name", + MANIFEST_OVERLAY_NAME_COLLISION => "manifest.overlay.name_collision", + MANIFEST_READ_FAILED => "manifest.read_failed", + MANIFEST_RESOLVE_WORKSPACE_ROOT => "manifest.resolve_workspace_root", + MANIFEST_WORKSPACE_NON_UTF8 => "manifest.workspace_non_utf8", + MANIFEST_PATH_NON_UTF8 => "manifest.path_non_utf8", + MANIFEST_PATH_MISSING_NAME => "manifest.path_missing_name", + MANIFEST_OPEN_WORKSPACE_FAILED => "manifest.open_workspace_failed", + MANIFEST_FOREACH_NOT_ITERABLE => "manifest.foreach.not_iterable", + MANIFEST_FOREACH_SERIALISE_ITEM => "manifest.foreach.serialise_item", + MANIFEST_BUDGET_EXCEEDED => "manifest.budget.exceeded", + MANIFEST_WHEN_EMPTY => "manifest.when.empty", + MANIFEST_WHEN_EVAL_ERROR => "manifest.when.eval_error", + MANIFEST_WHEN_TEMPLATE_ERROR => "manifest.when.template_error", + MANIFEST_TARGET_VARS_NOT_OBJECT => "manifest.target.vars_not_object", + MANIFEST_VARS_ENTRY_NOT_OBJECT => "manifest.vars.entry_not_object", + MANIFEST_FIELD_NOT_STRING => "manifest.field_not_string", + MANIFEST_EXPRESSION_PARSE_ERROR => "manifest.expression.parse_error", + MANIFEST_EXPRESSION_EVAL_ERROR => "manifest.expression.eval_error", + MANIFEST_MACRO_SIGNATURE_MISSING_IDENTIFIER => "manifest.macro.signature_missing_identifier", + MANIFEST_MACRO_SIGNATURE_MISSING_PARAMS => "manifest.macro.signature_missing_params", + MANIFEST_MACRO_COMPILE_FAILED => "manifest.macro.compile_failed", + MANIFEST_MACRO_SEQUENCE_INVALID => "manifest.macro.sequence_invalid", + MANIFEST_MACRO_REGISTER_FAILED => "manifest.macro.register_failed", + MANIFEST_MACRO_NOT_INITIALISED => "manifest.macro.not_initialised", + MANIFEST_MACRO_CALLER_INVALID => "manifest.macro.caller_invalid", + MANIFEST_MACRO_TEMPLATE_LOAD_FAILED => "manifest.macro.template_load_failed", + MANIFEST_MACRO_INIT_FAILED => "manifest.macro.init_failed", + MANIFEST_MACRO_MISSING => "manifest.macro.missing", + MANIFEST_GLOB_UNMATCHED_BRACE => "manifest.glob.unmatched_brace", + MANIFEST_GLOB_INVALID_PATTERN => "manifest.glob.invalid_pattern", + MANIFEST_GLOB_UNKNOWN_PATTERN_ERROR => "manifest.glob.unknown_pattern_error", + MANIFEST_GLOB_IO_FAILED => "manifest.glob.io_failed", + MANIFEST_GLOB_UNKNOWN_IO_ERROR => "manifest.glob.unknown_io_error", + MANIFEST_COMMAND_LIST_EMPTY => "manifest.command_list_empty", + MANIFEST_COMMAND_ISOLATION_RECIPE => "manifest.command_isolation_recipe", + MANIFEST_COMMAND_ISOLATION_SCALAR => "manifest.command_isolation_scalar", +} diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 0db4b7611..9cb73ce64 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -16,13 +16,17 @@ macro_rules! define_keys { /// Every Fluent message key this table declares, in declaration order. /// /// Each table carries its own slice because the key set is split - /// across three files; `keys::ALL_KEYS` is the union. + /// across four files; `ALL_KEYS` is the union. pub const TABLE_KEYS: &[&str] = &[$($name),+]; }; } mod check_keys; + mod clap_error_keys; + +mod manifest_keys; + pub mod keys; /// The locale registry, which lives at the crate root so that it depends on @@ -38,11 +42,11 @@ use std::sync::{Arc, OnceLock, RwLock}; /// All Fluent message keys referenced by Netsuke. /// /// Each table publishes its own `TABLE_KEYS` slice, and this is their union. -/// The split into `check_keys` and `clap_error_keys` exists only to keep every -/// module inside Whitaker's line cap, so a key moved into either table has to -/// stay in this set — which is why the union is assembled from the three -/// slices rather than shadowed by whichever `define_keys!` runs last. It lives -/// here rather than in `keys` because no single table owns it. +/// The split into `check_keys`, `clap_error_keys`, and `manifest_keys` exists +/// only to keep every module inside Whitaker's line cap, so a key moved into +/// any table has to stay in this set — which is why the union is assembled +/// from the table slices rather than shadowed by whichever `define_keys!` runs +/// last. It lives here rather than in `keys` because no single table owns it. /// /// The union is assembled at compile time, so the type stays the plain slice /// the single table used to publish: callers can still take `ALL_KEYS` as a @@ -50,14 +54,16 @@ use std::sync::{Arc, OnceLock, RwLock}; /// `Vec`. pub const ALL_KEYS: &[&str] = &ALL_KEYS_ARRAY; -/// Number of keys in [`ALL_KEYS`], summed from the three tables. -const ALL_KEYS_LEN: usize = - keys::TABLE_KEYS.len() + check_keys::TABLE_KEYS.len() + clap_error_keys::TABLE_KEYS.len(); +/// Number of keys in [`ALL_KEYS`], summed from every table. +const ALL_KEYS_LEN: usize = keys::TABLE_KEYS.len() + + check_keys::TABLE_KEYS.len() + + clap_error_keys::TABLE_KEYS.len() + + manifest_keys::TABLE_KEYS.len(); /// Every declared key, concatenated in table order. const ALL_KEYS_ARRAY: [&str; ALL_KEYS_LEN] = assemble_all_keys(); -/// Concatenate the three key tables into one array at compile time. +/// Concatenate the key tables into one array at compile time. /// /// Slices cannot be joined in a constant, so the tables are copied element by /// element. A `const fn` is what keeps the published type unchanged; deferring @@ -66,13 +72,14 @@ const fn assemble_all_keys() -> [&'static str; ALL_KEYS_LEN] { let mut all = [""; ALL_KEYS_LEN]; let after_general = append_keys(&mut all, 0, keys::TABLE_KEYS); let after_check = append_keys(&mut all, after_general, check_keys::TABLE_KEYS); - let _ = append_keys(&mut all, after_check, clap_error_keys::TABLE_KEYS); + let after_clap = append_keys(&mut all, after_check, clap_error_keys::TABLE_KEYS); + let _ = append_keys(&mut all, after_clap, manifest_keys::TABLE_KEYS); all } /// Copy `table` into `all` from `start`, returning the next free index. /// -/// The destination is sized as the sum of the three tables, so every index +/// The destination is sized as the sum of the tables, so every index /// this writes is in bounds. Indexing is nonetheless the only form that works /// here: `slice::get` and `slice::get_mut` are not yet `const`, and a /// `LazyLock` alternative would change the published type of [`ALL_KEYS`], @@ -106,8 +113,9 @@ static LOCALIZER: OnceLock>> = OnceLock::new(); fn localizer_storage() -> &'static RwLock> { // Keep the key registry referenced so dead-code lints do not discard it. let _ = keys::ALL_KEYS; - let _ = check_keys::TABLE_KEYS; let _ = clap_error_keys::TABLE_KEYS; + let _ = check_keys::TABLE_KEYS; + let _ = manifest_keys::TABLE_KEYS; LOCALIZER.get_or_init(|| { let default = crate::cli_localization::build_localizer(None); RwLock::new(Arc::from(default)) @@ -156,6 +164,7 @@ pub fn set_localizer_for_tests(new_localizer: Arc) -> LocalizerGu // Compile-time assertions that the public setters keep their signatures. /// Compile-time assertion that `set_localizer` keeps its signature. const _: fn(Arc) = set_localizer; + /// Compile-time assertion that `set_localizer_for_tests` keeps its signature. const _: fn(Arc) -> LocalizerGuard = set_localizer_for_tests; @@ -264,12 +273,12 @@ pub const fn message(key: &'static str) -> LocalizedMessage { mod tests { //! Guards on the key aggregates this module assembles. - use super::{ALL_KEYS, check_keys, clap_error_keys, keys}; + use super::{ALL_KEYS, check_keys, clap_error_keys, keys, manifest_keys}; // A key moved out of `keys.rs` to keep that module inside the line cap // used to be listed by the aggregate automatically. Comparing against the - // three table slices rather than a written out list keeps the assertion - // true when any table gains a key. + // table slices rather than a written out list keeps the assertion true + // when any table gains a key. #[test] fn all_keys_is_the_union_of_every_table() { let all = ALL_KEYS; @@ -278,11 +287,13 @@ mod tests { keys::TABLE_KEYS.len() + check_keys::TABLE_KEYS.len() + clap_error_keys::TABLE_KEYS.len() + + manifest_keys::TABLE_KEYS.len() ); for expected in keys::TABLE_KEYS .iter() .chain(check_keys::TABLE_KEYS) .chain(clap_error_keys::TABLE_KEYS) + .chain(manifest_keys::TABLE_KEYS) { assert!( all.contains(expected), diff --git a/src/manifest/budget/adapter.rs b/src/manifest/budget/adapter.rs index ec343fd2b..ccd2f148e 100644 --- a/src/manifest/budget/adapter.rs +++ b/src/manifest/budget/adapter.rs @@ -6,17 +6,17 @@ use crate::localization::{self, LocalizedMessage, keys}; use minijinja::{Error, ErrorKind}; /// Evaluate a manifest and record budget failures only for full loading. +/// +/// Discovery queries and test-surface loads do not contribute to the usage +/// telemetry these counters describe, so the registration decides whether a +/// failure is recorded at all (ADR-0050 Addendum E). pub(in crate::manifest) fn from_str_named( yaml: &str, - parse: super::super::ManifestParse<'_>, - on_stage: &mut Option<&mut dyn FnMut(super::super::ManifestLoadStage)>, + parse: super::super::ManifestParse<'_, '_>, ) -> anyhow::Result { - let is_query = matches!( - parse.stdlib_registration, - Some(super::super::StdlibRegistration::ManifestQuery) - ); - let result = super::super::evaluate_manifest(yaml, parse, on_stage); - if !is_query && let Err(error) = &result { + let records_telemetry = parse.registration.records_expansion_and_budget_telemetry(); + let result = super::super::evaluate_manifest(yaml, parse); + if records_telemetry && let Err(error) = &result { record_exhaustion(error); } result diff --git a/src/manifest/env/reader.rs b/src/manifest/env/reader.rs index 131ff721e..683dba50e 100644 --- a/src/manifest/env/reader.rs +++ b/src/manifest/env/reader.rs @@ -62,7 +62,9 @@ pub type EnvReader = Arc Result + Send + S /// /// On-disk manifest loaders consume this bundle at their Jinja boundary. It /// keeps the reader and the policy together, so callers cannot accidentally -/// inject a reader without also making the applicable access decision. +/// inject a reader without also making the applicable access decision. It is +/// cheap to clone: the reader is shared and the policy is small. +#[derive(Clone)] pub struct ManifestEnvironment<'a> { /// Supplies values to the Jinja `env()` helper after policy evaluation. reader: &'a EnvReader, diff --git a/src/manifest/expand/evaluation.rs b/src/manifest/expand/evaluation.rs index ede151f55..5c0c95873 100644 --- a/src/manifest/expand/evaluation.rs +++ b/src/manifest/expand/evaluation.rs @@ -108,11 +108,15 @@ fn eval_when( "{}", localization::message(keys::MANIFEST_WHEN_EMPTY) ); + let boundary = super::super::jinja_macros::EvaluationBoundary { + budget: expansion.budget, + classifies_query_refusals: expansion.classifies_query_refusals, + }; if let Some(evaluation) = super::super::jinja_macros::evaluate_when_expression( expansion.env, expr, context, - expansion.budget, + boundary, )? { return Ok(match evaluation { super::super::jinja_macros::QueryEvaluation::Value(is_true) => when_evaluation(is_true), @@ -125,7 +129,7 @@ fn eval_when( expansion.env, expr, context, - expansion.budget, + boundary, )? { super::super::jinja_macros::QueryEvaluation::Value(output) => output, super::super::jinja_macros::QueryEvaluation::QueryDisabled => { diff --git a/src/manifest/expand/mod.rs b/src/manifest/expand/mod.rs index 34b562a2a..82fd930bf 100644 --- a/src/manifest/expand/mod.rs +++ b/src/manifest/expand/mod.rs @@ -93,6 +93,8 @@ struct ExpansionContext<'a> { budget: &'a ManifestBudget, /// Name of the manifest section being expanded, such as `targets`. section: &'a str, + /// Whether a refused helper marks the entry conditional. + classifies_query_refusals: bool, } /// Decides how manifest discovery should handle an evaluated `when` expression. @@ -129,14 +131,22 @@ pub(crate) fn expand_foreach( env: &Environment, ) -> Result { let budget = ManifestBudget::default(); - expand_foreach_with_budget(doc, env, &budget) + // The suite builds both complete and manifest-query environments here, and + // only a query surface can emit the refusal marker, so classifying it is + // inert for the complete ones. + expand_foreach_with_budget(doc, env, &budget, true) } /// Expand targets and actions with the caller's shared manifest resource budget. +/// +/// `classifies_query_refusals` comes from the load's registration: a discovery +/// query turns a refused helper into a conditional entry, while a full load +/// propagates it as a failure. pub(crate) fn expand_foreach_with_budget( doc: &mut ManifestValue, env: &Environment, budget: &ManifestBudget, + classifies_query_refusals: bool, ) -> Result { let mut report = ExpansionReport::default(); for section in ["targets", "actions"] { @@ -144,6 +154,7 @@ pub(crate) fn expand_foreach_with_budget( env, budget, section, + classifies_query_refusals, }; expand_section(doc, &context, &mut report)?; } diff --git a/src/manifest/expand/tests/foreach/laziness_cases.rs b/src/manifest/expand/tests/foreach/laziness_cases.rs index c4c75eaa5..7579d4da6 100644 --- a/src/manifest/expand/tests/foreach/laziness_cases.rs +++ b/src/manifest/expand/tests/foreach/laziness_cases.rs @@ -86,7 +86,7 @@ fn foreach_stops_consuming_before_the_cardinality_limit() -> Result<()> { }; let budget = ManifestBudget::new(limits)?; - let error = expand_foreach_with_budget(&mut doc, &env, &budget) + let error = expand_foreach_with_budget(&mut doc, &env, &budget, true) .expect_err("the third value must exceed the per-foreach cardinality limit"); let rendered = normalize_fluent_isolates(&format!("{error:#}")); ensure!( diff --git a/src/manifest/expand/tests/foreach/property_cases.rs b/src/manifest/expand/tests/foreach/property_cases.rs index 43fcb1288..59e4cf1bd 100644 --- a/src/manifest/expand/tests/foreach/property_cases.rs +++ b/src/manifest/expand/tests/foreach/property_cases.rs @@ -30,11 +30,11 @@ proptest! { let mut first = doc.clone(); let first_budget = ManifestBudget::new(limits) .map_err(|error| TestCaseError::fail(error.to_string()))?; - let first_result = expand_foreach_with_budget(&mut first, &env, &first_budget); + let first_result = expand_foreach_with_budget(&mut first, &env, &first_budget, true); let mut second = doc; let second_budget = ManifestBudget::new(limits) .map_err(|error| TestCaseError::fail(error.to_string()))?; - let second_result = expand_foreach_with_budget(&mut second, &env, &second_budget); + let second_result = expand_foreach_with_budget(&mut second, &env, &second_budget, true); if values.len() <= CARDINALITY_LIMIT { first_result.map_err(|error| TestCaseError::fail(error.to_string()))?; diff --git a/src/manifest/jinja_macros/mod.rs b/src/manifest/jinja_macros/mod.rs index 0b24528fc..01351a152 100644 --- a/src/manifest/jinja_macros/mod.rs +++ b/src/manifest/jinja_macros/mod.rs @@ -17,7 +17,10 @@ use serde::Serialize; mod call; mod expression; mod invocation; +mod query_classification; pub(crate) use expression::{ExpressionEvaluation, evaluate_with_state}; +pub(crate) use query_classification::QueryEvaluation; +use query_classification::classify_query_evaluation; pub(crate) mod telemetry; // Only the manifest test suite reaches the helper through the parent path; @@ -29,16 +32,18 @@ use invocation::{make_macro_fn, validate_macro}; /// Global name holding accumulated import statements for manifest macros. const MACRO_IMPORTS_GLOBAL: &str = "__netsuke_manifest_macro_imports"; -/// Represents a Jinja evaluation that may be disabled in manifest-query mode. +/// Borrow the budget and refusal policy one `when` evaluation runs under. /// -/// This preserves the distinction between a false condition and a condition -/// that discovery cannot safely inspect, without leaking `MiniJinja` errors -/// into manifest expansion. -pub(crate) enum QueryEvaluation { - /// Holds a successfully evaluated value. - Value(T), - /// Marks an expression that invoked a query-disabled helper. - QueryDisabled, +/// Bundling the pair keeps `evaluate_when_expression` and +/// `render_when_template` within the repository's four-argument limit while +/// still letting the caller decide whether a refused helper is a conditional +/// entry or a genuine failure (C5). +#[derive(Clone, Copy, Debug)] +pub(crate) struct EvaluationBoundary<'a> { + /// Shared resource accounting for this complete manifest evaluation. + pub(crate) budget: &'a ManifestBudget, + /// Whether a refused helper marks the condition conditional. + pub(crate) classifies_query_refusals: bool, } /// Evaluate a `when` expression when it has expression syntax. @@ -54,9 +59,10 @@ pub(crate) fn evaluate_when_expression( env: &Environment, expression: &str, context: &Value, - budget: &ManifestBudget, + boundary: EvaluationBoundary<'_>, ) -> Result>> { - budget + boundary + .budget .charge_source(expression.len(), ManifestBudgetStage::Source) .map_err(|exhaustion| exhaustion.into_error(ErrorKind::InvalidOperation))?; if env.compile_expression(expression).is_err() { @@ -64,7 +70,7 @@ pub(crate) fn evaluate_when_expression( } let evaluation = evaluate_with_state( env, - budget, + boundary.budget, &ExpressionEvaluation { expression, context, @@ -73,7 +79,7 @@ pub(crate) fn evaluate_when_expression( ); match evaluation { Err(error) if is_budget_error(&error) => Err(error.into()), - result => classify_query_evaluation(result) + result => classify_query_evaluation(result, boundary.classifies_query_refusals) .map(|query_evaluation| query_evaluation.map(|value| value.is_true())) .with_context(|| { localization::message(keys::MANIFEST_WHEN_EVAL_ERROR).with_arg("expr", expression) @@ -92,11 +98,11 @@ pub(crate) fn render_when_template( env: &Environment, template: &str, context: &Value, - budget: &ManifestBudget, + boundary: EvaluationBoundary<'_>, ) -> Result> { let evaluation = render_template_at( env, - budget, + boundary.budget, &TemplateRenderRequest { template, context, @@ -105,9 +111,10 @@ pub(crate) fn render_when_template( ); match evaluation { Err(error) if is_budget_error(&error) => Err(error.into()), - result => classify_query_evaluation(result).with_context(|| { - localization::message(keys::MANIFEST_WHEN_TEMPLATE_ERROR).with_arg("expr", template) - }), + result => classify_query_evaluation(result, boundary.classifies_query_refusals) + .with_context(|| { + localization::message(keys::MANIFEST_WHEN_TEMPLATE_ERROR).with_arg("expr", template) + }), } } @@ -116,33 +123,6 @@ fn is_budget_error(error: &Error) -> bool { matches!(error.kind(), ErrorKind::OutOfFuel | ErrorKind::WriteFailure) } -/// Convert a `MiniJinja` result into the query-safe evaluation boundary. -/// -/// The stdlib marks intentionally disabled helpers with a stable `MiniJinja` -/// error. Keeping the adapter here confines that implementation detail to the -/// Jinja boundary; manifest expansion consumes only [`QueryEvaluation`]. -fn classify_query_evaluation( - evaluation: std::result::Result, -) -> Result> { - match evaluation { - Ok(value) => Ok(QueryEvaluation::Value(value)), - Err(error) if crate::stdlib::is_manifest_query_disabled_error(&error) => { - Ok(QueryEvaluation::QueryDisabled) - } - Err(error) => Err(error.into()), - } -} - -impl QueryEvaluation { - /// Transform a successful value while preserving query-disabled state. - fn map(self, transform: impl FnOnce(T) -> U) -> QueryEvaluation { - match self { - Self::Value(value) => QueryEvaluation::Value(transform(value)), - Self::QueryDisabled => QueryEvaluation::QueryDisabled, - } - } -} - /// Extract the macro identifier from a signature string. /// /// The signature must follow the form `name(params)` where `name` is a valid diff --git a/src/manifest/jinja_macros/query_classification.rs b/src/manifest/jinja_macros/query_classification.rs new file mode 100644 index 000000000..cdb2e8910 --- /dev/null +++ b/src/manifest/jinja_macros/query_classification.rs @@ -0,0 +1,74 @@ +//! Classify `MiniJinja` failures at the manifest-query boundary. +//! +//! `netsuke help targets` renders `when` conditions against a stdlib surface +//! whose host-observing helpers are deliberate refusals. Expansion must tell a +//! refused helper apart from a genuine render failure without seeing the +//! stdlib's error vocabulary. This module owns that outcome and the single +//! translation into it. +//! +//! It is a child module rather than part of `jinja_macros/mod.rs` for two +//! reasons. The parent sits at AGENTS.md's 400-line cap, and this boundary has +//! a different subject from macro registration: only this code must recognize +//! the restricted-helper marker, while the rest of the macro machinery only +//! evaluates expressions. Keeping [`QueryEvaluation`] and the adapter together +//! means a change to either half is reviewed in one place. + +use anyhow::Result; +use minijinja::Error; + +/// Represents a Jinja evaluation that may be disabled in manifest-query mode. +/// +/// This preserves the distinction between a false condition and a condition +/// that discovery cannot safely inspect, without leaking `MiniJinja` errors +/// into manifest expansion. +pub(crate) enum QueryEvaluation { + /// Holds a successfully evaluated value. + Value(T), + /// Marks an expression that invoked a query-disabled helper. + QueryDisabled, +} + +/// Convert a `MiniJinja` result into the query-safe evaluation boundary. +/// +/// The stdlib marks intentionally disabled helpers with a stable `MiniJinja` +/// error. This adapter is the only place that turns that marker into +/// [`QueryEvaluation::QueryDisabled`], so expansion never matches on the +/// stdlib's error text. `evaluate_when_expression` and `render_when_template` +/// call it; keeping it beside [`QueryEvaluation`] keeps the boundary's two +/// halves together. +/// +/// The marker is only meaningful for a surface that installs refusal stubs, so +/// `classifies_query_refusals` decides whether it is recognized at all. A load +/// that owns a complete stdlib treats the marker as an ordinary failure, which +/// is what keeps a full build from silently tolerating a refusal. +/// +/// # Errors +/// +/// Returns an error when classification is disabled or the failure is not the +/// query-refusal marker, so a genuine render failure still surfaces with its +/// own context. +pub(super) fn classify_query_evaluation( + evaluation: std::result::Result, + classifies_query_refusals: bool, +) -> Result> { + match evaluation { + Ok(value) => Ok(QueryEvaluation::Value(value)), + Err(error) + if classifies_query_refusals + && crate::stdlib::is_manifest_query_disabled_error(&error) => + { + Ok(QueryEvaluation::QueryDisabled) + } + Err(error) => Err(error.into()), + } +} + +impl QueryEvaluation { + /// Transform a successful value while preserving query-disabled state. + pub(super) fn map(self, transform: impl FnOnce(T) -> U) -> QueryEvaluation { + match self { + Self::Value(value) => QueryEvaluation::Value(transform(value)), + Self::QueryDisabled => QueryEvaluation::QueryDisabled, + } + } +} diff --git a/src/manifest/loader_options.rs b/src/manifest/loader_options.rs new file mode 100644 index 000000000..0921f515b --- /dev/null +++ b/src/manifest/loader_options.rs @@ -0,0 +1,359 @@ +//! Options that decide how one manifest load is performed. +//! +//! A load's host-facing capabilities are decided once, by +//! [`StdlibRegistration`], rather than separately by each entry point: the same +//! decision selects the stdlib surface, decides whether `env()` and `glob()` +//! are real or refusing, picks the render mode, and gates expansion telemetry +//! and the query-refusal classifier (C5). The public surface is +//! [`ManifestLoadOptions`]; the registration enum and its dispositions stay +//! crate-private so the roadmap can extend the sandbox without freezing them. +//! +//! There is deliberately no public `Default`: a default silently selecting the +//! ambient stdlib and process environment would be a trap for an embedder. + +use std::sync::OnceLock; + +use camino::Utf8PathBuf; +use minijinja::Environment; + +use super::{ + EnvAccessPolicy, EnvReader, ManifestBudgetLimits, ManifestEnvironment, ManifestLoadStage, + ManifestName, ManifestParse, NetsukeManifest, TemplateOverlays, TestSurface, + budget::adapter::from_str_named, + registration::{register_env_function, register_glob_function}, +}; +use crate::stdlib::{RestrictedSurface, StdlibConfig, StdlibState}; + +/// State whether the loader installs a real host-reading helper or a refusal. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HostHelperDisposition { + /// The loader installs the real helper. + Real, + /// The loader installs the named restricted surface's refusal stub. + Refused(RestrictedSurface), +} + +/// Selects the stdlib surface available while rendering a manifest. +/// +/// Every capability decision for a load is answered from this one value, and +/// no second mode enum survives (C5). +pub(crate) enum StdlibRegistration { + /// The complete stdlib, configured from the process's ambient state. + AmbientFull, + /// The complete stdlib, configured by the caller. + Full(Box), + /// The read-only stdlib used to inspect manifest discovery metadata. + ManifestQuery, + /// The deny-by-default surface used by `netsuke test`. + Test(Box), +} + +impl StdlibRegistration { + /// Decide whether `env()` reads the injected environment or refuses. + /// + /// A test surface is real here: its reader is an explicit seam, and with + /// none supplied it refuses every lookup rather than consulting the + /// process. + pub(crate) const fn env_disposition(&self) -> HostHelperDisposition { + match self { + Self::AmbientFull | Self::Full(_) | Self::Test(_) => HostHelperDisposition::Real, + Self::ManifestQuery => HostHelperDisposition::Refused(RestrictedSurface::ManifestQuery), + } + } + + /// Decide whether `glob()` inspects the filesystem or refuses. + pub(crate) const fn glob_disposition(&self) -> HostHelperDisposition { + match self { + Self::AmbientFull | Self::Full(_) => HostHelperDisposition::Real, + Self::ManifestQuery => HostHelperDisposition::Refused(RestrictedSurface::ManifestQuery), + Self::Test(_) => HostHelperDisposition::Refused(RestrictedSurface::Test), + } + } + + /// Return the reader behind a surface that supplies its own. + /// + /// A test surface replaces the options' environment with an explicitly + /// injected reader; every other mode uses the environment the options + /// carry. + pub(crate) fn env_reader<'r>(&'r self, fallback: &'r EnvReader) -> &'r EnvReader { + match self { + Self::Test(surface) => surface.reader(), + _ => fallback, + } + } + + /// Report whether rendering uses the manifest-query render mode. + pub(crate) const fn uses_query_rendering(&self) -> bool { + matches!(self, Self::ManifestQuery) + } + + /// Report whether expansion and budget telemetry is recorded for this load. + /// + /// Those counters describe normal manifest loading; counting discovery + /// queries or test runs would distort usage evidence (ADR-0050 Addendum E). + pub(crate) const fn records_expansion_and_budget_telemetry(&self) -> bool { + matches!(self, Self::AmbientFull | Self::Full(_)) + } + + /// Report whether a refused helper marks the surrounding `when` as + /// conditional instead of failing the load. + pub(crate) const fn classifies_query_refusals(&self) -> bool { + matches!(self, Self::ManifestQuery) + } + + /// Return the template overlays this mode may register, if any. + /// + /// Overlays are a capability grant, so only the test surface admits them. + /// The decision table reads this accessor so a new variant cannot be added + /// without stating its answer. + pub(crate) fn overlays(&self) -> Option<&TemplateOverlays> { + match self { + Self::Test(surface) => Some(surface.overlays()), + Self::AmbientFull | Self::Full(_) | Self::ManifestQuery => None, + } + } +} + +/// Inputs to one manifest load beyond the YAML text and its name. +pub struct ManifestLoadOptions<'a> { + /// Decides every capability for this load. + registration: StdlibRegistration, + /// Reader and access policy behind `env()`. + environment: ManifestEnvironment<'a>, + /// Resource ceilings resolved before loading. + budget_limits: ManifestBudgetLimits, + /// Observes load stages without changing parsing behaviour. + on_stage: Option<&'a mut dyn FnMut(ManifestLoadStage)>, +} + +impl<'a> ManifestLoadOptions<'a> { + /// Load with the complete build stdlib and explicit environment inputs. + /// + /// # Examples + /// + /// ``` + /// use netsuke::manifest::{ + /// EnvAccessPolicy, ManifestEnvironment, ManifestLoadOptions, process_env_reader, + /// }; + /// use netsuke::stdlib::StdlibConfig; + /// + /// let reader = process_env_reader(); + /// let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); + /// let options = ManifestLoadOptions::full( + /// StdlibConfig::from_current_dir().expect("open the workspace"), + /// environment, + /// ); + /// # let _ = options; + /// ``` + #[must_use] + pub fn full(config: StdlibConfig, environment: ManifestEnvironment<'a>) -> Self { + Self { + registration: StdlibRegistration::Full(Box::new(config)), + environment, + budget_limits: ManifestBudgetLimits::default(), + on_stage: None, + } + } + + /// Load with the ambient build stdlib and explicit environment inputs. + /// + /// This is the mode behind [`super::from_str`]: the stdlib is rooted at the + /// process working directory, exactly as before options existed. + #[must_use] + pub(crate) fn ambient(environment: ManifestEnvironment<'a>) -> Self { + Self { + registration: StdlibRegistration::AmbientFull, + environment, + budget_limits: ManifestBudgetLimits::default(), + on_stage: None, + } + } + + /// Load with the deny-by-default test surface. + /// + /// # Examples + /// + /// ``` + /// use netsuke::manifest::{ManifestLoadOptions, TestSurface}; + /// use netsuke::stdlib::StdlibConfig; + /// + /// let surface = TestSurface::new(StdlibConfig::from_current_dir().expect("open the workspace")); + /// let options = ManifestLoadOptions::test(surface); + /// # let _ = options; + /// ``` + #[must_use] + pub fn test(surface: TestSurface) -> Self { + Self { + registration: StdlibRegistration::Test(Box::new(surface)), + environment: query_environment(), + budget_limits: ManifestBudgetLimits::default(), + on_stage: None, + } + } + + /// Load for a side-effect-free discovery query. + /// + /// Query loads never read the environment, so they supply a reader that + /// refuses every lookup rather than accepting one that could contradict the + /// refusal stubs the registration installs. + #[must_use] + pub(crate) fn manifest_query<'options>() -> ManifestLoadOptions<'options> { + ManifestLoadOptions { + registration: StdlibRegistration::ManifestQuery, + environment: query_environment(), + budget_limits: ManifestBudgetLimits::default(), + on_stage: None, + } + } + + /// Placeholder for a path load whose registration the workspace decides. + /// + /// The registration here is never used: [`super::query::from_path_with_options`] + /// always replaces it with the one its `registration_for` closure derives + /// from the opened workspace. Keeping the field populated lets the same + /// builder chain serve string and path loads. + #[must_use] + pub(crate) fn deferred(environment: ManifestEnvironment<'a>) -> Self { + Self { + registration: StdlibRegistration::AmbientFull, + environment, + budget_limits: ManifestBudgetLimits::default(), + on_stage: None, + } + } + + /// Supply resource ceilings. + #[must_use] + pub const fn with_budget_limits(mut self, limits: ManifestBudgetLimits) -> Self { + self.budget_limits = limits; + self + } + + /// Observe loader stages. + #[must_use] + pub fn with_stage_observer(mut self, on_stage: &'a mut dyn FnMut(ManifestLoadStage)) -> Self { + self.on_stage = Some(on_stage); + self + } + + /// Replace the registration with one derived from a workspace. + #[must_use] + pub(super) fn with_registration(mut self, registration: StdlibRegistration) -> Self { + self.registration = registration; + self + } + + /// Report a load stage to the observer, if one is installed. + pub(super) fn notify(&mut self, stage: ManifestLoadStage) { + super::loading::notify_stage(&mut self.on_stage, stage); + } + + /// Resolve these options into the loader's internal parse request. + pub(super) fn into_parse<'name>( + self, + name: &'name ManifestName, + manifest_root: Option, + ) -> ManifestParse<'a, 'name> { + ManifestParse { + name, + manifest_root, + registration: self.registration, + environment: self.environment, + budget_limits: self.budget_limits, + on_stage: self.on_stage, + } + } +} + +/// Load a manifest string with explicit options. +/// +/// # Examples +/// +/// ``` +/// use netsuke::manifest::{ +/// EnvAccessPolicy, ManifestEnvironment, ManifestLoadOptions, ManifestName, +/// from_str_with_options, process_env_reader, +/// }; +/// use netsuke::stdlib::StdlibConfig; +/// +/// let reader = process_env_reader(); +/// let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); +/// let options = ManifestLoadOptions::full( +/// StdlibConfig::from_current_dir().expect("open the workspace"), +/// environment, +/// ); +/// let yaml = concat!( +/// "netsuke_version: 1.0.0\n", +/// "targets:\n", +/// " - name: build\n", +/// " command: echo hi\n", +/// ); +/// let manifest = from_str_with_options(yaml, &ManifestName::new("Netsukefile"), options) +/// .expect("load manifest"); +/// assert_eq!(manifest.targets.len(), 1); +/// ``` +/// +/// # Errors +/// +/// Returns an error if YAML parsing, template evaluation, or recipe validation +/// fails, or if the load exhausts its resource budget. +pub fn from_str_with_options<'a>( + yaml: &str, + name: &'a ManifestName, + options: ManifestLoadOptions<'a>, +) -> anyhow::Result { + from_str_named(yaml, options.into_parse(name, None)) +} + +/// Register `env()` and `glob()` from the load's registration decision. +/// +/// The loader owns these two helpers rather than the stdlib registrations, +/// because whether a load may read the host is a property of the load, not of +/// the library surface (C5). The refusal shapes still come from the restricted +/// surface's inventory, so query refusal text is unchanged (C2). +pub(super) fn register_host_helpers( + env: &mut Environment<'_>, + registration: &StdlibRegistration, + environment: &ManifestEnvironment<'_>, + manifest_root: Option, +) { + match registration.env_disposition() { + HostHelperDisposition::Real => { + let reader = registration.env_reader(environment.reader()); + register_env_function(env, reader, environment.access_policy()); + } + HostHelperDisposition::Refused(_) => crate::stdlib::register_env_refusal(env), + } + match registration.glob_disposition() { + HostHelperDisposition::Real => register_glob_function(env, manifest_root), + HostHelperDisposition::Refused(surface) => { + crate::stdlib::register_glob_refusal(env, surface); + } + } +} + +/// Register the stdlib surface the registration selects. +pub(super) fn register_stdlib( + env: &mut Environment<'_>, + registration: StdlibRegistration, +) -> anyhow::Result { + match registration { + StdlibRegistration::AmbientFull => crate::stdlib::register(env), + StdlibRegistration::Full(config) => crate::stdlib::register_with_config(env, *config), + StdlibRegistration::ManifestQuery => Ok(crate::stdlib::register_manifest_query(env)), + StdlibRegistration::Test(surface) => { + Ok(crate::stdlib::register_for_test(env, surface.config())) + } + } +} + +/// Return a `'static` environment whose reader refuses every lookup. +/// +/// Query options live for as long as their caller needs them, so the reader +/// they carry must be static; one shared instance is enough because it holds no +/// state. The refusal stubs mean this reader is never actually consulted. +fn query_environment() -> ManifestEnvironment<'static> { + static READER: OnceLock = OnceLock::new(); + let reader = READER.get_or_init(super::env::disabled_env_reader); + ManifestEnvironment::new(reader, EnvAccessPolicy::default()) +} diff --git a/src/manifest/loading.rs b/src/manifest/loading.rs index 6c6aade61..018f9ba37 100644 --- a/src/manifest/loading.rs +++ b/src/manifest/loading.rs @@ -4,10 +4,6 @@ use super::{ManifestLoadStage, expand::ExpansionReport}; use metrics::{counter, describe_counter}; use std::sync::Once; -/// Receives normal-loader reports; manifest queries supply `None` to stay -/// telemetry-free. -pub(super) type ExpansionReportObserver = fn(&ExpansionReport); - /// Metric name counting filtered manifest targets. const FILTERED_TARGETS_TOTAL: &str = "netsuke_manifest_filtered_targets_total"; /// Metric name counting filtered manifest actions. diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index f08161395..a2e09f5ec 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -10,16 +10,16 @@ use crate::{ ast::NetsukeManifest, localization::{self, keys}, - stdlib::StdlibConfig, }; use anyhow::Result; +use camino::Utf8PathBuf; use minijinja::{Environment, UndefinedBehavior}; use serde::de::Error as _; mod access_policy; mod budget; +use budget::adapter::BudgetErrorExt; pub(crate) use budget::adapter::budget_exhaustion_message; -use budget::adapter::{BudgetErrorExt, from_str_named}; mod diagnostics; mod expand; // `glob_paths` is the module's only boundary: every other item, including the @@ -31,12 +31,15 @@ mod glob; mod hints; mod jinja_macros; mod load_stage; +mod loader_options; mod loading; mod parse_with_config; mod path_loaders; mod query; mod registration; mod render; +mod str_loaders; +mod test_surface; /// JSON representation of a manifest node after YAML and Jinja evaluation. pub type ManifestValue = serde_json::Value; /// JSON object mapping string keys to manifest values. @@ -51,7 +54,9 @@ pub use env::{EnvReadError, EnvReader, ManifestEnvironment, process_env_reader}; pub(crate) use expand::expand_foreach_with_budget; pub use glob::glob_paths; pub use load_stage::ManifestLoadStage; -use loading::{ExpansionReportObserver, notify_stage, trace_expansion_report}; +pub(crate) use loader_options::StdlibRegistration; +pub use loader_options::{ManifestLoadOptions, from_str_with_options}; +use loading::{notify_stage, trace_expansion_report}; pub use parse_with_config::{from_str_with_env_and_config, from_str_with_env_and_policy}; pub use path_loaders::{ from_path, from_path_with_policy, from_path_with_policy_and_env, @@ -65,89 +70,186 @@ pub(crate) use query::from_path_for_manifest_query_with_limits; pub(crate) use query::{LoadedManifest, from_path_for_manifest_query_with_source}; #[cfg(test)] use registration::RESERVED_VAR_NAMES; -use registration::{ - localize_recipe_error, register_env_function, register_glob_function, register_manifest_vars, -}; +use registration::{localize_recipe_error, register_manifest_vars}; pub use render::render_manifest; #[cfg(test)] +pub(crate) use str_loaders::from_str_with_limits; +pub use str_loaders::{from_str, from_str_with_env}; +pub use test_surface::{ + OverlayCall, OverlayCallable, OverlayNameKind, TemplateOverlayError, TemplateOverlays, + TestSurface, +}; +pub(crate) use test_surface::{overlay_namespace, register_overlays}; +#[cfg(test)] use workspace::open_manifest_workspace; /// Inputs to a manifest parse, bundled to keep the parameter list bounded. -struct ManifestParse<'a> { +struct ManifestParse<'a, 'name> { /// Name reported in diagnostics. - name: &'a ManifestName, - /// Optional stdlib registration configuration. - stdlib_registration: Option, - /// Environment reader backing the `env()` helper. - env_reader: &'a EnvReader, - /// Access policy evaluated before the `env()` reader runs. - env_access_policy: &'a EnvAccessPolicy, + name: &'name ManifestName, /// Manifest workspace root, anchoring relative `glob()` patterns; `None` /// falls back to the process current directory at the composition root. - manifest_root: Option, - /// Optional observer for reports produced by normal manifest loading. - expansion_report_observer: Option, + manifest_root: Option, + /// Capabilities decided for this load. + registration: StdlibRegistration, + /// Environment inputs backing the `env()` helper. + environment: ManifestEnvironment<'a>, /// Resource ceilings resolved from trusted configuration before loading. budget_limits: ManifestBudgetLimits, + /// Observes load phases without changing parsing behaviour. + on_stage: Option<&'a mut dyn FnMut(ManifestLoadStage)>, +} + +/// Decisions the render and expansion halves need from the registration. +#[derive(Clone, Copy, Debug)] +struct RenderDecisions { + /// Render with the manifest-query render mode. + uses_query_rendering: bool, + /// Treat a refused helper as a conditional `when`, not a failure. + classifies_query_refusals: bool, + /// Record expansion and budget telemetry. + records_expansion_and_budget_telemetry: bool, +} + +impl RenderDecisions { + /// Read every decision from the load's registration. + const fn from_registration(registration: &StdlibRegistration) -> Self { + Self { + uses_query_rendering: registration.uses_query_rendering(), + classifies_query_refusals: registration.classifies_query_refusals(), + records_expansion_and_budget_telemetry: registration + .records_expansion_and_budget_telemetry(), + } + } +} + +/// Inputs that shape the template environment for one load. +struct TemplateRequest<'name, 'a> { + /// Name reported in diagnostics. + name: &'name ManifestName, + /// Root anchoring relative `glob()` patterns. + manifest_root: Option, + /// Capabilities decided for this load. + registration: StdlibRegistration, + /// Environment inputs backing the `env()` helper. + environment: &'a ManifestEnvironment<'a>, + /// Resource ceilings resolved before loading. + budget_limits: ManifestBudgetLimits, } -/// Selects the stdlib surface available while rendering a manifest. -enum StdlibRegistration { - /// The complete stdlib used for a normal build manifest. - Full(Box), - /// The read-only stdlib used to inspect manifest discovery metadata. - ManifestQuery, +/// Everything needed to expand and render an evaluated manifest. +struct ExpansionRequest<'name, 'a, 'stage> { + /// YAML tree produced by the template-environment stage. + doc: ManifestValue, + /// Template environment built for this load. + jinja: Environment<'static>, + /// Shared resource accounting for this evaluation. + budget: &'a budget::ManifestBudget, + /// Decisions the registration supplies to this half of the load. + decisions: RenderDecisions, + /// Name reported in diagnostics. + name: &'name ManifestName, + /// Observes load phases without changing parsing behaviour. + on_stage: &'a mut Option<&'stage mut dyn FnMut(ManifestLoadStage)>, } /// Parse, render, and validate a manifest with injected loading boundaries. /// /// Render Jinja values, anchor relative `glob()` patterns at `manifest_root`, -/// notify the optional expansion observer, and validate rendered recipes. -fn evaluate_manifest( - yaml: &str, - parse: ManifestParse<'_>, - on_stage: &mut Option<&mut dyn FnMut(ManifestLoadStage)>, -) -> Result { +/// record telemetry for full loads, and validate rendered recipes. +fn evaluate_manifest(yaml: &str, parse: ManifestParse<'_, '_>) -> Result { let ManifestParse { name, - stdlib_registration, - env_reader, - env_access_policy, manifest_root, - expansion_report_observer, + registration, + environment, budget_limits, + mut on_stage, } = parse; - let is_manifest_query = matches!(stdlib_registration, Some(StdlibRegistration::ManifestQuery)); + let decisions = RenderDecisions::from_registration(®istration); + let request = TemplateRequest { + name, + manifest_root, + registration, + environment: &environment, + budget_limits, + }; + let (doc, jinja, budget) = build_template_environment(yaml, request, &mut on_stage)?; + expand_and_render(ExpansionRequest { + doc, + jinja, + budget: &budget, + decisions, + name, + on_stage: &mut on_stage, + }) +} + +/// Build the template environment and evaluate the manifest's YAML tree. +/// +/// This covers the loader's parse, registration, and macro steps; expansion and +/// rendering are left to [`expand_and_render`] so each half stays small enough +/// to review on its own. +fn build_template_environment( + yaml: &str, + request: TemplateRequest<'_, '_>, + on_stage: &mut Option<&mut dyn FnMut(ManifestLoadStage)>, +) -> Result<(ManifestValue, Environment<'static>, budget::ManifestBudget)> { + let TemplateRequest { + name, + manifest_root, + registration, + environment, + budget_limits, + } = request; notify_stage(on_stage, ManifestLoadStage::InitialYamlParsing); let budget = budget::ManifestBudget::new(budget_limits)?; budget .charge_source(yaml.len(), budget::ManifestBudgetStage::Source) .map_err(|exhaustion| exhaustion.into_error(minijinja::ErrorKind::InvalidOperation))?; - let mut doc: ManifestValue = - serde_saphyr::from_str(yaml).map_err(|e| ManifestError::Parse { - source: map_yaml_error(&e, &ManifestSource::from(yaml), name), - message: localization::message(keys::MANIFEST_PARSE), - })?; + let doc: ManifestValue = serde_saphyr::from_str(yaml).map_err(|e| ManifestError::Parse { + source: map_yaml_error(&e, &ManifestSource::from(yaml), name), + message: localization::message(keys::MANIFEST_PARSE), + })?; let mut jinja = Environment::new(); jinja.set_undefined_behavior(UndefinedBehavior::Strict); // Expose custom helpers to templates. - register_env_function(&mut jinja, env_reader, env_access_policy); - register_glob_function(&mut jinja, manifest_root); - let _stdlib_state = match stdlib_registration { - Some(StdlibRegistration::Full(config)) => { - crate::stdlib::register_with_config(&mut jinja, *config) - } - Some(StdlibRegistration::ManifestQuery) => { - Ok(crate::stdlib::register_manifest_query(&mut jinja)) - } - None => crate::stdlib::register(&mut jinja), - }?; - register_manifest_vars(&doc, &mut jinja, name)?; + loader_options::register_host_helpers(&mut jinja, ®istration, environment, manifest_root); + // `register_stdlib` consumes the registration, so the overlays it carries + // are taken first. Cloning them copies only the `Arc` handles and the name + // map; the dispatch closures stay shared. + let overlays = registration.overlays().cloned(); + let _stdlib_state = loader_options::register_stdlib(&mut jinja, registration)?; + // Computed before the vars are registered: the manifest's own keys are part + // of the namespace an overlay must not take, and the same value answers the + // reserved-name check inside `register_manifest_vars`. + let namespace = overlay_namespace(&doc); + register_manifest_vars(&doc, &mut jinja, name, &namespace)?; notify_stage(on_stage, ManifestLoadStage::TemplateExpansion); register_manifest_macros_with_budget(&doc, &mut jinja, &budget)?; - let expansion_report = expand_foreach_with_budget(&mut doc, &jinja, &budget)?; - if let Some(observe_expansion_report) = expansion_report_observer { - observe_expansion_report(&expansion_report); + // Last, so an overlay can replace any helper the steps above installed. + register_overlays(&mut jinja, overlays.as_ref(), &namespace)?; + Ok((doc, jinja, budget)) +} + +/// Expand `foreach` and `when`, then deserialize and render the manifest. +fn expand_and_render(request: ExpansionRequest<'_, '_, '_>) -> Result { + let ExpansionRequest { + mut doc, + jinja, + budget, + decisions, + name, + on_stage, + } = request; + let expansion_report = expand_foreach_with_budget( + &mut doc, + &jinja, + budget, + decisions.classifies_query_refusals, + )?; + if decisions.records_expansion_and_budget_telemetry { + trace_expansion_report(&expansion_report); } notify_stage(on_stage, ManifestLoadStage::FinalRendering); let manifest: NetsukeManifest = @@ -155,10 +257,10 @@ fn evaluate_manifest( source: map_data_error(localize_recipe_error(error), name), message: localization::message(keys::MANIFEST_PARSE), })?; - let rendered_manifest = if is_manifest_query { - render::render_manifest_for_manifest_query_with_budget(manifest, &jinja, &budget)? + let rendered_manifest = if decisions.uses_query_rendering { + render::render_manifest_for_manifest_query_with_budget(manifest, &jinja, budget)? } else { - render::render_manifest_with_budget(manifest, &jinja, &budget)? + render::render_manifest_with_budget(manifest, &jinja, budget)? }; rendered_manifest .validate_recipes() @@ -168,80 +270,6 @@ fn evaluate_manifest( })?; Ok(rendered_manifest) } -/// Parse a manifest string using Jinja for value templating. -/// -/// The input YAML must be valid on its own. Jinja expressions are evaluated -/// only inside recognised string fields and the `foreach` and `when` keys. -/// -/// # Errors -/// -/// Returns an error if YAML parsing or Jinja evaluation fails. -pub fn from_str(yaml: &str) -> Result { - from_str_with_env(yaml, &process_env_reader()) -} -/// Parse a manifest string with an explicit environment reader. -/// -/// Lets a caller — in practice a test — drive the `env()` helper without -/// touching the process environment. -/// -/// # Errors -/// -/// Returns an error if YAML parsing or Jinja evaluation fails. -/// -/// # Examples -/// -/// ``` -/// use netsuke::{ -/// ast::Recipe, -/// manifest::{EnvReadError, EnvReader, from_str_with_env}, -/// }; -/// use std::sync::Arc; -/// -/// let reader: EnvReader = Arc::new(|name| match name { -/// "PROFILE" => Ok("release".to_owned()), -/// _ => Err(EnvReadError::NotPresent), -/// }); -/// let yaml = concat!( -/// "netsuke_version: 1.0.0\n", -/// "targets:\n", -/// " - name: build\n", -/// " command: echo {{ env('PROFILE') }}\n", -/// ); -/// let manifest = from_str_with_env(yaml, &reader).expect("parse manifest"); -/// -/// assert!(matches!( -/// &manifest.targets[0].recipe, -/// Recipe::Command { command, .. } if command.as_single() == Some("echo release") -/// )); -/// ``` -pub fn from_str_with_env(yaml: &str, env_reader: &EnvReader) -> Result { - let env_access_policy = EnvAccessPolicy::default(); - from_str_with_env_and_policy(yaml, env_reader, &env_access_policy) -} -/// Parse a manifest string with explicit resource ceilings for focused tests. -/// -/// # Errors -/// -/// Returns an error if parsing, expansion, or rendering exhausts a limit. -#[cfg(test)] -pub(crate) fn from_str_with_limits( - yaml: &str, - budget_limits: ManifestBudgetLimits, -) -> Result { - from_str_named( - yaml, - ManifestParse { - name: &ManifestName::new("Netsukefile"), - stdlib_registration: None, - env_reader: &process_env_reader(), - env_access_policy: &EnvAccessPolicy::default(), - manifest_root: None, - expansion_report_observer: Some(trace_expansion_report), - budget_limits, - }, - &mut None, - ) -} mod env; pub use env::{ENV_LOOKUP_OUTCOME_VALUES, ENV_LOOKUP_TOTAL}; #[cfg(test)] diff --git a/src/manifest/parse_with_config.rs b/src/manifest/parse_with_config.rs index da737a90a..696d893da 100644 --- a/src/manifest/parse_with_config.rs +++ b/src/manifest/parse_with_config.rs @@ -1,14 +1,14 @@ //! Manifest string parsing with explicit stdlib configuration. //! -//! The `from_str`-family entrypoints in the parent module parse with the +//! The `from_str`-family entrypoints in [`super::str_loaders`] parse with the //! default stdlib registration. This module hosts the variant that also //! injects a caller-owned [`StdlibConfig`], so tests can pin behaviour such //! as `command_available` resolution without mutating the process //! environment. use super::{ - EnvAccessPolicy, EnvReader, ManifestBudgetLimits, ManifestName, ManifestParse, - StdlibRegistration, from_str_named, trace_expansion_report, + EnvAccessPolicy, EnvReader, ManifestEnvironment, ManifestLoadOptions, ManifestName, + from_str_with_options, }; use crate::{ast::NetsukeManifest, stdlib::StdlibConfig}; use anyhow::Result; @@ -64,19 +64,11 @@ pub fn from_str_with_env_and_config( env_reader: &EnvReader, stdlib_config: StdlibConfig, ) -> Result { - let env_access_policy = EnvAccessPolicy::default(); - from_str_named( + let environment = ManifestEnvironment::new(env_reader, EnvAccessPolicy::default()); + from_str_with_options( yaml, - ManifestParse { - name: &ManifestName::new("Netsukefile"), - stdlib_registration: Some(StdlibRegistration::Full(Box::new(stdlib_config))), - env_reader, - env_access_policy: &env_access_policy, - manifest_root: None, - expansion_report_observer: Some(trace_expansion_report), - budget_limits: ManifestBudgetLimits::default(), - }, - &mut None, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::full(stdlib_config, environment), ) } @@ -94,17 +86,10 @@ pub fn from_str_with_env_and_policy( env_reader: &EnvReader, env_access_policy: &EnvAccessPolicy, ) -> Result { - from_str_named( + let environment = ManifestEnvironment::new(env_reader, env_access_policy.clone()); + from_str_with_options( yaml, - ManifestParse { - name: &ManifestName::new("Netsukefile"), - stdlib_registration: None, - env_reader, - env_access_policy, - manifest_root: None, - expansion_report_observer: Some(trace_expansion_report), - budget_limits: ManifestBudgetLimits::default(), - }, - &mut None, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::ambient(environment), ) } diff --git a/src/manifest/path_loaders.rs b/src/manifest/path_loaders.rs index 5a8ef5157..7a513d8ee 100644 --- a/src/manifest/path_loaders.rs +++ b/src/manifest/path_loaders.rs @@ -7,10 +7,14 @@ //! parent module. use super::{ - EnvAccessPolicy, EnvReader, ManifestBudgetLimits, ManifestEnvironment, ManifestLoadStage, - process_env_reader, query, + EnvAccessPolicy, EnvReader, ManifestBudgetLimits, ManifestEnvironment, ManifestLoadOptions, + ManifestLoadStage, StdlibRegistration, process_env_reader, query, +}; +use crate::{ + ast::NetsukeManifest, + recipe_shell::RecipeShell, + stdlib::{NetworkPolicy, StdlibConfig}, }; -use crate::{ast::NetsukeManifest, recipe_shell::RecipeShell, stdlib::NetworkPolicy}; use anyhow::Result; use std::path::Path; @@ -214,12 +218,21 @@ pub fn from_path_with_policy_and_environment_and_limits( recipe_shell: RecipeShell, on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, ) -> Result { - query::from_path_with_policy_and_environment_and_limits( + let mut options = + ManifestLoadOptions::deferred(environment.clone()).with_budget_limits(budget_limits); + if let Some(observer) = on_stage { + options = options.with_stage_observer(observer); + } + query::from_path_with_options( path, - policy, - environment, - budget_limits, - recipe_shell, - on_stage, + |workspace| { + let config = StdlibConfig::new(workspace.dir)? + .with_workspace_root_path(&workspace.root)? + .with_network_policy(policy) + .with_recipe_shell(recipe_shell); + Ok(StdlibRegistration::Full(Box::new(config))) + }, + options, ) + .map(|loaded| loaded.manifest) } diff --git a/src/manifest/query.rs b/src/manifest/query.rs index 1cd959b21..f9ee7a24d 100644 --- a/src/manifest/query.rs +++ b/src/manifest/query.rs @@ -1,17 +1,18 @@ //! Workspace-backed manifest loading for builds and discovery queries. //! //! This module owns the capability-scoped filesystem boundary shared by normal -//! manifest loading and `netsuke help targets`. The latter selects a restricted -//! stdlib registration so it can render discovery metadata without allowing -//! network requests, cache writes, or command execution. +//! manifest loading and `netsuke help targets`. The caller supplies the +//! [`StdlibRegistration`] each load needs — a discovery query selects the +//! restricted surface — so this module only reads, names, and hands the +//! manifest to the string loader. use super::{ - EnvAccessPolicy, ExpansionReportObserver, ManifestBudgetLimits, ManifestEnvironment, - ManifestLoadStage, ManifestName, ManifestParse, NetsukeManifest, StdlibConfig, - StdlibRegistration, env::disabled_env_reader, from_str_named, loading::trace_expansion_report, - notify_stage, workspace::open_manifest_workspace, + ManifestBudgetLimits, ManifestLoadOptions, ManifestLoadStage, ManifestName, NetsukeManifest, + StdlibRegistration, + budget::adapter::from_str_named, + workspace::{ManifestWorkspace, open_manifest_workspace}, }; -use crate::{localization, localization::keys, stdlib::NetworkPolicy}; +use crate::{localization, localization::keys}; use anyhow::{Context, Result}; use std::path::Path; @@ -55,141 +56,49 @@ pub(crate) fn from_path_for_manifest_query_with_source( budget_limits: ManifestBudgetLimits, on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, ) -> Result { - let env_reader = disabled_env_reader(); - let environment = ManifestEnvironment::new(&env_reader, EnvAccessPolicy::default()); - from_path_with_registration(ManifestLoadRequest { - path: path.as_ref(), - environment: &environment, - budget_limits, - on_stage, - mode: ManifestLoadMode::ManifestQuery, - }) + let mut options = ManifestLoadOptions::manifest_query().with_budget_limits(budget_limits); + if let Some(observer) = on_stage { + options = options.with_stage_observer(observer); + } + from_path_with_options( + path, + |_workspace| Ok(StdlibRegistration::ManifestQuery), + options, + ) } -/// Load a full manifest with explicit policy, environment inputs, and -/// resource ceilings. +/// Read one manifest through the capability-scoped workspace boundary. /// -/// `recipe_shell` is the interpreter the runner resolved; the template -/// filters and IR lowering must agree on it, so it is threaded here rather -/// than re-derived from the environment. -#[expect( - clippy::too_many_arguments, - reason = "This compatibility entry point keeps the established policy, environment, budget, shell, and stage-observer seams explicit." -)] -pub(super) fn from_path_with_policy_and_environment_and_limits( +/// `registration_for` derives the load's capabilities from the opened +/// workspace. That is what lets a path load configure the stdlib against the +/// directory the manifest actually lives in; string loads have no workspace, so +/// they decide their registration up front instead. +pub(super) fn from_path_with_options( path: impl AsRef, - policy: NetworkPolicy, - environment: &ManifestEnvironment<'_>, - budget_limits: ManifestBudgetLimits, - recipe_shell: crate::recipe_shell::RecipeShell, - on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, -) -> Result { - from_path_with_registration(ManifestLoadRequest { - path: path.as_ref(), - environment, - budget_limits, - on_stage, - mode: ManifestLoadMode::Full { - policy, - recipe_shell, - }, - }) - .map(|loaded| loaded.manifest) -} - -/// A rendered manifest together with the source text it was parsed from. -/// -/// The source and its display name are kept only for the linter's span index; -/// a build without the `lint` feature has no reader for them. -pub(crate) struct LoadedManifest { - /// The expanded and rendered manifest. - pub(crate) manifest: NetsukeManifest, - /// The manifest source, exactly as read. - #[cfg(feature = "lint")] - pub(crate) source: String, - /// The display name the diagnostics label the source with. - #[cfg(feature = "lint")] - pub(crate) name: String, -} - -/// Select the standard-library boundary for a manifest load. -enum ManifestLoadMode { - /// A normal build load with a configured network policy and the - /// interpreter the runner resolved. - Full { - /// Network grant ceiling applied to fetch helpers. - policy: NetworkPolicy, - /// Interpreter whose quoting rules the template filters follow. - recipe_shell: crate::recipe_shell::RecipeShell, - }, - /// A metadata-only load that must not construct an ambient stdlib config. - /// - /// Query-rendered metadata uses a fixed `sh` dialect for shell quoting, - /// independent of the host and build-shell configuration. Resolving the - /// build shell here would make a metadata query inspect a setting it does - /// not use and could make help fail on an unrelated malformed override. - ManifestQuery, -} - -/// Hold the dependencies needed to load one manifest from a workspace path. -struct ManifestLoadRequest<'path, 'env, 'stage> { - /// Identifies the manifest file on the capability-scoped workspace boundary. - path: &'path Path, - /// Reads environment variables and carries the policy for `env()` lookups. - environment: &'env ManifestEnvironment<'env>, - /// Supplies immutable resource ceilings for this parse. - budget_limits: ManifestBudgetLimits, - /// Observes load phases without changing parsing behaviour. - on_stage: Option<&'stage mut dyn FnMut(ManifestLoadStage)>, - /// Selects full or metadata-only standard-library registration. - mode: ManifestLoadMode, -} - -/// Read a manifest and render it with the selected stdlib registration. -fn from_path_with_registration( - mut request: ManifestLoadRequest<'_, '_, '_>, + registration_for: impl FnOnce(ManifestWorkspace) -> Result, + mut options: ManifestLoadOptions<'_>, ) -> Result { - notify_stage(&mut request.on_stage, ManifestLoadStage::ManifestIngestion); - let workspace = open_manifest_workspace(request.path, None)?; + let manifest_path = path.as_ref(); + options.notify(ManifestLoadStage::ManifestIngestion); + let workspace = open_manifest_workspace(manifest_path, None)?; let data = workspace .dir .read_to_string(&workspace.manifest_file) .with_context(|| { localization::message(keys::MANIFEST_READ_FAILED) - .with_arg("path", request.path.display().to_string()) + .with_arg("path", manifest_path.display().to_string()) })?; - let name = ManifestName::new(request.path.display().to_string()); - let (stdlib_registration, expansion_report_observer): ( - StdlibRegistration, - Option, - ) = match request.mode { - ManifestLoadMode::Full { - policy, - recipe_shell, - } => ( - StdlibRegistration::Full(Box::new( - StdlibConfig::new(workspace.dir)? - .with_workspace_root_path(&workspace.root)? - .with_network_policy(policy) - .with_recipe_shell(recipe_shell), - )), - Some(trace_expansion_report), - ), - ManifestLoadMode::ManifestQuery => (StdlibRegistration::ManifestQuery, None), - }; - let manifest_root = Some(workspace.root); + let name = ManifestName::new(manifest_path.display().to_string()); + // Clone the root before the closure consumes the workspace. The closure + // takes `ManifestWorkspace` by value and moves its `Dir` handle into the + // `StdlibConfig` it builds, so the path is no longer readable afterwards. + let manifest_root = Some(workspace.root.clone()); + let registration = registration_for(workspace)?; let manifest = from_str_named( &data, - ManifestParse { - name: &name, - stdlib_registration: Some(stdlib_registration), - env_reader: request.environment.reader(), - env_access_policy: request.environment.access_policy(), - manifest_root, - expansion_report_observer, - budget_limits: request.budget_limits, - }, - &mut request.on_stage, + options + .with_registration(registration) + .into_parse(&name, manifest_root), )?; Ok(LoadedManifest { manifest, @@ -199,3 +108,18 @@ fn from_path_with_registration( name: name.as_ref().to_owned(), }) } + +/// A rendered manifest together with the source text it was parsed from. +/// +/// The source and its display name are kept only for the linter's span index; +/// a build without the `lint` feature has no reader for them. +pub(crate) struct LoadedManifest { + /// The expanded and rendered manifest. + pub(crate) manifest: NetsukeManifest, + /// The manifest source, exactly as read. + #[cfg(feature = "lint")] + pub(crate) source: String, + /// The display name the diagnostics label the source with. + #[cfg(feature = "lint")] + pub(crate) name: String, +} diff --git a/src/manifest/registration.rs b/src/manifest/registration.rs index 8d4733f19..6303bf366 100644 --- a/src/manifest/registration.rs +++ b/src/manifest/registration.rs @@ -11,6 +11,7 @@ use super::{ env::env_var_with_default, glob::{GlobBaseCache, expand_manifest_template_glob}, map_data_error, + test_surface::OverlayNamespace, }; use crate::{ ast::{ @@ -137,6 +138,10 @@ pub(super) fn localize_recipe_error(error: serde_json::Error) -> serde_json::Err /// Expose the manifest's `vars` section as Jinja globals. /// +/// The reserved-name check reads `namespace` rather than re-deriving the key +/// list, so it cannot disagree with the overlay namespace rule about which +/// names this manifest already claims. +/// /// # Errors /// /// Returns a structural manifest error for non-object variables or helper-name collisions. @@ -144,6 +149,7 @@ pub(super) fn register_manifest_vars( doc: &ManifestValue, jinja: &mut Environment<'_>, name: &ManifestName, + namespace: &OverlayNamespace, ) -> Result<(), ManifestError> { let Some(vars_value) = doc.get("vars") else { return Ok(()); @@ -151,10 +157,7 @@ pub(super) fn register_manifest_vars( let vars = vars_value.as_object().ok_or_else(|| { manifest_structure_error(&localization::message(keys::MANIFEST_VARS_NOT_OBJECT), name) })?; - if let Some(reserved) = vars - .keys() - .find(|key| RESERVED_VAR_NAMES.contains(&key.as_str())) - { + if let Some(reserved) = namespace.reserved_variable(&RESERVED_VAR_NAMES) { return Err(manifest_structure_error( &localization::message(keys::MANIFEST_VARS_RESERVED_NAME).with_arg("name", reserved), name, diff --git a/src/manifest/str_loaders.rs b/src/manifest/str_loaders.rs new file mode 100644 index 000000000..492d06eac --- /dev/null +++ b/src/manifest/str_loaders.rs @@ -0,0 +1,90 @@ +//! Manifest string loaders and their compatibility adapters. +//! +//! Every entry point here parses a manifest already held in memory, so none of +//! them opens a workspace. [`from_str_with_env`] delegates to the +//! options-carrying core with a caller-supplied reader; [`from_str`] is the +//! ambient adapter over it, and `from_str_with_limits` is the test-only adapter +//! that narrows the resource ceilings. The variants that also inject a +//! [`StdlibConfig`](crate::stdlib::StdlibConfig) live in +//! [`super::parse_with_config`]; the file-path equivalents live in +//! [`super::path_loaders`]. + +use super::{EnvAccessPolicy, EnvReader, from_str_with_env_and_policy, process_env_reader}; +#[cfg(test)] +use super::{ + ManifestBudgetLimits, ManifestEnvironment, ManifestLoadOptions, ManifestName, + from_str_with_options, +}; +use crate::ast::NetsukeManifest; +use anyhow::Result; + +/// Parse a manifest string using Jinja for value templating. +/// +/// The input YAML must be valid on its own. Jinja expressions are evaluated +/// only inside recognized string fields and the `foreach` and `when` keys. +/// +/// # Errors +/// +/// Returns an error if YAML parsing or Jinja evaluation fails. +pub fn from_str(yaml: &str) -> Result { + from_str_with_env(yaml, &process_env_reader()) +} + +/// Parse a manifest string with an explicit environment reader. +/// +/// Lets a caller — in practice a test — drive the `env()` helper without +/// touching the process environment. +/// +/// # Errors +/// +/// Returns an error if YAML parsing or Jinja evaluation fails. +/// +/// # Examples +/// +/// ``` +/// use netsuke::{ +/// ast::Recipe, +/// manifest::{EnvReadError, EnvReader, from_str_with_env}, +/// }; +/// use std::sync::Arc; +/// +/// let reader: EnvReader = Arc::new(|name| match name { +/// "PROFILE" => Ok("release".to_owned()), +/// _ => Err(EnvReadError::NotPresent), +/// }); +/// let yaml = concat!( +/// "netsuke_version: 1.0.0\n", +/// "targets:\n", +/// " - name: build\n", +/// " command: echo {{ env('PROFILE') }}\n", +/// ); +/// let manifest = from_str_with_env(yaml, &reader).expect("parse manifest"); +/// +/// assert!(matches!( +/// &manifest.targets[0].recipe, +/// Recipe::Command { command, .. } if command.as_single() == Some("echo release") +/// )); +/// ``` +pub fn from_str_with_env(yaml: &str, env_reader: &EnvReader) -> Result { + let env_access_policy = EnvAccessPolicy::default(); + from_str_with_env_and_policy(yaml, env_reader, &env_access_policy) +} + +/// Parse a manifest string with explicit resource ceilings for focused tests. +/// +/// # Errors +/// +/// Returns an error if parsing, expansion, or rendering exhausts a limit. +#[cfg(test)] +pub(crate) fn from_str_with_limits( + yaml: &str, + budget_limits: ManifestBudgetLimits, +) -> Result { + let reader = process_env_reader(); + let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); + from_str_with_options( + yaml, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::ambient(environment).with_budget_limits(budget_limits), + ) +} diff --git a/src/manifest/test_surface/mod.rs b/src/manifest/test_surface/mod.rs new file mode 100644 index 000000000..bc830e907 --- /dev/null +++ b/src/manifest/test_surface/mod.rs @@ -0,0 +1,84 @@ +//! Capabilities granted to a manifest loaded under `netsuke test`. +//! +//! The surface is deny-by-default. It carries a [`StdlibConfig`] so the caller +//! can pin the recipe dialect and, by injecting a clock, make `now()` +//! deterministic; an [`EnvReader`] so `env()` answers from an explicit seam +//! rather than the process environment; and the [`TemplateOverlays`] a test +//! supplies in place of the helpers the surface withholds. Without an injected +//! reader every lookup reports "not set", which is what lets a test manifest +//! observe that the boundary was withheld without mutating the process. +//! +//! Keeping the payload here rather than in the loader options means the +//! loader's registration enum never has to grow a second capability field. The +//! overlay machinery lives in [`overlays`], which also owns the namespace rules +//! the loader consults before it registers anything. + +mod overlays; + +pub use overlays::{ + OverlayCall, OverlayCallable, OverlayNameKind, TemplateOverlayError, TemplateOverlays, +}; +pub(crate) use overlays::{OverlayNamespace, overlay_namespace, register_overlays}; + +use super::EnvReader; +use super::env::disabled_env_reader; +use crate::stdlib::StdlibConfig; + +/// The capabilities granted to a manifest under `netsuke test`. +pub struct TestSurface { + /// Stdlib configuration, including the clock `now()` may read. + config: StdlibConfig, + /// Reader behind `env()`; refuses every lookup by default. + reader: EnvReader, + /// Template functions registered in place of withheld helpers. + overlays: TemplateOverlays, +} + +impl TestSurface { + /// Start from a stdlib configuration. + /// + /// `now()` is real only when the configuration carries an injected clock; + /// with the ambient clock the helper refuses, because the host clock is a + /// read a test must not depend on. + #[must_use] + pub fn new(config: StdlibConfig) -> Self { + Self { + config, + reader: disabled_env_reader(), + overlays: TemplateOverlays::default(), + } + } + + /// Answer `env()` from this reader instead of refusing every lookup. + #[must_use] + pub fn with_env_reader(mut self, reader: EnvReader) -> Self { + self.reader = reader; + self + } + + /// Register these template functions for the load. + /// + /// An overlay replaces a same-named helper and is visible to `foreach`, + /// `when`, and every rendered field, so a test can stand in for a helper + /// the surface refuses without granting that helper itself. + #[must_use] + pub fn with_overlays(mut self, overlays: TemplateOverlays) -> Self { + self.overlays = overlays; + self + } + + /// Return the reader behind `env()` for this surface. + pub(crate) const fn reader(&self) -> &EnvReader { + &self.reader + } + + /// Return the stdlib configuration this surface grants. + pub(crate) const fn config(&self) -> &StdlibConfig { + &self.config + } + + /// Return the template overlays this surface grants. + pub(crate) const fn overlays(&self) -> &TemplateOverlays { + &self.overlays + } +} diff --git a/src/manifest/test_surface/overlays.rs b/src/manifest/test_surface/overlays.rs new file mode 100644 index 000000000..7355f2d06 --- /dev/null +++ b/src/manifest/test_surface/overlays.rs @@ -0,0 +1,372 @@ +//! Test-supplied template functions that stand in for withheld helpers. +//! +//! A `netsuke test` load is deny-by-default: a helper that reaches the host is +//! registered as a refusal, so a manifest that calls one fails. Overlays let a +//! test supply the answer a run on a real machine would have produced, without +//! granting the helper itself. An overlay is a template function registered +//! under a name the manifest may call and dispatched to a caller's closure. +//! +//! Three properties make overlays safe to reason about. An overlay may replace +//! a helper that already exists — the replaced function is handed to the +//! dispatch as [`OverlayCall::shadowed`], so a test can wrap rather than +//! discard it — but it may not take a name the loader or the manifest has +//! already claimed; that is [`TemplateOverlayError::NameCollision`]. Overlays +//! apply to test loads only, so build scaffolding cannot be reinterpreted by a +//! test fixture. And the mechanism is compiled unconditionally: overlays are a +//! loader capability rather than a test-only code path (C9). + +use std::fmt; +use std::sync::Arc; + +use indexmap::IndexMap; +use minijinja::value::{Kwargs, Rest, Value}; +use minijinja::{Environment, Error, State}; + +use super::super::ManifestValue; +use super::super::jinja_macros::parse_macro_name; +use crate::ast::MacroDefinition; +use crate::localization::{self, keys}; + +/// The name the loader reserves for its environment seam. +const ENVIRONMENT_SEAM_NAME: &str = "env"; + +/// The prefix of the globals only the loader may define. +const INTERNAL_NAME_PREFIX: &str = "__netsuke_"; + +/// Template functions a test load registers in place of withheld helpers. +/// +/// Overlays are applied at the end of registration, so they see — and may +/// replace — every helper the stdlib and the loader installed before them. +#[derive(Clone, Default)] +pub struct TemplateOverlays { + /// Callables in registration order, which fixes which collision a load + /// reports when a test names two claimed names. + functions: IndexMap, +} + +impl TemplateOverlays { + /// Add a template function named `name`. + /// + /// The name is checked against the manifest's own namespace when the + /// manifest is loaded, because only a load knows which `vars` keys and + /// macros the manifest defines. + #[must_use] + pub fn with_function(mut self, name: impl Into, callable: OverlayCallable) -> Self { + self.functions.insert(name.into(), callable); + self + } + + /// Report whether the test supplied no overlays at all. + #[must_use] + pub fn is_empty(&self) -> bool { + self.functions.is_empty() + } + + /// Iterate the overlays in registration order. + fn entries(&self) -> impl Iterator { + self.functions + .iter() + .map(|(name, callable)| (name.as_str(), callable)) + } +} + +/// The type-erased dispatch a template overlay runs. +/// +/// Named rather than written inline: the same signature appears in the stored +/// callable, its constructor bound, and the registered closure, and a single +/// alias keeps those three in step. +type Dispatch = dyn Fn(&OverlayCall<'_, '_>) -> Result + Send + Sync; + +/// A caller-supplied implementation behind one template overlay. +/// +/// `MiniJinja` registers a plain closure, so the dispatch is type-erased here: +/// a test supplies whatever closure its fixtures need, and the loader keeps one +/// uniform value. +#[derive(Clone)] +pub struct OverlayCallable( + /// The dispatch invoked for each call the template makes. + Arc, +); + +impl OverlayCallable { + /// Wrap `dispatch` as a template overlay. + /// + /// # Examples + /// + /// ``` + /// use minijinja::value::Value; + /// use netsuke::manifest::{OverlayCall, OverlayCallable, TemplateOverlays}; + /// + /// let overlays = TemplateOverlays::default().with_function( + /// "answer", + /// OverlayCallable::new(|_call: &OverlayCall<'_, '_>| Ok(Value::from(42))), + /// ); + /// assert!(!overlays.is_empty()); + /// ``` + #[must_use] + pub fn new(dispatch: F) -> Self + where + F: Fn(&OverlayCall<'_, '_>) -> Result + Send + Sync + 'static, + { + Self(Arc::new(dispatch)) + } + + /// Run the dispatch for one call. + fn invoke(&self, call: &OverlayCall<'_, '_>) -> Result { + (self.0)(call) + } +} + +/// One call a template made to an overlay. +/// +/// The borrows come from the environment that is rendering, so an overlay can +/// forward a call to the function it replaced without copying anything. +pub struct OverlayCall<'call, 'env> { + /// Rendering state, which a replaced function may also need. + state: &'call State<'call, 'env>, + /// Positional arguments, with keyword arguments appended as one trailing + /// [`Value`] when the template passed any. + args: &'call [Value], + /// The function this overlay replaced, if the name was already registered. + shadowed: Option<&'call Value>, +} + +impl<'call, 'env> OverlayCall<'call, 'env> { + /// Return the rendering state the template called through. + #[must_use] + pub const fn state(&self) -> &'call State<'call, 'env> { + self.state + } + + /// Return the call's arguments. + /// + /// Keyword arguments arrive as a single trailing kwargs map, so this slice + /// is exactly the argument list a replaced function accepts. + #[must_use] + pub const fn args(&self) -> &'call [Value] { + self.args + } + + /// Return the function this overlay replaced, if there was one. + /// + /// A name no helper had yet yields `None`. A replaced helper is handed back + /// as a value, so `shadowed().call(state(), args())` reproduces the call + /// the overlay displaced. + #[must_use] + pub const fn shadowed(&self) -> Option<&'call Value> { + self.shadowed + } +} + +/// Reasons a test load rejected its template overlays. +#[derive(Debug)] +pub enum TemplateOverlayError { + /// An overlay name is one the load already gives another meaning. + NameCollision { + /// The overlay name that collided. + name: String, + /// The namespace the name already belongs to. + kind: OverlayNameKind, + }, +} + +impl fmt::Display for TemplateOverlayError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NameCollision { name, kind } => { + let text = localization::message(keys::MANIFEST_OVERLAY_NAME_COLLISION) + .with_arg("name", name) + .with_arg("kind", kind.to_string()) + .to_string(); + formatter.write_str(&text) + } + } + } +} + +impl std::error::Error for TemplateOverlayError {} + +/// The namespace an overlay name collided with. +/// +/// The rendered form is the token the collision message interpolates, so it +/// stays stable and untranslated: it names the namespace, not the prose around +/// it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OverlayNameKind { + /// The loader's `env()` seam. + EnvironmentSeam, + /// A global the loader owns by prefix. + Internal, + /// A key in the manifest's `vars` section. + ManifestVariable, + /// A macro the manifest defines. + ManifestMacro, +} + +impl fmt::Display for OverlayNameKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::EnvironmentSeam => "environment-seam", + Self::Internal => "loader-internal", + Self::ManifestVariable => "manifest-variable", + Self::ManifestMacro => "manifest-macro", + }) + } +} + +/// Names the loader and the manifest already claim in the template namespace. +/// +/// The manifest half is computed once, before its `vars` are registered, and is +/// reused by the reserved-name check that registration performs, so the two +/// checks cannot disagree about which names a manifest claims. +pub(crate) struct OverlayNamespace { + /// Keys declared under the manifest's `vars` section. + variables: Vec, + /// Macro names the manifest defines, in declaration order. + macros: Vec, +} + +impl OverlayNamespace { + /// Return the first `vars` key the loader reserves, if one is declared. + pub(crate) fn reserved_variable(&self, reserved: &[&str]) -> Option<&str> { + self.variables + .iter() + .map(String::as_str) + .find(|key| reserved.contains(key)) + } + + /// Classify `name` against the names already claimed. + /// + /// `glob()` is deliberately absent: the loader registers it, but replacing + /// it is the reason overlays exist. + fn collision(&self, name: &str) -> Option { + if name == ENVIRONMENT_SEAM_NAME { + Some(OverlayNameKind::EnvironmentSeam) + } else if name.starts_with(INTERNAL_NAME_PREFIX) { + Some(OverlayNameKind::Internal) + } else if self.variables.iter().any(|key| key == name) { + Some(OverlayNameKind::ManifestVariable) + } else if self.macros.iter().any(|macro_name| macro_name == name) { + Some(OverlayNameKind::ManifestMacro) + } else { + None + } + } +} + +/// Read the names a manifest has already claimed in the template namespace. +/// +/// A malformed `macros` section yields no macro names rather than an error: the +/// macro registration reports that diagnosis in its own step, and this function +/// must not pre-empt it with a different message. +pub(crate) fn overlay_namespace(doc: &ManifestValue) -> OverlayNamespace { + OverlayNamespace { + variables: declared_variables(doc), + macros: declared_macros(doc), + } +} + +/// Return the keys of the manifest's `vars` section. +fn declared_variables(doc: &ManifestValue) -> Vec { + // A let-else rather than `map_or_default`: Whitaker compiles this crate + // with its own older nightly, where that method is still unstable. + let Some(vars) = doc.get("vars").and_then(ManifestValue::as_object) else { + return Vec::new(); + }; + vars.keys().cloned().collect() +} + +/// Return the names of the macros the manifest defines. +fn declared_macros(doc: &ManifestValue) -> Vec { + let Some(macros) = doc.get("macros") else { + return Vec::new(); + }; + serde_json::from_value::>(macros.clone()) + .unwrap_or_default() + .iter() + .filter_map(|definition| parse_macro_name(&definition.signature).ok()) + .collect() +} + +/// Reject overlays that would take a name the manifest or loader already uses. +fn validate_overlay_names( + overlays: &TemplateOverlays, + namespace: &OverlayNamespace, +) -> Result<(), TemplateOverlayError> { + overlays + .entries() + .find_map(|(name, _callable)| { + namespace + .collision(name) + .map(|kind| TemplateOverlayError::NameCollision { + name: name.to_owned(), + kind, + }) + }) + .map_or(Ok(()), Err) +} + +/// Register a test load's overlays after every other template helper. +/// +/// Registering last is what lets an overlay replace a helper the stdlib or the +/// loader already installed. Validating first means a rejected overlay leaves +/// the environment untouched. +/// +/// # Errors +/// +/// Returns [`TemplateOverlayError::NameCollision`] when an overlay name is one +/// the manifest or the loader has already claimed. +pub(crate) fn register_overlays( + jinja: &mut Environment<'static>, + overlays: Option<&TemplateOverlays>, + namespace: &OverlayNamespace, +) -> Result<(), TemplateOverlayError> { + let Some(supplied) = overlays else { + return Ok(()); + }; + validate_overlay_names(supplied, namespace)?; + for (name, callable) in supplied.entries() { + register_overlay(jinja, name.to_owned(), callable.clone()); + } + Ok(()) +} + +/// Register one overlay, capturing the function it replaces. +fn register_overlay(jinja: &mut Environment<'static>, name: String, callable: OverlayCallable) { + let shadowed = find_registered(jinja, &name); + jinja.add_function( + name, + move |state: &State, args: Rest, kwargs: Kwargs| { + let positional = with_trailing_kwargs(args.0, &kwargs); + callable.invoke(&OverlayCall { + state, + args: &positional, + shadowed: shadowed.as_ref(), + }) + }, + ); +} + +/// Return the function already registered under `name`, if any. +/// +/// `MiniJinja` has no lookup by name, but globals enumerate and functions are +/// stored as globals, so the replaced function is still reachable and can be +/// handed to the overlay instead of being lost. +fn find_registered(jinja: &Environment<'static>, name: &str) -> Option { + jinja + .globals() + .find(|(existing, _value)| *existing == name) + .map(|(_existing, value)| value) +} + +/// Append the call's keyword arguments as one trailing value. +/// +/// `MiniJinja` hands a function its keyword arguments separately, while a value +/// made from them is extracted back as keyword arguments by whatever function +/// receives it, so appending one reproduces the original call. +fn with_trailing_kwargs(mut positional: Vec, kwargs: &Kwargs) -> Vec { + if kwargs.args().next().is_some() { + positional.push(Value::from(kwargs.clone())); + } + positional +} diff --git a/src/manifest/tests/loader_options.rs b/src/manifest/tests/loader_options.rs new file mode 100644 index 000000000..4e48c0a96 --- /dev/null +++ b/src/manifest/tests/loader_options.rs @@ -0,0 +1,178 @@ +//! Exhaustive decision table for the manifest loader's registration modes. +//! +//! `StdlibRegistration` is the single place that decides a load's host-facing +//! capabilities (C5), so the decision is asserted directly here rather than +//! inferred from rendered output. The table is exhaustive over the variants +//! that exist, which is why a bounded model checker would add nothing. +//! +//! The `Test` row asserts the deny-by-default surface: `env()` is real over an +//! injected reader, `glob()` refuses, and the mode neither renders in query +//! mode, records normal-load telemetry, nor classifies query refusals. It is +//! the only row that grants template overlays, which is the counterpart of the +//! refusing stubs: an overlay stands in for a helper the surface withheld. + +use rstest::rstest; + +use crate::manifest::loader_options::HostHelperDisposition; +use crate::manifest::{ + EnvAccessPolicy, ManifestBudgetLimits, ManifestEnvironment, ManifestLoadOptions, ManifestName, + StdlibRegistration, TestSurface, from_str_with_options, process_env_reader, +}; +use crate::stdlib::{RestrictedSurface, StdlibConfig}; + +/// A manifest that reads and acts on nothing, so every mode can load it. +const PURE_MANIFEST: &str = concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: pure\n", + " description: \"joined {{ ['a', 'b'] | join('-') }}\"\n", + " command: \"echo pure\"\n", +); + +/// One registration mode under test. +#[derive(Clone, Copy, Debug)] +enum Row { + AmbientFull, + Full, + ManifestQuery, + Test, +} + +/// The six outputs `StdlibRegistration` decides for one mode. +#[derive(Clone, Copy, Debug)] +struct Expected { + env: HostHelperDisposition, + glob: HostHelperDisposition, + /// The four boolean decisions, each paired with the label the assertion + /// reports, so one table row covers them without four near-identical + /// assertions. + flags: [(&'static str, bool); 4], +} + +impl Row { + /// Build the registration this row names. + fn registration(self) -> StdlibRegistration { + match self { + Self::AmbientFull => StdlibRegistration::AmbientFull, + Self::Full => StdlibRegistration::Full(Box::new( + StdlibConfig::from_current_dir().expect("open the test workspace"), + )), + Self::ManifestQuery => StdlibRegistration::ManifestQuery, + Self::Test => StdlibRegistration::Test(Box::new(TestSurface::new( + StdlibConfig::from_current_dir().expect("open the test workspace"), + ))), + } + } + + /// Return the decisions this row must produce. + const fn expected(self) -> Expected { + match self { + Self::AmbientFull | Self::Full => Expected { + env: HostHelperDisposition::Real, + glob: HostHelperDisposition::Real, + flags: [ + ("render mode", false), + ("telemetry gate", true), + ("refusal classifier gate", false), + ("overlay grant", false), + ], + }, + Self::ManifestQuery => Expected { + env: HostHelperDisposition::Refused(RestrictedSurface::ManifestQuery), + glob: HostHelperDisposition::Refused(RestrictedSurface::ManifestQuery), + flags: [ + ("render mode", true), + ("telemetry gate", false), + ("refusal classifier gate", true), + ("overlay grant", false), + ], + }, + Self::Test => Expected { + env: HostHelperDisposition::Real, + glob: HostHelperDisposition::Refused(RestrictedSurface::Test), + flags: [ + ("render mode", false), + ("telemetry gate", false), + ("refusal classifier gate", false), + ("overlay grant", true), + ], + }, + } + } +} + +#[rstest] +#[case(Row::AmbientFull)] +#[case(Row::Full)] +#[case(Row::ManifestQuery)] +#[case(Row::Test)] +fn registration_decides_each_capability(#[case] row: Row) { + let registration = row.registration(); + let expected = row.expected(); + + assert_eq!( + registration.env_disposition(), + expected.env, + "env() disposition for {row:?}" + ); + assert_eq!( + registration.glob_disposition(), + expected.glob, + "glob() disposition for {row:?}" + ); + assert_eq!( + [ + ("render mode", registration.uses_query_rendering()), + ( + "telemetry gate", + registration.records_expansion_and_budget_telemetry() + ), + ( + "refusal classifier gate", + registration.classifies_query_refusals() + ), + ("overlay grant", registration.overlays().is_some()), + ], + expected.flags, + "boolean decisions for {row:?}" + ); +} + +#[test] +fn from_str_with_options_loads_a_full_manifest() { + let reader = process_env_reader(); + let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); + let config = StdlibConfig::from_current_dir().expect("open the test workspace"); + let options = ManifestLoadOptions::full(config, environment); + + let manifest = from_str_with_options(PURE_MANIFEST, &ManifestName::new("Netsukefile"), options) + .expect("load the pure manifest"); + + assert_eq!(manifest.targets.len(), 1); + let target = manifest.targets.first().expect("one target was loaded"); + assert_eq!(target.name.to_string_vec(), vec!["pure".to_owned()]); +} + +#[test] +fn builders_carry_the_budget_limits_and_stage_observer() { + let reader = process_env_reader(); + let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); + let mut stages = Vec::new(); + let mut observer = |stage| stages.push(stage); + let config = StdlibConfig::from_current_dir().expect("open the test workspace"); + let options = ManifestLoadOptions::full(config, environment) + .with_budget_limits(ManifestBudgetLimits::default()) + .with_stage_observer(&mut observer); + + from_str_with_options(PURE_MANIFEST, &ManifestName::new("Netsukefile"), options) + .expect("load the pure manifest"); + + assert_eq!( + stages, + vec![ + crate::manifest::ManifestLoadStage::InitialYamlParsing, + crate::manifest::ManifestLoadStage::TemplateExpansion, + crate::manifest::ManifestLoadStage::FinalRendering, + ] + ); +} diff --git a/src/manifest/tests/mod.rs b/src/manifest/tests/mod.rs index 77538524f..bdc8cbcbc 100644 --- a/src/manifest/tests/mod.rs +++ b/src/manifest/tests/mod.rs @@ -10,3 +10,7 @@ mod workspace; mod env; mod expansion_telemetry; mod glob; +mod loader_options; +mod overlays; +mod test_mode; +mod wrapper_equivalence; diff --git a/src/manifest/tests/overlays/mod.rs b/src/manifest/tests/overlays/mod.rs new file mode 100644 index 000000000..f25f81e02 --- /dev/null +++ b/src/manifest/tests/overlays/mod.rs @@ -0,0 +1,80 @@ +//! Contracts for the template overlays a test load may register. +//! +//! An overlay is the only way a `netsuke test` load can answer a helper the +//! surface withholds. Two claims follow, and each has its own module. +//! +//! The namespace rules are load-bearing rather than cosmetic: an overlay that +//! took `env`, a loader-owned `__netsuke_` global, a manifest `vars` key, or a +//! macro name would silently reinterpret a name the manifest or the loader +//! already gives meaning to. `namespaces` pins those rejections. +//! +//! A legitimate overlay must be visible everywhere expansion looks — +//! `foreach`, `when`, and rendered fields — and a replaced helper must still be +//! reachable through [`OverlayCall::shadowed`]. `registration` pins that. + +mod namespaces; +mod registration; + +use anyhow::{Context as _, Result}; +use minijinja::value::Value; + +use crate::ast::{NetsukeManifest, Recipe}; +use crate::manifest::{ + ManifestLoadOptions, ManifestName, OverlayCall, OverlayCallable, TemplateOverlays, TestSurface, + from_str_with_options, +}; +use crate::stdlib::StdlibConfig; + +/// Open the stdlib configuration the test workspace supplies. +fn config() -> StdlibConfig { + StdlibConfig::from_current_dir().expect("the test workspace must open") +} + +/// Load `yaml` under a test surface carrying `overlays`. +fn load_with(yaml: &str, overlays: TemplateOverlays) -> Result { + from_str_with_options( + yaml, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::test(TestSurface::new(config()).with_overlays(overlays)), + ) +} + +/// Load `yaml` under a test surface with no overlays. +fn load(yaml: &str) -> Result { + from_str_with_options( + yaml, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::test(TestSurface::new(config())), + ) +} + +/// Register one overlay whose dispatch ignores its call and returns `value`. +fn constant_overlay(name: &str, value: Value) -> TemplateOverlays { + TemplateOverlays::default().with_function( + name, + OverlayCallable::new(move |_call: &OverlayCall<'_, '_>| Ok(value.clone())), + ) +} + +/// Return the names of every target in `manifest`. +fn target_names(manifest: &NetsukeManifest) -> Vec { + manifest + .targets + .iter() + .flat_map(|target| target.name.to_string_vec()) + .collect() +} + +/// Return the single rendered command of a loaded probe manifest. +fn rendered_command(manifest: &NetsukeManifest) -> Result<&str> { + let target = manifest + .targets + .first() + .context("the probe has one target")?; + match &target.recipe { + Recipe::Command { command, .. } => command + .as_single() + .context("the probe command must render to one string"), + other => anyhow::bail!("the probe target is not a command: {other:?}"), + } +} diff --git a/src/manifest/tests/overlays/namespaces.rs b/src/manifest/tests/overlays/namespaces.rs new file mode 100644 index 000000000..84c0a0086 --- /dev/null +++ b/src/manifest/tests/overlays/namespaces.rs @@ -0,0 +1,119 @@ +//! Namespace rules an overlay name must respect. +//! +//! Every rejection row loads the same manifest, so the only thing that differs +//! between rows is the overlay name. The accepted rows prove the check is a +//! namespace rule rather than a blanket refusal: `glob` is registered by the +//! loader, and replacing it is exactly why overlays exist. + +use anyhow::{Context as _, Result, ensure}; +use minijinja::value::Value; +use rstest::rstest; + +use super::{constant_overlay, load_with, target_names}; +use crate::manifest::{OverlayNameKind, TemplateOverlayError}; + +/// The manifest every rejection row loads. +/// +/// It declares a `vars` key and a macro so each namespace has a name to +/// collide with, and one target so the load has something to render. +const NAMESPACED_MANIFEST: &str = concat!( + "netsuke_version: \"1.0.0\"\n", + "vars:\n", + " greeting: hello\n", + "macros:\n", + " - signature: \"shout(text)\"\n", + " body: \"{{ text | upper }}\"\n", + "targets:\n", + " - name: probe\n", + " command: \"echo probe\"\n", +); + +/// Return the typed collision a load reported, if any. +fn collision(error: &anyhow::Error) -> Option<&TemplateOverlayError> { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) +} + +/// Assert a load rejected `name` because it collided with `kind`. +fn assert_collision(name: &str, kind: OverlayNameKind) -> Result<()> { + let overlays = constant_overlay(name, Value::from(true)); + let error = load_with(NAMESPACED_MANIFEST, overlays) + .err() + .with_context(|| format!("the overlay `{name}` must be rejected"))?; + match collision(&error) { + Some(TemplateOverlayError::NameCollision { + name: reported, + kind: reported_kind, + }) => { + ensure!( + reported == name, + "the load named `{reported}` rather than `{name}`" + ); + ensure!( + *reported_kind == kind, + "`{name}` was classified {reported_kind:?}, not {kind:?}" + ); + } + other => anyhow::bail!("`{name}` carried no typed collision: {other:?}"), + } + let text = error.to_string(); + ensure!( + text.contains("netsuke::manifest::overlay::collision"), + "the diagnostic did not lead with the collision code: {text}" + ); + ensure!( + text.contains(name), + "the diagnostic did not name `{name}`: {text}" + ); + Ok(()) +} + +/// An overlay may not take the loader's environment seam. +#[test] +fn overlay_named_env_is_rejected() -> Result<()> { + assert_collision("env", OverlayNameKind::EnvironmentSeam) +} + +/// An overlay may not take a global the loader owns by prefix. +#[test] +fn overlay_named_with_the_internal_prefix_is_rejected() -> Result<()> { + assert_collision( + "__netsuke_manifest_macro_imports", + OverlayNameKind::Internal, + ) +} + +/// An overlay may not take a name the manifest's `vars` section declares. +#[test] +fn overlay_named_after_a_manifest_variable_is_rejected() -> Result<()> { + assert_collision("greeting", OverlayNameKind::ManifestVariable) +} + +/// An overlay may not take a name the manifest's `macros` section declares. +#[test] +fn overlay_named_after_a_manifest_macro_is_rejected() -> Result<()> { + assert_collision("shout", OverlayNameKind::ManifestMacro) +} + +/// An overlay may take a name the loader does not reserve. +/// +/// `glob()` is loader-owned but not reserved: standing in for it is the reason +/// the overlay mechanism exists. A name nothing claims collides with nothing. +/// Both must load, or the rejection rows above would be indistinguishable from +/// a blanket refusal. +#[rstest] +#[case::loader_helper("glob")] +#[case::fresh_name("brand_new_helper")] +fn overlay_may_take_an_unreserved_name(#[case] name: &str) -> Result<()> { + let manifest = load_with( + NAMESPACED_MANIFEST, + constant_overlay(name, Value::from(true)), + )?; + ensure!( + target_names(&manifest) == ["probe"], + "the load did not complete: {:?}", + target_names(&manifest) + ); + Ok(()) +} diff --git a/src/manifest/tests/overlays/registration.rs b/src/manifest/tests/overlays/registration.rs new file mode 100644 index 000000000..f1a872a0d --- /dev/null +++ b/src/manifest/tests/overlays/registration.rs @@ -0,0 +1,245 @@ +//! Behaviour a legitimate overlay must have once it is registered. +//! +//! An overlay is only useful if expansion sees it everywhere a helper would be +//! seen — `foreach`, `when`, and rendered fields — and if a replaced helper +//! stays reachable through [`OverlayCall::shadowed`] instead of being lost. + +use std::error::Error as _; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use anyhow::{Context as _, Result, ensure}; +use minijinja::value::Value; +use minijinja::{Error as MiniJinjaError, ErrorKind}; + +use super::{constant_overlay, load, load_with, rendered_command, target_names}; +use crate::manifest::{OverlayCall, OverlayCallable, TemplateOverlays}; +use crate::stdlib::TestModeRefusal; + +/// The manifest whose `foreach` calls the refused `glob()` helper. +const GLOB_MANIFEST: &str = concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - foreach: glob('glob_files/*.txt')\n", + " name: \"{{ item | replace('glob_files/', '') | replace('.txt', '.out') }}\"\n", + " command: \"echo {{ item }}\"\n", +); + +/// An overlay `glob` drives a `foreach` the surface would otherwise refuse. +#[test] +fn overlay_glob_drives_a_foreach() -> Result<()> { + ensure!( + load(GLOB_MANIFEST).is_err(), + "the probe is vacuous unless `glob()` refuses without the overlay" + ); + + let manifest = load_with( + GLOB_MANIFEST, + constant_overlay( + "glob", + Value::from_serialize(["glob_files/a.txt", "glob_files/b.txt"]), + ), + )?; + + ensure!( + target_names(&manifest) == ["a.out", "b.out"], + "the overlay did not drive the expansion: {:?}", + target_names(&manifest) + ); + Ok(()) +} + +/// The manifest whose `when` calls the refused `which()` helper. +const WHICH_MANIFEST: &str = concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: probe\n", + " command: \"echo probe\"\n", + " when: \"which('probe-tool')\"\n", +); + +/// An overlay `which` decides a `when` the surface would otherwise refuse. +#[test] +fn overlay_which_decides_a_when() -> Result<()> { + ensure!( + load(WHICH_MANIFEST).is_err(), + "the probe is vacuous unless `which()` refuses without the overlay" + ); + + let kept = load_with(WHICH_MANIFEST, constant_overlay("which", Value::from(true)))?; + ensure!( + target_names(&kept) == ["probe"], + "a true overlay must keep the target: {:?}", + target_names(&kept) + ); + + let dropped = load_with( + WHICH_MANIFEST, + constant_overlay("which", Value::from(false)), + )?; + ensure!( + target_names(&dropped).is_empty(), + "a false overlay must filter the target: {:?}", + target_names(&dropped) + ); + Ok(()) +} + +/// The manifest whose command calls the overlay. +const COMMAND_MANIFEST: &str = concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: probe\n", + " command: \"echo {{ greet('world') }}\"\n", +); + +/// An overlay renders wherever a helper would. +#[test] +fn overlay_renders_in_a_command() -> Result<()> { + let manifest = load_with( + COMMAND_MANIFEST, + TemplateOverlays::default().with_function( + "greet", + OverlayCallable::new(|call: &OverlayCall<'_, '_>| { + let who = call + .args() + .first() + .ok_or_else(|| { + MiniJinjaError::new( + ErrorKind::InvalidOperation, + "the manifest passes one argument", + ) + })? + .to_string(); + Ok(Value::from(format!("hello {who}"))) + }), + ), + )?; + + ensure!( + rendered_command(&manifest)? == "echo hello world", + "the overlay did not render: {}", + rendered_command(&manifest)? + ); + Ok(()) +} + +/// The manifest whose command calls the available `timedelta()` helper. +const TIMEDELTA_MANIFEST: &str = concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: probe\n", + " command: \"echo {{ timedelta(days=1) }}\"\n", +); + +/// Forwarding to the shadowed helper reproduces the call it displaced. +/// +/// The call count is asserted as well as the equality: an overlay that was +/// never dispatched would leave the guard passing on the stdlib's own answer, +/// which is the vacuous form a seeded registration-order fault produces. +#[test] +fn passthrough_overlay_returns_the_shadowed_result() -> Result<()> { + let shadowed = load(TIMEDELTA_MANIFEST)?; + let calls = Arc::new(AtomicUsize::new(0)); + let dispatched = Arc::clone(&calls); + let overlaid = load_with( + TIMEDELTA_MANIFEST, + TemplateOverlays::default().with_function( + "timedelta", + OverlayCallable::new(move |call: &OverlayCall<'_, '_>| { + dispatched.fetch_add(1, Ordering::SeqCst); + let replaced = call.shadowed().ok_or_else(|| { + MiniJinjaError::new(ErrorKind::InvalidOperation, "`timedelta` is registered") + })?; + replaced.call(call.state(), call.args()) + }), + ), + )?; + + ensure!( + calls.load(Ordering::SeqCst) == 1, + "the overlay was dispatched {} times, so the comparison is vacuous", + calls.load(Ordering::SeqCst) + ); + ensure!( + rendered_command(&shadowed)? == rendered_command(&overlaid)?, + "forwarding changed the rendered command: {} vs {}", + rendered_command(&shadowed)?, + rendered_command(&overlaid)? + ); + Ok(()) +} + +/// The manifest whose command calls the refused `now()` helper. +const NOW_MANIFEST: &str = concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: probe\n", + " command: \"echo {{ now() }}\"\n", +); + +/// Return the operation named by a test-mode refusal in an error chain. +fn refusal_operation(error: &anyhow::Error) -> Option<&str> { + error.chain().find_map(|cause| { + let jinja = cause.downcast_ref::()?; + jinja + .source()? + .downcast_ref::() + .map(TestModeRefusal::operation) + }) +} + +/// A refusal stub is handed to its overlay as the shadowed function. +#[test] +fn overlay_now_sees_the_refusing_stub() -> Result<()> { + ensure!( + load(NOW_MANIFEST).is_err(), + "the probe is vacuous unless `now()` refuses under the default clock" + ); + + let calls = Arc::new(AtomicUsize::new(0)); + let dispatched = Arc::clone(&calls); + let overlays = TemplateOverlays::default().with_function( + "now", + OverlayCallable::new(move |call: &OverlayCall<'_, '_>| { + dispatched.fetch_add(1, Ordering::SeqCst); + let replaced = call.shadowed().ok_or_else(|| { + MiniJinjaError::new(ErrorKind::InvalidOperation, "`now` is registered") + })?; + replaced.call(call.state(), call.args()) + }), + ); + + let error = load_with(NOW_MANIFEST, overlays) + .err() + .context("forwarding to the refusing stub must fail the load")?; + ensure!( + calls.load(Ordering::SeqCst) == 1, + "the overlay was dispatched {} times, so the stack below is the stub's own", + calls.load(Ordering::SeqCst) + ); + ensure!( + refusal_operation(&error) == Some("now"), + "the forward did not reach the `now` refusal: {error:#}" + ); + Ok(()) +} + +/// An empty overlay set is indistinguishable from supplying none. +#[test] +fn default_overlays_equal_no_overlays() -> Result<()> { + let plain = load(TIMEDELTA_MANIFEST)?; + let empty = load_with(TIMEDELTA_MANIFEST, TemplateOverlays::default())?; + + ensure!( + target_names(&plain) == target_names(&empty), + "the empty overlay set changed the targets: {:?} vs {:?}", + target_names(&plain), + target_names(&empty) + ); + ensure!( + rendered_command(&plain)? == rendered_command(&empty)?, + "the empty overlay set changed the rendered command" + ); + Ok(()) +} diff --git a/src/manifest/tests/test_mode.rs b/src/manifest/tests/test_mode.rs new file mode 100644 index 000000000..6eb9f5346 --- /dev/null +++ b/src/manifest/tests/test_mode.rs @@ -0,0 +1,368 @@ +//! Contracts for manifest loads under the deny-by-default test surface. +//! +//! The unit rows in `restricted_helpers` observe the surface directly; this +//! module observes the same surface where a manifest author meets it, through +//! `from_str_with_options`. Three behaviours are pinned here. +//! +//! `env()` reads only the surface's own reader. With none supplied it answers +//! every lookup as absent without consulting the process, which is the property +//! that keeps a test load reproducible on a machine whose environment differs +//! from the author's. +//! +//! `now()` is available exactly when the caller injected a clock. The ambient +//! clock refuses, because an unrepeatable timestamp would make a test result +//! depend on when it ran; an explicitly injected clock — including +//! `system_clock()`, which is an injection even though it reads the host — is +//! admitted. +//! +//! A refusal raised inside `when`, directly or through a macro, fails the load +//! rather than marking the entry conditional. That classification belongs to +//! manifest query loads alone, so the test surface must not inherit it. + +use std::error::Error as _; + +use anyhow::{Context as _, Result, ensure}; +use metrics_util::{MetricKind, debugging::DebuggingRecorder}; +use minijinja::Error as MiniJinjaError; +use mockable::{DefaultEnv, Env}; +use time::macros::datetime; + +use crate::ast::Recipe; +use crate::manifest::{ + EnvAccessPolicy, EnvReadError, EnvReader, ManifestBudgetLimits, ManifestEnvironment, + ManifestLoadOptions, ManifestName, TestSurface, from_str_with_options, process_env_reader, +}; +use crate::stdlib::{StdlibConfig, fixed_clock, system_clock}; + +/// The instant the fixed-clock rows pin. +const FIXED_INSTANT: time::OffsetDateTime = datetime!(2026-06-08 12:00:00 UTC); + +/// Open the stdlib configuration the test workspace supplies. +fn config() -> StdlibConfig { + StdlibConfig::from_current_dir().expect("the test workspace must open") +} + +/// Build a one-target manifest whose command interpolates `expression`. +fn command_manifest(expression: &str) -> String { + format!( + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: probe\n", + " command: \"echo {}\"\n", + ), + expression + ) +} + +/// Load `yaml` under the surface `surface` describes. +fn load(yaml: &str, surface: TestSurface) -> Result { + from_str_with_options( + yaml, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::test(surface), + ) +} + +/// Build the build-surface options the telemetry rows compare against. +fn full_options(reader: &EnvReader) -> ManifestLoadOptions<'_> { + ManifestLoadOptions::full( + config(), + ManifestEnvironment::new(reader, EnvAccessPolicy::default()), + ) +} + +/// Load `yaml` through the build registration. +fn load_full(yaml: &str) -> Result { + let reader = process_env_reader(); + from_str_with_options( + yaml, + &ManifestName::new("Netsukefile"), + full_options(&reader), + ) +} + +/// Build a manifest that filters every foreach target. +fn filtered_manifest() -> String { + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: \"out-{{ item }}\"\n", + " command: \"echo {{ item }}\"\n", + " foreach:\n", + " - alpha\n", + " - beta\n", + " when: \"false\"\n", + ) + .to_owned() +} + +/// Run `run` under a local recorder and return its counters' names. +fn recorded_counters(run: impl FnOnce() -> T) -> (T, Vec) { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let outcome = metrics::with_local_recorder(&recorder, run); + let names = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.kind() == MetricKind::Counter) + .map(|(key, ..)| key.key().name().to_owned()) + .collect(); + (outcome, names) +} + +/// The counters a test load must not contribute to. +const TRACKED_COUNTERS: [&str; 4] = [ + "netsuke_manifest_filtered_targets_total", + "netsuke_manifest_filtered_actions_total", + "netsuke_manifest_omitted_filtered_entries_total", + "netsuke_manifest_budget_exhausted_total", +]; + +/// Return the tracked counters present in `names`. +fn tracked(names: &[String]) -> Vec<&str> { + names + .iter() + .map(String::as_str) + .filter(|name| TRACKED_COUNTERS.contains(name)) + .collect() +} + +/// A test load expands and filters without emitting expansion telemetry. +#[test] +fn test_load_emits_no_expansion_telemetry() -> Result<()> { + let yaml = filtered_manifest(); + let (test_result, test_counters) = + recorded_counters(|| load(&yaml, TestSurface::new(config()))); + ensure!( + test_result?.targets.is_empty(), + "the probe is vacuous unless the load actually filters a target" + ); + ensure!( + tracked(&test_counters).is_empty(), + "a test load recorded expansion telemetry: {test_counters:?}" + ); + + let (full_result, full_counters) = recorded_counters(|| load_full(&yaml)); + ensure!( + full_result?.targets.is_empty(), + "the probe must filter in both modes" + ); + ensure!( + tracked(&full_counters).contains(&"netsuke_manifest_filtered_targets_total"), + "the build load recorded no expansion telemetry, so the comparison is vacuous: \ + {full_counters:?}" + ); + Ok(()) +} + +/// A test load that exhausts its budget records no budget telemetry. +#[test] +fn test_load_emits_no_budget_telemetry_when_exhausted() -> Result<()> { + let yaml = command_manifest("{{ 1 }}"); + let limits = ManifestBudgetLimits { + source_bytes: 8, + ..ManifestBudgetLimits::default() + }; + let (test_result, test_counters) = recorded_counters(|| { + from_str_with_options( + &yaml, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::test(TestSurface::new(config())).with_budget_limits(limits), + ) + }); + ensure!( + test_result.is_err(), + "the probe is vacuous unless the budget exhausts" + ); + ensure!( + tracked(&test_counters).is_empty(), + "an exhausted test load recorded telemetry: {test_counters:?}" + ); + + let reader = process_env_reader(); + let (full_result, full_counters) = recorded_counters(|| { + from_str_with_options( + &yaml, + &ManifestName::new("Netsukefile"), + full_options(&reader).with_budget_limits(limits), + ) + }); + ensure!( + full_result.is_err(), + "the budget must exhaust in both modes" + ); + ensure!( + tracked(&full_counters).contains(&"netsuke_manifest_budget_exhausted_total"), + "the build load recorded no budget telemetry, so the comparison is vacuous: \ + {full_counters:?}" + ); + Ok(()) +} + +/// Return the single rendered command of a loaded probe manifest. +fn rendered_command(manifest: &crate::ast::NetsukeManifest) -> Result<&str> { + let target = manifest + .targets + .first() + .context("the probe has one target")?; + match &target.recipe { + Recipe::Command { command, .. } => command + .as_single() + .context("the probe command must render to one string"), + other => anyhow::bail!("the probe target is not a command: {other:?}"), + } +} + +/// Return the operation named by a test-mode refusal in an error chain. +fn refusal_operation(error: &anyhow::Error) -> Option<&str> { + error.chain().find_map(|cause| { + let jinja = cause.downcast_ref::()?; + jinja + .source()? + .downcast_ref::() + .map(crate::stdlib::TestModeRefusal::operation) + }) +} + +/// A test load with no reader treats the process environment as absent. +#[test] +fn test_load_without_a_reader_reads_no_process_environment() -> Result<()> { + ensure!( + DefaultEnv.raw("PATH").is_ok(), + "the probe is vacuous unless PATH is set in the test process" + ); + + let manifest = load( + &command_manifest("{{ env('PATH', default='unset') }}"), + TestSurface::new(config()), + )?; + + ensure!( + rendered_command(&manifest)? == "echo unset", + "the surface consulted the process environment: {}", + rendered_command(&manifest)? + ); + Ok(()) +} + +/// A surface reader supplies the value `env()` returns. +#[test] +fn test_load_reads_the_supplied_surface_reader() -> Result<()> { + let reader: EnvReader = std::sync::Arc::new(|name: &str| { + if name == "PROFILE" { + Ok("from-reader".to_owned()) + } else { + Err(EnvReadError::NotPresent) + } + }); + + let manifest = load( + &command_manifest("{{ env('PROFILE', default='unset') }}"), + TestSurface::new(config()).with_env_reader(reader), + )?; + + ensure!( + rendered_command(&manifest)? == "echo from-reader", + "the surface ignored its reader: {}", + rendered_command(&manifest)? + ); + Ok(()) +} + +/// An injected fixed clock is what `now()` reports. +#[test] +fn test_load_renders_now_from_an_injected_fixed_clock() -> Result<()> { + let manifest = load( + &command_manifest("{{ now() }}"), + TestSurface::new(config().with_clock(fixed_clock(FIXED_INSTANT))), + )?; + + ensure!( + rendered_command(&manifest)? == "echo 2026-06-08T12:00:00Z", + "the injected clock was not used: {}", + rendered_command(&manifest)? + ); + Ok(()) +} + +/// Explicitly injecting the system clock keeps `now()` real. +#[test] +fn test_load_admits_now_when_the_system_clock_is_injected() -> Result<()> { + let manifest = load( + &command_manifest("{{ now() }}"), + TestSurface::new(config().with_clock(system_clock())), + )?; + + let rendered = rendered_command(&manifest)? + .strip_prefix("echo ") + .context("the probe rendered a timestamp")?; + time::OffsetDateTime::parse(rendered, &time::format_description::well_known::Rfc3339) + .with_context(|| { + format!("the injected system clock did not render an instant: {rendered}") + })?; + Ok(()) +} + +/// The ambient clock refuses, naming `now` in a typed refusal. +#[test] +fn test_load_refuses_now_with_the_default_clock() -> Result<()> { + let error = load(&command_manifest("{{ now() }}"), TestSurface::new(config())) + .expect_err("the ambient clock must refuse under test"); + + ensure!( + refusal_operation(&error) == Some("now"), + "the ambient clock did not produce a typed `now` refusal: {error:#}" + ); + Ok(()) +} + +/// Build a manifest whose `when` calls a refused helper directly. +fn when_manifest(condition: &str, macros: &str) -> String { + format!( + concat!( + "netsuke_version: \"1.0.0\"\n", + "{macros}", + "targets:\n", + " - name: probe\n", + " command: \"echo probe\"\n", + " when: \"{condition}\"\n", + ), + macros = macros, + condition = condition + ) +} + +/// A refusal in `when` fails the load instead of marking it conditional. +#[test] +fn a_refusal_inside_when_fails_the_load() -> Result<()> { + let yaml = when_manifest("command_available('rustc')", ""); + let error = load(&yaml, TestSurface::new(config())) + .expect_err("a refused helper in `when` must fail the load"); + + ensure!( + refusal_operation(&error) == Some("command_available"), + "the `when` failure was not a typed refusal: {error:#}" + ); + Ok(()) +} + +/// A refusal reached through a macro called from `when` also fails the load. +#[test] +fn a_refusal_inside_a_macro_called_from_when_fails_the_load() -> Result<()> { + let macros = concat!( + "macros:\n", + " - signature: \"leaky()\"\n", + " body: \"{{ fetch('https://example.invalid/probe') }}\"\n", + ); + let yaml = when_manifest("leaky()", macros); + let error = load(&yaml, TestSurface::new(config())) + .expect_err("a refused helper inside a macro must fail the load"); + + ensure!( + refusal_operation(&error) == Some("fetch"), + "the macro failure was not a typed refusal: {error:#}" + ); + Ok(()) +} diff --git a/src/manifest/tests/vars/reserved/property.rs b/src/manifest/tests/vars/reserved/property.rs index 2ae5b7eee..60ca43fa6 100644 --- a/src/manifest/tests/vars/reserved/property.rs +++ b/src/manifest/tests/vars/reserved/property.rs @@ -7,7 +7,8 @@ //! handwritten example. use crate::manifest::{ - ManifestError, ManifestName, ManifestValue, RESERVED_VAR_NAMES, register_manifest_vars, + ManifestError, ManifestName, ManifestValue, RESERVED_VAR_NAMES, overlay_namespace, + register_manifest_vars, }; use minijinja::Environment; use proptest::prelude::*; @@ -71,10 +72,15 @@ fn doc_with_vars(vars: Map) -> ManifestValue { /// Register `vars` into a fresh environment, returning it alongside the result. fn register(vars: Map) -> (Environment<'static>, Result<(), ManifestError>) { let mut jinja = Environment::new(); + let doc = doc_with_vars(vars); + // Built exactly as the loader builds it, so the property exercises the + // namespace the real reserved-name check reads. + let namespace = overlay_namespace(&doc); let result = register_manifest_vars( - &doc_with_vars(vars), + &doc, &mut jinja, &ManifestName::new("Netsukefile"), + &namespace, ); (jinja, result) } diff --git a/src/manifest/tests/wrapper_equivalence.rs b/src/manifest/tests/wrapper_equivalence.rs new file mode 100644 index 000000000..f5b8d0db0 --- /dev/null +++ b/src/manifest/tests/wrapper_equivalence.rs @@ -0,0 +1,176 @@ +//! What the crate-internal manifest-query entry point produces today. +//! +//! `from_path_for_manifest_query_with_limits` is the loader behind +//! `netsuke help targets`, and it is crate-private, so the public suite in +//! `tests/manifest_load_options/wrapper_equivalence/` cannot reach it. It +//! needs its own characterization for the same reason the public entries do: +//! the refactor that replaces the shared loader core must leave it producing +//! exactly what it produces now, and the pre-refactor code is the only +//! trustworthy oracle. Comparing it with the core afterwards would be equal by +//! construction. +//! +//! # Why this row cannot reuse the public fixtures +//! +//! The public suite's fixtures live in the `tests/` integration crate, which +//! `src/` cannot import. The corpus and its staging are therefore restated +//! here — deliberately not shared — and the manifests are staged inline rather +//! than read from `tests/data`. +//! +//! That restatement is affordable because this entry point admits a small +//! share of the public corpus. It builds its own environment from +//! `disabled_env_reader`, so no fixture that calls `env()` is loadable; it +//! registers the query surface, so `fetch`, `shell`, and `glob` are refused +//! rather than exercised; and it takes no `StdlibConfig`, so no clock can be +//! injected and `now()` is a refusal too. What remains is exactly what this +//! module stages: a pure manifest, the malformed-YAML path, the Boolean +//! interpolation that separates the query surface from the build surface, and +//! the budget-exhaustion path. +//! +//! # Naming +//! +//! The snapshot is named `from_path_with_limits` rather than after this +//! module, and the settings drop insta's module prefix, so the file's name and +//! its directory depend only on what is being asserted. The entry point's body +//! lives in `src/manifest/query.rs`, so a name carrying this test module's path +//! would be tied to a test layout rather than to the assertion, and reorganizing +//! the suite would read as a behaviour change. + +use crate::manifest::{self, ManifestBudgetLimits}; +use crate::snapshot_test_support::stable_snapshot_settings; +use anyhow::{Context, Result}; +use rstest::rstest; +use test_support::fs as test_fs; + +/// Stage one manifest in a fresh temporary workspace. +/// +/// The query entry point reads from a workspace path, and it refuses to leave +/// the manifest's own directory, so an inline manifest still needs a directory +/// to itself. Staging one also keeps the rows independent: nothing a row writes +/// can be seen by another. +/// +/// # Errors +/// +/// Returns an error when the temporary directory cannot be created or when the +/// manifest cannot be written. +fn staged(manifest_yaml: &str) -> Result<(tempfile::TempDir, std::path::PathBuf)> { + let dir = tempfile::tempdir().context("create a workspace for a query load")?; + let path = dir.path().join("Netsukefile"); + test_fs::write(&path, manifest_yaml)?; + Ok((dir, path)) +} + +/// Return the three fixtures every query row loads. +/// +/// The three cover the outcomes the entry point can produce: a manifest that +/// loads, a manifest that fails YAML parsing, and a manifest whose rendering +/// differs by surface. The `glob_files/` directory the fourth public fixture +/// reaches through `glob()` is absent by design — under the query surface that +/// helper is a refusal, so a fixture calling it would characterize the refusal +/// rather than the entry point. +fn cases() -> [(&'static str, &'static str); 3] { + [ + ( + "pure", + concat!( + "# Nothing here reads or acts on the host, so the query surface ", + "admits it.\n", + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: pure\n", + " description: \"joined {{ ['a', 'b'] | join('-') }}\"\n", + " command: \"echo pure\"\n", + ), + ), + ("malformed_yaml", "targets: [{name: broken\n"), + ( + "boolean_interpolation", + concat!( + "# `{{ true }}` is a Jinja literal, so `Display for Value` decides ", + "its text.\n", + "# The query registration does not install the legacy Boolean ", + "formatter the\n", + "# build registration does, so this fixture is the observable lever ", + "separating\n", + "# the two surfaces at the manifest level.\n", + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: booleans\n", + " description: \"literals: {{ true }}/{{ false }}\"\n", + " vars:\n", + " enabled: \"{{ true }}\"\n", + " disabled: \"{{ false }}\"\n", + " command: \"echo {{ enabled }} {{ disabled }}\"\n", + ), + ), + ] +} + +/// Load one fixture through the query entry point, keeping its workspace alive. +/// +/// The `error` variant is rendered with `{:#}` so the snapshot records the +/// whole `anyhow` chain rather than only its outermost context. A refactor that +/// starts wrapping or dropping a context changes that text, which is exactly +/// what the baseline exists to catch. The row's label is not threaded through +/// here: it names the row, not the load, so the caller joins it to the text. +fn outcome(path: &std::path::Path, limits: ManifestBudgetLimits) -> String { + let result = manifest::query::from_path_for_manifest_query_with_limits(path, limits, None); + match &result { + Ok(manifest) => match serde_json_canonicalizer::to_string(manifest) { + Ok(canonical) => match serde_json::from_str::(&canonical) { + Ok(value) => serde_json::to_string_pretty(&value) + .unwrap_or_else(|error| format!("")), + Err(error) => format!(""), + }, + Err(error) => format!(""), + }, + Err(error) => format!("{error:#}"), + } +} + +/// Snapshot what the crate-internal query entry point produces. +/// +/// The budget is left at its default so the rows show the entry point's +/// ordinary behaviour. Exhaustion is a separate row below, which is what keeps +/// this one from recording a limit that is a choice of this test rather than +/// behaviour of the loader. +#[rstest] +fn from_path_with_limits_row() -> Result<()> { + let mut blocks = Vec::new(); + for (label, text) in cases() { + let (_dir, path) = staged(text)?; + let rendered = outcome(&path, ManifestBudgetLimits::default()); + blocks.push(format!("=== {label} ===\n{rendered}")); + } + let document = blocks.join("\n\n"); + stable_snapshot_settings("manifest_load_options").bind(|| { + insta::assert_snapshot!("from_path_with_limits", document); + }); + Ok(()) +} + +/// Snapshot the query entry point's budget-exhaustion path. +/// +/// Exhaustion is charged against the source bytes first, so a limit below the +/// manifest's own text fails ahead of YAML parsing and rendering. Keeping this +/// as its own row is what lets [`from_path_with_limits_row`] leave the default +/// budget in place: a limit that a test chooses would otherwise be entangled +/// with the entry point's ordinary behaviour in one snapshot, and a reviewer +/// could not tell which change moved it. +/// +/// The limit is a constant rather than a computed value, so the diagnostic is +/// repeatable. +#[rstest] +fn budget_exhaustion_row() -> Result<()> { + let limits = ManifestBudgetLimits { + source_bytes: 8, + ..ManifestBudgetLimits::default() + }; + let (label, text) = cases()[0]; + let (_dir, path) = staged(text)?; + let rendered = outcome(&path, limits); + let document = format!("=== {label} ===\n{rendered}"); + stable_snapshot_settings("manifest_load_options").bind(|| { + insta::assert_snapshot!("from_path_with_limits_exhausted_budget", document); + }); + Ok(()) +} diff --git a/src/snapshot_test_support.rs b/src/snapshot_test_support.rs index 923bbdebf..6e3bd976b 100644 --- a/src/snapshot_test_support.rs +++ b/src/snapshot_test_support.rs @@ -6,6 +6,7 @@ use insta::Settings; use rstest::fixture; use std::path::PathBuf; +use test_support::temp_path_filter::temp_dir_filter_pattern; use crate::output_mode::OutputMode; use crate::output_prefs::{OutputPrefs, resolve_from_theme_with}; @@ -35,6 +36,41 @@ pub(crate) fn snapshot_settings(subdir: &str) -> Settings { settings } +/// Build snapshot settings rooted at `src/snapshots/` with the module +/// path left out of the snapshot name. +/// +/// [`snapshot_settings`] keeps insta's module-prefixed names, which is right for +/// a suite whose modules are settled. It is wrong for a suite that a later +/// milestone moves between modules: the prefix changes with the module, so the +/// snapshot would be renamed — and a rename during a pure move reads as a +/// behaviour change. Removing the prefix makes the name depend only on the +/// assertion, so the rows survive being relocated. +/// +/// Both of a snapshot's incidental inputs are neutralized here, because both +/// would otherwise vary without the code under test varying: the module path +/// names the file, and a temporary directory reaches the text whenever a row +/// stages a workspace and an error names it. +/// +/// Callers pass a directory unique to their suite, because two rows in +/// different modules sharing one name would silently overwrite each other. +pub(crate) fn stable_snapshot_settings(subdir: &str) -> Settings { + let mut settings = snapshot_settings(subdir); + settings.set_prepend_module_to_snapshot(false); + add_temp_dir_filter(&mut settings); + settings +} + +/// Replace the temporary-directory prefix of any absolute path with a +/// placeholder. +/// +/// Resolving the directory here rather than in +/// [`temp_dir_filter_pattern`] keeps the pattern a pure function of its input, +/// so a test can assert the Windows spelling on any host. +fn add_temp_dir_filter(settings: &mut Settings) { + let temp_dir = std::env::temp_dir().display().to_string(); + settings.add_filter(&temp_dir_filter_pattern(&temp_dir), "[TMP]"); +} + /// Add a redaction filter for the Netsuke generator version. /// /// Anchor the filter on the enclosing generator object so unrelated versioned @@ -100,7 +136,18 @@ pub(crate) fn theme_prefs(theme: ThemePreference) -> OutputPrefs { #[cfg(test)] mod tests { - //! Verify that generator-version redaction remains scoped across `SemVer` values. + //! Verify the generator-version filter redacts exactly what it claims. + //! + //! Both snapshot filters are the reason a checked-in snapshot repeats on a + //! machine that did not record it: one hides the generator version across + //! `SemVer` values, the other hides the temporary directory a staged + //! workspace lives in. + //! + //! Only the version filter is exercised here, because it is the only one + //! this module still owns. The temporary-path pattern moved to + //! `test_support::temp_path_filter` and its cases moved with it, so this + //! crate and the integration suite share one set of rows rather than + //! holding a copy each. use super::*; use proptest::prelude::*; diff --git a/src/snapshots/manifest_load_options/from_path_with_limits.snap b/src/snapshots/manifest_load_options/from_path_with_limits.snap new file mode 100644 index 000000000..932dfadaf --- /dev/null +++ b/src/snapshots/manifest_load_options/from_path_with_limits.snap @@ -0,0 +1,63 @@ +--- +source: src/manifest/tests/wrapper_equivalence.rs +expression: document +--- +=== pure === +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +Manifest parse failed.: YAML parse error at line ⁨1⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 1, column 11⁩: unclosed bracket '{' at line 1, column 11 + +=== boolean_interpolation === +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo {{ enabled }} {{ disabled }}" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: True/False", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "False", + "enabled": "True" + } + } + ], + "vars": {} +} diff --git a/src/snapshots/manifest_load_options/from_path_with_limits_exhausted_budget.snap b/src/snapshots/manifest_load_options/from_path_with_limits_exhausted_budget.snap new file mode 100644 index 000000000..5fab73e12 --- /dev/null +++ b/src/snapshots/manifest_load_options/from_path_with_limits_exhausted_budget.snap @@ -0,0 +1,6 @@ +--- +source: src/manifest/tests/wrapper_equivalence.rs +expression: document +--- +=== pure === +invalid operation: Manifest resource budget exhausted during ⁨source⁩ after reaching ⁨8⁩.: manifest resource budget exhausted at Source (limit 8) diff --git a/src/snapshots/manifest_load_options/refusals.snap b/src/snapshots/manifest_load_options/refusals.snap new file mode 100644 index 000000000..9f6fbf2e9 --- /dev/null +++ b/src/snapshots/manifest_load_options/refusals.snap @@ -0,0 +1,72 @@ +--- +source: src/stdlib/register/refusal_snapshots.rs +expression: document +--- +=== 00 env: {{ env('PATH') }} === +env is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 01 glob: {{ glob('glob_files/*.txt') }} === +glob is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 02 fetch: {{ fetch('https://example.invalid/toolchain.json') }} === +fetch is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 03 shell: {{ '' | shell('echo fixture') }} === +shell is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 04 grep: {{ 'x' | grep('x') }} === +grep is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 05 contents: {{ 'Cargo.toml' | contents }} === +contents is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 06 which: {{ 'cargo' | which }} === +which is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 07 which: {{ which('cargo') }} === +which is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 08 command_available: {{ command_available('cargo') }} === +command_available is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 09 now: {{ now() }} === +now is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 10 realpath: {{ '.' | realpath }} === +realpath is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 11 expanduser: {{ '~' | expanduser }} === +expanduser is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 12 size: {{ 'Cargo.toml' | size }} === +size is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 13 linecount: {{ 'Cargo.toml' | linecount }} === +linecount is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 14 hash: {{ 'Cargo.toml' | hash }} === +hash is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 15 digest: {{ 'Cargo.toml' | digest }} === +digest is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 16 dir: {{ 'Cargo.toml' is dir }} === +dir is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 17 file: {{ 'Cargo.toml' is file }} === +file is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 18 symlink: {{ 'Cargo.toml' is symlink }} === +symlink is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 19 pipe: {{ 'Cargo.toml' is pipe }} === +pipe is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 20 block_device: {{ 'Cargo.toml' is block_device }} === +block_device is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 21 char_device: {{ 'Cargo.toml' is char_device }} === +char_device is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers + +=== 22 device: {{ 'Cargo.toml' is device }} === +device is disabled while rendering `netsuke help targets`; manifest queries permit only non-disclosing, side-effect-free template helpers diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 86279a5bf..036f45528 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -16,6 +16,7 @@ mod network; mod path; mod recipe_text; mod register; +mod restricted_helpers; mod time; mod which; @@ -34,8 +35,13 @@ pub use network::{ }; pub use path::{FILE_READ_FILTER_VALUES, FILE_READ_OUTCOME_VALUES, FILE_READ_TOTAL}; pub use recipe_text::{DIALECT_SOURCE_VALUES, DIALECT_VALUES, SHELL_QUOTE_DIALECT_TOTAL}; -pub(crate) use register::{is_manifest_query_disabled_error, register_manifest_query}; +pub(crate) use register::register_manifest_query; pub use register::{register, register_with_config, value_from_bytes}; +pub use restricted_helpers::TestModeRefusal; +pub(crate) use restricted_helpers::{ + RestrictedSurface, is_manifest_query_disabled_error, register_env_refusal, register_for_test, + register_glob_refusal, +}; pub use time::{ClockInstant, ClockProvider, fixed_clock, system_clock}; pub use which::{ RESOLVE_ERROR_CATEGORY_VALUES, WHICH_CACHE_OUTCOME_VALUES, WHICH_CACHE_TOTAL, diff --git a/src/stdlib/register.rs b/src/stdlib/register.rs index 41f177316..f1fbcefe8 100644 --- a/src/stdlib/register.rs +++ b/src/stdlib/register.rs @@ -7,7 +7,8 @@ //! alongside `StdlibConfig` and `NetworkConfig`. use super::{ - StdlibConfig, StdlibState, collections, command, network, path, recipe_text, time, + StdlibConfig, StdlibState, collections, command, network, path, recipe_text, + restricted_helpers, time, which::{self, WhichConfig, WorkspaceSkipList}, }; use anyhow::Context; @@ -24,26 +25,6 @@ use std::sync::Arc; use crate::localization::{self, keys}; use crate::shell_word::ShellDialect; -#[path = "register/query_helpers.rs"] -mod query_helpers; -pub(crate) use query_helpers::is_manifest_query_disabled_error; -use query_helpers::register_disabled_query_helpers; - -/// A template file test: a registration name paired with a capability file -/// type predicate. -type FileTest = (&'static str, fn(fs::FileType) -> bool); - -/// Stable text identifying helpers deliberately unavailable to manifest queries. -/// -/// Shared with the [`query_helpers`] child, which appends it to every -/// deliberate failure and matches on it to recognize one. It stays declared -/// here, in the parent, so both the child and this module's own consumers name -/// the same constant. -pub(super) const MANIFEST_QUERY_DISABLED_HELPER_MARKER: &str = concat!( - "is disabled while rendering `netsuke help targets`; manifest queries permit ", - "only non-disclosing, side-effect-free template helpers" -); - /// Register standard library helpers with the `MiniJinja` environment. /// /// # Examples @@ -134,7 +115,10 @@ pub fn register_with_config( } /// Preserve lowercase Boolean interpolation for existing manifests. -fn register_legacy_boolean_formatter(env: &mut Environment<'_>) { +/// +/// The test surface installs the same formatter, so a manifest that renders a +/// Boolean produces identical text under both surfaces. +pub(super) fn register_legacy_boolean_formatter(env: &mut Environment<'_>) { env.set_formatter(|out, state, value| { if value.kind() == ValueKind::Bool { out.write_str(if value.is_true() { "true" } else { "false" }) @@ -156,7 +140,7 @@ pub(crate) fn register_manifest_query(env: &mut Environment<'_>) -> StdlibState let state = StdlibState::default(); register_query_helpers(env); time::register_query_functions(env); - register_disabled_query_helpers(env); + restricted_helpers::register_query_refusals(env); state } @@ -203,39 +187,17 @@ pub fn value_from_bytes(bytes: Vec) -> Value { } } -/// The file tests registered as template tests on Unix. -/// -/// Shared with the [`query_helpers`] child, which registers the same names as -/// deliberate failures. Keeping one list means a file test added here cannot -/// reach the build surface while staying silently unregistered — and therefore -/// merely "unknown" — on the query surface. -#[cfg(unix)] -const FILE_TESTS: &[FileTest] = &[ - ("dir", is_dir), - ("file", is_file), - ("symlink", is_symlink), - ("pipe", is_fifo), - ("block_device", is_block_device), - ("char_device", is_char_device), - ("device", is_device), -]; - -/// The file tests registered as template tests on non-Unix platforms. -#[cfg(not(unix))] -const FILE_TESTS: &[FileTest] = &[ - ("dir", is_dir), - ("file", is_file), - ("symlink", is_symlink), - ("pipe", is_fifo), - ("block_device", is_block_device), - ("char_device", is_char_device), - ("device", is_device), -]; - -/// Register the `is ` file tests, treating non-string inputs as a -/// negative match. +/// Register inventoried `is ` file tests, treating non-string inputs as +/// a negative match. fn register_file_tests(env: &mut Environment<'_>) { - for &(name, pred) in FILE_TESTS { + for name in restricted_helpers::file_test_names() { + let Some(pred) = file_test_predicate(name) else { + tracing::error!( + file_test = name, + "file-test inventory has no build predicate" + ); + continue; + }; env.add_test(name, move |val: Value| -> Result { if let Some(s) = val.as_str() { return path::file_type_matches(Utf8Path::new(s), pred); @@ -248,6 +210,20 @@ fn register_file_tests(env: &mut Environment<'_>) { } } +/// Return the build-time predicate for an inventoried file-test name. +fn file_test_predicate(name: &str) -> Option bool> { + match name { + "dir" => Some(is_dir), + "file" => Some(is_file), + "symlink" => Some(is_symlink), + "pipe" => Some(is_fifo), + "block_device" => Some(is_block_device), + "char_device" => Some(is_char_device), + "device" => Some(is_device), + _ => None, + } +} + /// Test whether a file type is a directory. fn is_dir(ft: fs::FileType) -> bool { ft.is_dir() @@ -308,3 +284,28 @@ fn is_device(ft: fs::FileType) -> bool { const fn is_device(_ft: fs::FileType) -> bool { false } + +#[cfg(test)] +mod tests { + //! Guard the inventory-to-dispatch mapping this module owns. + //! + //! `register_file_tests` skips an inventoried name whose predicate is + //! missing, logging rather than failing. The skip keeps the build surface + //! running, but it would also let the inventory and the dispatch table drift + //! apart silently, so the drift is asserted here instead. + + use super::{file_test_predicate, restricted_helpers::file_test_names}; + + // A gap names the offending helper rather than a count, because the + // registration row that must change is not discoverable from a total. + #[test] + fn every_inventoried_file_test_has_a_build_predicate() { + let unmapped: Vec<&str> = file_test_names() + .filter(|name| file_test_predicate(name).is_none()) + .collect(); + assert!( + unmapped.is_empty(), + "these inventoried file tests have no `file_test_predicate` arm: {unmapped:?}" + ); + } +} diff --git a/src/stdlib/register/query_helpers.rs b/src/stdlib/register/query_helpers.rs deleted file mode 100644 index 4541496a8..000000000 --- a/src/stdlib/register/query_helpers.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Deliberate failures for helpers excluded from manifest queries. -//! -//! Manifest discovery renders `netsuke help targets`, which must not disclose -//! host state or perform side effects. Every helper that is legitimate at build -//! time but unsafe to evaluate while discovering is registered here as a stub -//! that fails with a recognizable marker, so the failure is a clear diagnostic -//! rather than an undefined-function error. -//! -//! This is a `#[path]`-declared child of `register.rs` rather than an inline -//! module: the parent sits at AGENTS.md's 400-line cap, and the query-disabled -//! cluster is the natural seam because it shares one vocabulary — the marker -//! constant, the error constructor that appends it, and the two registration -//! functions that raise it — and nothing else in the parent refers to any of -//! them. - -use super::{FILE_TESTS, MANIFEST_QUERY_DISABLED_HELPER_MARKER}; -use minijinja::{ - Environment, Error, ErrorKind, State, - value::{Kwargs, Value}, -}; - -/// Register deliberate failures for helpers excluded from manifest queries. -pub(super) fn register_disabled_query_helpers(env: &mut Environment<'_>) { - register_always_disabled_query_helpers(env); - register_host_dependent_query_helpers(env); -} - -/// Register helpers that are never safe while rendering discovery metadata. -fn register_always_disabled_query_helpers(env: &mut Environment<'_>) { - register_file_test_stubs(env); - env.add_function( - "env", - |_variable: String, _kwargs: Kwargs| -> Result { - Err(manifest_query_operation_error("env")) - }, - ); - env.add_function("glob", |_pattern: String| -> Result { - Err(manifest_query_operation_error("glob")) - }); - env.add_function( - "fetch", - |_url: String, _kwargs: Kwargs| -> Result { - Err(manifest_query_operation_error("fetch")) - }, - ); - env.add_filter( - "shell", - |_state: &State, - _value: Value, - _command: String, - _options: Option| - -> Result { Err(manifest_query_operation_error("shell")) }, - ); - env.add_filter( - "grep", - |_state: &State, - _value: Value, - _pattern: String, - _flags: Option, - _options: Option| - -> Result { Err(manifest_query_operation_error("grep")) }, - ); - env.add_filter( - "contents", - |_value: String, _encoding: Option| -> Result { - Err(manifest_query_operation_error("contents")) - }, - ); -} - -/// Register deliberate failures for the `is ` file tests. -/// -/// Each test stats the path it is given, so it discloses host state and is -/// unavailable to a discovery query. Registering the stubs from the parent's -/// own [`FILE_TESTS`] list, rather than retyping the names, keeps this stub set -/// exactly as wide as the real one: a file test that reaches the build surface -/// without a stub here would otherwise fail as merely "unknown", which a -/// name-only assertion cannot tell apart from a deliberate rejection. -fn register_file_test_stubs(env: &mut Environment<'_>) { - for &(name, _) in FILE_TESTS { - env.add_test(name, move |_value: Value| -> Result { - Err(manifest_query_operation_error(name)) - }); - } -} - -/// Register helpers whose result would disclose host state during a query. -fn register_host_dependent_query_helpers(env: &mut Environment<'_>) { - env.add_filter( - "which", - |_value: Value, _kwargs: Kwargs| -> Result { - Err(manifest_query_operation_error("which")) - }, - ); - env.add_function( - "which", - |_value: Value, _kwargs: Kwargs| -> Result { - Err(manifest_query_operation_error("which")) - }, - ); - env.add_function( - "command_available", - |_value: Value, _kwargs: Kwargs| -> Result { - Err(manifest_query_operation_error("command_available")) - }, - ); - env.add_function("now", |_kwargs: Kwargs| -> Result { - Err(manifest_query_operation_error("now")) - }); - env.add_filter("realpath", |_value: String| -> Result { - Err(manifest_query_operation_error("realpath")) - }); - env.add_filter("expanduser", |_value: String| -> Result { - Err(manifest_query_operation_error("expanduser")) - }); - env.add_filter("size", |_value: String| -> Result { - Err(manifest_query_operation_error("size")) - }); - env.add_filter("linecount", |_value: String| -> Result { - Err(manifest_query_operation_error("linecount")) - }); - env.add_filter( - "hash", - |_value: String, _algorithm: Option| -> Result { - Err(manifest_query_operation_error("hash")) - }, - ); - env.add_filter( - "digest", - |_value: String, - _length: Option, - _algorithm: Option| - -> Result { Err(manifest_query_operation_error("digest")) }, - ); -} - -/// Explain why a restricted helper is unavailable while querying a manifest. -pub(super) fn manifest_query_operation_error(operation: &str) -> Error { - Error::new( - ErrorKind::InvalidOperation, - format!("{operation} {MANIFEST_QUERY_DISABLED_HELPER_MARKER}"), - ) -} - -/// Return whether an error marks a helper intentionally unavailable in queries. -pub(crate) fn is_manifest_query_disabled_error(error: &Error) -> bool { - error.kind() == ErrorKind::InvalidOperation - && error - .to_string() - .contains(MANIFEST_QUERY_DISABLED_HELPER_MARKER) -} diff --git a/src/stdlib/restricted_helpers/mod.rs b/src/stdlib/restricted_helpers/mod.rs new file mode 100644 index 000000000..e263be2ed --- /dev/null +++ b/src/stdlib/restricted_helpers/mod.rs @@ -0,0 +1,347 @@ +//! Inventory and refuse template helpers that cross a restricted load boundary. +//! +//! Manifest query and test loads expose only explicitly permitted helpers. +//! Keeping their dispositions here makes the inventory the shared source for +//! refusals and for the build registration's file-test names. + +/// Classify the `MiniJinja` registration shape of a template helper. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HelperKind { + /// A callable written as `name(...)`. + Function, + /// A value transformation written as `value | name`. + Filter, + /// A predicate written as `value is name`. + Test, +} + +/// State whether a helper may be used during manifest-query rendering. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum QueryDisposition { + /// The helper is safe and available during manifest queries. + Available, + /// The helper must produce the established query refusal. + Refused, +} + +/// State how a helper is exposed during test-mode rendering. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TestDisposition { + /// The helper is deterministic and available during test loads. + Available, + /// The helper is unavailable until a later sandbox adapter is added. + Refused, + /// The helper is available only when the caller injects a clock. + ClockInjected, +} + +/// Describe one registered helper and its restricted-load dispositions. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct HelperEntry { + /// Name exposed to `MiniJinja`. + name: &'static str, + /// Registration shape exposed to `MiniJinja`. + kind: HelperKind, + /// Manifest-query availability. + query: QueryDisposition, + /// Test-mode availability. + test: TestDisposition, +} + +/// Select the unavailable-helper inventory for a restricted load surface. +/// +/// A surface is one capability decision, not a rendering mode: the loader reads +/// its `env()` and `glob()` dispositions from the same value so the refusal +/// vocabulary cannot disagree with itself (C5). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RestrictedSurface { + /// The side-effect-free surface used by manifest discovery. + ManifestQuery, + /// The deny-by-default surface used by `netsuke test`. + Test, +} + +impl RestrictedSurface { + /// Report whether this surface refuses one inventory entry. + fn refuses(self, entry: &HelperEntry) -> bool { + match self { + Self::ManifestQuery => entry.query == QueryDisposition::Refused, + Self::Test => entry.test == TestDisposition::Refused, + } + } +} + +/// Report whether the manifest loader owns this helper's registration. +/// +/// `env()` and `glob()` are load-wide capabilities, so the loader registers +/// them; a surface that registered them too would overwrite that decision. +/// The check names the helper kind as well as the name: a filter or test that +/// happened to share one of these names would be a separate helper, not the +/// loader's function. +fn is_loader_owned(entry: &HelperEntry) -> bool { + entry.kind == HelperKind::Function && matches!(entry.name, "env" | "glob") +} + +/// Construct one inventory row without repeating the field labels. +const fn helper( + name: &'static str, + kind: HelperKind, + query: QueryDisposition, + test: TestDisposition, +) -> HelperEntry { + HelperEntry { + name, + kind, + query, + test, + } +} + +/// Inventory template registrations whose behaviour differs by load surface. +/// +/// Keep both query and test dispositions beside each registration. Query +/// refusal stubs, the real file-test registration, and `register_for_test` +/// consume this list, and `surface_contracts` checks it in both directions: +/// every row must have a reachable probe, and every literal registration in +/// the source tree must appear here. +const SURFACE: &[HelperEntry] = &[ + helper( + "env", + HelperKind::Function, + QueryDisposition::Refused, + TestDisposition::Available, + ), + helper( + "glob", + HelperKind::Function, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "fetch", + HelperKind::Function, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "which", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "which", + HelperKind::Function, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "command_available", + HelperKind::Function, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "now", + HelperKind::Function, + QueryDisposition::Refused, + TestDisposition::ClockInjected, + ), + helper( + "timedelta", + HelperKind::Function, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "shell", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "grep", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "contents", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "realpath", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "expanduser", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "size", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "linecount", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "hash", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "digest", + HelperKind::Filter, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "basename", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "dirname", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "with_suffix", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "relative_to", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "shell_quote", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "shell_join", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "uniq", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "flatten", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "compact", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "group_by", + HelperKind::Filter, + QueryDisposition::Available, + TestDisposition::Available, + ), + helper( + "dir", + HelperKind::Test, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "file", + HelperKind::Test, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "symlink", + HelperKind::Test, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "pipe", + HelperKind::Test, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "block_device", + HelperKind::Test, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "char_device", + HelperKind::Test, + QueryDisposition::Refused, + TestDisposition::Refused, + ), + helper( + "device", + HelperKind::Test, + QueryDisposition::Refused, + TestDisposition::Refused, + ), +]; + +mod query; +mod test_mode; +pub(crate) use query::{is_manifest_query_disabled_error, register_env_refusal}; +pub use test_mode::TestModeRefusal; +pub(crate) use test_mode::register_for_test; + +/// Register the deliberate failures selected for manifest-query loads. +pub(super) fn register_query_refusals(env: &mut minijinja::Environment<'_>) { + query::register_refusals(env, RestrictedSurface::ManifestQuery); +} + +/// Register the `glob()` refusal selected by a restricted surface. +pub(crate) fn register_glob_refusal( + env: &mut minijinja::Environment<'_>, + surface: RestrictedSurface, +) { + match surface { + RestrictedSurface::ManifestQuery => query::register_glob_refusal(env), + RestrictedSurface::Test => test_mode::register_glob_refusal(env), + } +} + +/// Return the names used by the build surface's real file-test registrations. +pub(super) fn file_test_names() -> impl Iterator { + SURFACE + .iter() + .filter(|entry| entry.kind == HelperKind::Test) + .map(|entry| entry.name) +} + +#[cfg(test)] +mod surface_contracts; +#[cfg(test)] +mod tests; diff --git a/src/stdlib/restricted_helpers/query.rs b/src/stdlib/restricted_helpers/query.rs new file mode 100644 index 000000000..7e351ec3a --- /dev/null +++ b/src/stdlib/restricted_helpers/query.rs @@ -0,0 +1,167 @@ +//! Register query refusals without changing their existing call contracts. + +use minijinja::{ + Environment, Error, ErrorKind, State, + value::{Kwargs, Value}, +}; + +use super::{HelperKind, RestrictedSurface, SURFACE, is_loader_owned}; + +/// Marker retained in query-refusal text so the Jinja boundary can classify it. +const MANIFEST_QUERY_DISABLED_HELPER_MARKER: &str = concat!( + "is disabled while rendering `netsuke help targets`; manifest queries permit ", + "only non-disclosing, side-effect-free template helpers" +); + +/// Register the deliberate failures selected by the query disposition table. +/// +/// Host-reading helpers are skipped: whether a load may reach the host is a +/// property of the load, so the loader registers those two from its own +/// disposition. Registering them here too would overwrite the loader's +/// decision, and a test load would lose a real surface reader to a refusal. +pub(super) fn register_refusals(env: &mut Environment<'_>, surface: RestrictedSurface) { + for entry in SURFACE + .iter() + .filter(|entry| surface.refuses(entry)) + .filter(|entry| !is_loader_owned(entry)) + { + match entry.kind { + HelperKind::Function => register_query_function_stub(env, entry.name), + HelperKind::Filter => register_query_filter_stub(env, entry.name), + HelperKind::Test => register_query_test_stub(env, entry.name), + } + } +} + +/// Register only the `env()` refusal for a load that may not read the host. +pub(crate) fn register_env_refusal(env: &mut Environment<'_>) { + register_query_function_stub(env, "env"); +} + +/// Register only the `glob()` refusal for a load that may not touch the +/// filesystem. +pub(super) fn register_glob_refusal(env: &mut Environment<'_>) { + register_query_function_stub(env, "glob"); +} + +/// Register a query-refusing function with its established argument shape. +fn register_query_function_stub(env: &mut Environment<'_>, name: &'static str) { + match name { + "env" => env.add_function( + name, + move |_variable: String, _kwargs: Kwargs| -> Result { + Err(manifest_query_operation_error(name)) + }, + ), + "glob" => env.add_function(name, move |_pattern: String| -> Result { + Err(manifest_query_operation_error(name)) + }), + "fetch" => env.add_function( + name, + move |_url: String, _kwargs: Kwargs| -> Result { + Err(manifest_query_operation_error(name)) + }, + ), + "which" => env.add_function( + name, + move |_value: Value, _kwargs: Kwargs| -> Result { + Err(manifest_query_operation_error(name)) + }, + ), + "command_available" => env.add_function( + name, + move |_value: Value, _kwargs: Kwargs| -> Result { + Err(manifest_query_operation_error(name)) + }, + ), + "now" => env.add_function(name, move |_kwargs: Kwargs| -> Result { + Err(manifest_query_operation_error(name)) + }), + _ => {} + } +} + +/// Register a query-refusing filter with its established argument shape. +fn register_query_filter_stub(env: &mut Environment<'_>, name: &'static str) { + match name { + "which" => env.add_filter( + name, + move |_value: Value, _kwargs: Kwargs| -> Result { + Err(manifest_query_operation_error(name)) + }, + ), + "shell" => env.add_filter( + name, + move |_state: &State, + _value: Value, + _command: String, + _options: Option| + -> Result { Err(manifest_query_operation_error(name)) }, + ), + "grep" => env.add_filter( + name, + move |_state: &State, + _value: Value, + _pattern: String, + _flags: Option, + _options: Option| + -> Result { Err(manifest_query_operation_error(name)) }, + ), + "contents" => env.add_filter( + name, + move |_value: String, _encoding: Option| -> Result { + Err(manifest_query_operation_error(name)) + }, + ), + "realpath" | "expanduser" => { + env.add_filter(name, move |_value: String| -> Result { + Err(manifest_query_operation_error(name)) + }); + } + "size" => env.add_filter(name, move |_value: String| -> Result { + Err(manifest_query_operation_error(name)) + }), + "linecount" => env.add_filter(name, move |_value: String| -> Result { + Err(manifest_query_operation_error(name)) + }), + "hash" => env.add_filter( + name, + move |_value: String, _algorithm: Option| -> Result { + Err(manifest_query_operation_error(name)) + }, + ), + "digest" => env.add_filter( + name, + move |_value: String, + _length: Option, + _algorithm: Option| + -> Result { + Err(manifest_query_operation_error(name)) + }, + ), + _ => {} + } +} + +/// Register query-refusing tests using the single inventory of test names. +fn register_query_test_stub(env: &mut Environment<'_>, name: &'static str) { + env.add_test(name, move |_value: Value| -> Result { + Err(manifest_query_operation_error(name)) + }); +} + +/// Explain why a restricted helper is unavailable while querying a manifest. +fn manifest_query_operation_error(operation: &str) -> Error { + Error::new( + ErrorKind::InvalidOperation, + format!("{operation} {MANIFEST_QUERY_DISABLED_HELPER_MARKER}"), + ) +} + +/// Return whether an error marks a helper intentionally unavailable in queries. +pub(crate) fn is_manifest_query_disabled_error(error: &Error) -> bool { + error.kind() == ErrorKind::InvalidOperation + && error + .to_string() + .contains(MANIFEST_QUERY_DISABLED_HELPER_MARKER) +} diff --git a/src/stdlib/restricted_helpers/surface_contracts/mod.rs b/src/stdlib/restricted_helpers/surface_contracts/mod.rs new file mode 100644 index 000000000..287507050 --- /dev/null +++ b/src/stdlib/restricted_helpers/surface_contracts/mod.rs @@ -0,0 +1,151 @@ +//! Contracts for the deny-by-default template surface used by `netsuke test`. +//! +//! Three claims are asserted across this module and its children, and they are +//! kept together because each is only meaningful beside the other two. The +//! inventory is complete: every helper registered anywhere under `src/stdlib` +//! appears in `SURFACE`, and no name the inventory claims to refuse is +//! reachable as an unknown helper. Every row has a probe that loads a real +//! manifest through the loader, so the surface is observed where a manifest +//! author meets it rather than through a reconstruction of the registration. +//! And every refusal is typed: the error source downcasts to [`TestModeRefusal`], +//! which is what lets a caller tell a withheld capability from a typo without +//! matching diagnostic text. +//! +//! The probe table is keyed by `(name, kind)` rather than name alone because +//! `which` is both a filter and a function, and the two are separate +//! registrations that can regress independently. +//! +//! These rows live beside `tests.rs` rather than inside it: that module pins +//! the query-refusal text and already fills most of the 400-line file cap, so +//! extending it would have taken it over the limit. The split is mechanical — +//! the query rows are untouched — and the two modules share only `SURFACE`. +//! +//! This module owns the probe fixtures the rows share; `probe_rows.rs` runs +//! them against the inventory, `registration_scan.rs` checks the inventory +//! against the registration sites in the source tree, and `refusal_text.rs` +//! pins the refusal shape and its text. Those three are separate files because +//! together they exceeded the same cap. + +mod probe_rows; +mod refusal_text; +mod registration_scan; + +use std::{error::Error as _, sync::Arc}; + +use anyhow::Result; +use minijinja::Error as MiniJinjaError; + +use super::{HelperEntry, HelperKind, TestModeRefusal}; +use crate::manifest::{ + EnvReadError, EnvReader, ManifestLoadOptions, ManifestName, TestSurface, from_str_with_options, +}; +use crate::stdlib::StdlibConfig; + +/// Environment name the injected probe reader answers. +const PROBE_ENV_NAME: &str = "NETSUKE_TEST_ENV"; +/// Value the injected probe reader returns. +const PROBE_ENV_VALUE: &str = "probe"; + +/// Build the stdlib configuration the probe surface carries. +fn probe_config() -> StdlibConfig { + StdlibConfig::from_current_dir().expect("the test workspace must open") +} + +/// Return a reader that answers one name and refuses the rest. +fn probe_reader() -> EnvReader { + Arc::new(|name: &str| { + if name == PROBE_ENV_NAME { + Ok(PROBE_ENV_VALUE.to_owned()) + } else { + Err(EnvReadError::NotPresent) + } + }) +} + +/// Build the test surface every manifest-level probe loads through. +fn probe_surface() -> TestSurface { + TestSurface::new(probe_config()).with_env_reader(probe_reader()) +} + +/// Build a one-target manifest whose command interpolates `expression`. +/// +/// The command is the rendered field because it is the one place a probe can +/// observe a value without needing a target property the AST does not carry. +fn probe_manifest(expression: &str) -> String { + format!( + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: probe\n", + " command: \"echo {}\"\n", + ), + expression + ) +} + +/// Load the probe manifest under the deny-by-default surface. +fn load_probe(expression: &str) -> Result { + from_str_with_options( + &probe_manifest(expression), + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::test(probe_surface()), + ) +} + +/// Return the `MiniJinja` error at the root of a load failure, if any. +fn jinja_error(error: &anyhow::Error) -> Option<&MiniJinjaError> { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) +} + +/// Return the operation named by a test-mode refusal in the error chain. +fn refusal_operation(error: &anyhow::Error) -> Option<&str> { + let jinja = jinja_error(error)?; + jinja + .source()? + .downcast_ref::() + .map(TestModeRefusal::operation) +} + +/// Return the template expression that exercises one inventory row. +fn probe_expression(entry: &HelperEntry) -> Option<&'static str> { + let expression = match (entry.name, entry.kind) { + ("env", _) => "{{ env('NETSUKE_TEST_ENV') }}", + ("glob", _) => "{{ glob('*') }}", + ("fetch", _) => "{{ fetch('https://example.invalid/probe') }}", + ("which", HelperKind::Filter) => "{{ 'rustc' | which }}", + ("which", HelperKind::Function) => "{{ which('rustc') }}", + ("command_available", _) => "{{ command_available('rustc') }}", + ("now", _) => "{{ now() }}", + ("timedelta", _) => "{{ timedelta(seconds=1) }}", + ("shell", _) => "{{ 'input' | shell('echo', 'hi') }}", + ("grep", _) => "{{ 'input' | grep('probe') }}", + ("contents", _) => "{{ 'probe.txt' | contents }}", + ("realpath", _) => "{{ 'probe' | realpath }}", + ("expanduser", _) => "{{ '~' | expanduser }}", + ("size", _) => "{{ 'probe' | size }}", + ("linecount", _) => "{{ 'probe' | linecount }}", + ("hash", _) => "{{ 'probe' | hash('sha256') }}", + ("digest", _) => "{{ 'probe' | digest(8) }}", + ("basename", _) => "{{ 'a/b.txt' | basename }}", + ("dirname", _) => "{{ 'a/b.txt' | dirname }}", + ("with_suffix", _) => "{{ 'a.txt' | with_suffix('.md') }}", + ("relative_to", _) => "{{ 'reports/daily.csv' | relative_to('reports') }}", + ("shell_quote", _) => "{{ 'probe' | shell_quote }}", + ("shell_join", _) => "{{ ['a', 'b'] | shell_join }}", + ("uniq", _) => "{{ ['a', 'a'] | uniq | join(',') }}", + ("flatten", _) => "{{ [[1], [2]] | flatten | join(',') }}", + ("compact", _) => "{{ ['a', '', none] | compact | join(',') }}", + ("group_by", _) => "{{ ([{'kind': 'tool'}] | group_by('kind')).tool | length }}", + ("dir", _) => "{{ 'probe' is dir }}", + ("file", _) => "{{ 'probe' is file }}", + ("symlink", _) => "{{ 'probe' is symlink }}", + ("pipe", _) => "{{ 'probe' is pipe }}", + ("block_device", _) => "{{ 'probe' is block_device }}", + ("char_device", _) => "{{ 'probe' is char_device }}", + ("device", _) => "{{ 'probe' is device }}", + _ => return None, + }; + Some(expression) +} diff --git a/src/stdlib/restricted_helpers/surface_contracts/probe_rows.rs b/src/stdlib/restricted_helpers/surface_contracts/probe_rows.rs new file mode 100644 index 000000000..5cb6e1a8a --- /dev/null +++ b/src/stdlib/restricted_helpers/surface_contracts/probe_rows.rs @@ -0,0 +1,85 @@ +//! Every inventory row, exercised through a real manifest load. +//! +//! Split from the single-file contracts module, which outgrew the module line +//! cap, along the claim it serves: this file is the half that needs a loader, +//! while `registration_scan.rs` reads the source text and `refusal_text.rs` +//! pins the refusal shape. The rows are checked together so a single failure report +//! names every row that drifted rather than only the first. + +use minijinja::{Error as MiniJinjaError, ErrorKind}; + +use super::super::{HelperEntry, SURFACE, TestDisposition}; +use super::{jinja_error, load_probe, probe_expression, refusal_operation}; + +/// Check one inventory row against the probe's outcome. +fn check_row(entry: &HelperEntry, expression: &str) -> std::result::Result<(), String> { + let outcome = load_probe(expression); + match entry.test { + TestDisposition::Available => outcome + .map(|_| ()) + .map_err(|error| format!("expected `{expression}` to load, got {error:#}")), + TestDisposition::Refused | TestDisposition::ClockInjected => match outcome { + Ok(_) => Err(format!("expected `{expression}` to refuse, but it loaded")), + Err(error) => check_refusal(entry, &error), + }, + } +} + +/// Report whether `kind` is `MiniJinja`'s unknown-helper error. +/// +/// An inventory row whose helper was never registered resolves as unknown +/// rather than refused, which is a different fault from a misnamed refusal and +/// is reported with its own message. +fn is_unknown_helper(kind: ErrorKind) -> bool { + matches!( + kind, + ErrorKind::UnknownFunction | ErrorKind::UnknownFilter | ErrorKind::UnknownTest + ) +} + +/// Check a refused probe names its helper and is not an unknown-helper error. +fn check_refusal(entry: &HelperEntry, error: &anyhow::Error) -> std::result::Result<(), String> { + if let Some(kind) = jinja_error(error) + .map(MiniJinjaError::kind) + .filter(|kind| is_unknown_helper(*kind)) + { + return Err(format!( + "`{}` resolved as an unknown helper: {kind:?}", + entry.name + )); + } + match refusal_operation(error) { + Some(operation) if operation == entry.name => Ok(()), + Some(operation) => Err(format!("`{}` refused as `{operation}`", entry.name)), + None => Err(format!( + "`{}` failed without a typed refusal: {error:#}", + entry.name + )), + } +} + +/// Assert every inventory row can be exercised and answers as recorded. +#[test] +fn every_inventory_row_has_a_reachable_probe() { + let mut failures = Vec::new(); + for entry in SURFACE { + match probe_expression(entry) { + Some(expression) => { + if let Err(failure) = check_row(entry, expression) { + failures.push(format!("{} ({:?}): {failure}", entry.name, entry.kind)); + } + } + None => failures.push(format!( + "{} ({:?}): no probe expression is defined", + entry.name, entry.kind + )), + } + } + assert!( + failures.is_empty(), + "{} of {} inventory rows failed:\n{}", + failures.len(), + SURFACE.len(), + failures.join("\n") + ); +} diff --git a/src/stdlib/restricted_helpers/surface_contracts/refusal_text.rs b/src/stdlib/restricted_helpers/surface_contracts/refusal_text.rs new file mode 100644 index 000000000..02cc649d5 --- /dev/null +++ b/src/stdlib/restricted_helpers/surface_contracts/refusal_text.rs @@ -0,0 +1,116 @@ +//! The typed refusal, its call shapes, and its rendered text. +//! +//! Split from the single-file contracts module, which outgrew the module line +//! cap, along the claim it serves: a refusal is only useful if a caller can +//! recognize it without matching diagnostic text, so these cases render against +//! a bare `MiniJinja` environment rather than loading a manifest, and the two +//! text cases then pin what a manifest author actually sees. + +use std::error::Error as _; + +use anyhow::{Context as _, Result, ensure}; +use minijinja::{Environment, ErrorKind}; +use rstest::rstest; +use test_support::{ + EnLocalizer, en_localizer, fluent::normalize_fluent_isolates, localizer::locale_localizer, +}; + +use super::super::{ + RestrictedSurface, SURFACE, TestModeRefusal, register_for_test, register_glob_refusal, +}; +use super::{probe_config, probe_expression}; + +/// Build an environment holding only the test surface and its refusals. +fn refusal_environment() -> Environment<'static> { + let mut env = Environment::new(); + let config = probe_config(); + let _state = register_for_test(&mut env, &config); + register_glob_refusal(&mut env, RestrictedSurface::Test); + env +} + +/// Assert a rendered expression refuses with the typed source and operation. +fn assert_typed_refusal(env: &Environment<'_>, expression: &str, operation: &str) -> Result<()> { + let error = env + .render_str(expression, ()) + .expect_err(&format!("`{expression}` must refuse")); + ensure!( + error.kind() == ErrorKind::InvalidOperation, + "`{expression}` reported {kind:?}, not an invalid operation", + kind = error.kind() + ); + let refusal = error + .source() + .and_then(|source| source.downcast_ref::()) + .with_context(|| format!("`{expression}` carried no typed refusal"))?; + ensure!( + refusal.operation() == operation, + "`{expression}` refused as `{}`, not `{operation}`", + refusal.operation() + ); + Ok(()) +} + +/// Assert every refusing row carries a typed source naming the helper. +#[test] +fn refusals_carry_a_typed_source() -> Result<()> { + let env = refusal_environment(); + let mut failures = Vec::new(); + for entry in SURFACE + .iter() + .filter(|entry| RestrictedSurface::Test.refuses(entry)) + { + let Some(expression) = probe_expression(entry) else { + failures.push(format!("{}: no probe expression", entry.name)); + continue; + }; + if let Err(error) = assert_typed_refusal(&env, expression, entry.name) { + failures.push(format!("{}: {error:#}", entry.name)); + } + } + ensure!( + failures.is_empty(), + "{} refusing rows lacked a typed source:\n{}", + failures.len(), + failures.join("\n") + ); + Ok(()) +} + +/// Assert a refusal is reached from both positional and keyword call shapes. +#[test] +fn refusals_accept_any_call_shape() -> Result<()> { + let env = refusal_environment(); + for (expression, operation) in [ + ("{{ fetch('https://example.invalid') }}", "fetch"), + ("{{ fetch(url='https://example.invalid') }}", "fetch"), + ("{{ 'probe' | hash('sha256') }}", "hash"), + ("{{ 'probe' | hash() }}", "hash"), + ("{{ 'probe' is file }}", "file"), + ] { + assert_typed_refusal(&env, expression, operation)?; + } + Ok(()) +} + +/// Pin the English refusal text for one helper. +#[rstest] +fn english_refusal_text_is_pinned(en_localizer: EnLocalizer) { + let _localizer = en_localizer; + let text = normalize_fluent_isolates(&TestModeRefusal::new("hash").to_string()); + insta::assert_snapshot!( + text, + @"[netsuke::jinja::test_mode::unavailable] hash is unavailable under `netsuke test`." + ); +} + +/// Assert the refusal leads with its code under a non-English catalogue. +#[test] +fn refusal_text_carries_the_code_under_another_locale() { + let _guards = locale_localizer("ja"); + let text = normalize_fluent_isolates(&TestModeRefusal::new("hash").to_string()); + assert!( + text.starts_with("[netsuke::jinja::test_mode::unavailable] hash"), + "Japanese refusal did not lead with the code and helper name: {text}" + ); +} diff --git a/src/stdlib/restricted_helpers/surface_contracts/registration_scan.rs b/src/stdlib/restricted_helpers/surface_contracts/registration_scan.rs new file mode 100644 index 000000000..01b295bed --- /dev/null +++ b/src/stdlib/restricted_helpers/surface_contracts/registration_scan.rs @@ -0,0 +1,176 @@ +//! The inventory checked against the registration sites in the source tree. +//! +//! Split from the single-file contracts module, which outgrew the module line +//! cap, along the claim it serves: completeness in both directions is a claim +//! about the source text rather than about a loaded manifest, so it needs no +//! probe surface and belongs beside the scanner that reads the registrations. + +use anyhow::{Context as _, Result, ensure}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; + +use super::super::{HelperKind, SURFACE, is_loader_owned}; + +/// Return every literal `add_function`/`add_filter`/`add_test` registration. +/// +/// The scanner walks the source text rather than the compiled environment +/// because `MiniJinja` exposes no way to enumerate filters or tests, so the +/// registrations themselves are the only complete witness. Whitespace between +/// the call, the parenthesis, and the name literal is tolerated so a reformat +/// cannot silently hide a name. +/// +/// The registration marker decides the helper kind. Functions, filters, and +/// tests occupy separate `MiniJinja` namespaces, so a name alone does not +/// identify a registration: a filter called `now` is a different helper from +/// the inventoried function `now`. +fn scanned_registrations() -> Result> { + const MARKERS: [(&str, HelperKind); 3] = [ + ("add_function", HelperKind::Function), + ("add_filter", HelperKind::Filter), + ("add_test", HelperKind::Test), + ]; + let root = Dir::open_ambient_dir(env!("CARGO_MANIFEST_DIR"), ambient_authority()) + .context("open the crate root")?; + let mut sources = Vec::new(); + collect_sources(&root, Utf8Path::new("src/stdlib"), &mut sources)?; + let mut found = Vec::new(); + for path in sources { + let text = root + .read_to_string(path.as_str()) + .with_context(|| format!("read {path}"))?; + for (kind, name) in literal_names(&text, &MARKERS) { + found.push((path.clone(), kind, name)); + } + } + Ok(found) +} + +/// Append every Rust source beneath `directory` to `sources`. +fn collect_sources(root: &Dir, directory: &Utf8Path, sources: &mut Vec) -> Result<()> { + for entry in root + .read_dir(directory) + .with_context(|| format!("read `{directory}`"))? + { + let resolved = entry.with_context(|| format!("read an entry of `{directory}`"))?; + let name = resolved + .file_name() + .with_context(|| format!("read an entry name in `{directory}`"))?; + let path = directory.join(&name); + if resolved.file_type()?.is_dir() { + collect_sources(root, &path, sources)?; + } else if path.extension() == Some("rs") { + sources.push(path); + } + } + Ok(()) +} + +/// Return every `marker("name"` occurrence in `text` with its helper kind. +#[expect( + clippy::string_slice, + reason = "every offset comes from `str::find`, so each one is a character boundary" +)] +fn literal_names(text: &str, markers: &[(&str, HelperKind)]) -> Vec<(HelperKind, String)> { + let mut names = Vec::new(); + for (marker, kind) in markers { + let mut from = 0; + while let Some(offset) = text[from..].find(marker) { + let after = from + offset + marker.len(); + if let Some(name) = literal_after(&text[after..]) { + names.push((*kind, name)); + } + from = after; + } + } + names +} + +/// Return the string literal following `(` and whitespace, if one is there. +#[expect( + clippy::string_slice, + reason = "the end offset comes from `str::find`, so it is a character boundary" +)] +fn literal_after(rest: &str) -> Option { + let after_paren = rest.trim_start().strip_prefix('(')?.trim_start(); + let quoted = after_paren.strip_prefix('"')?; + let end = quoted.find('"')?; + Some(quoted[..end].to_owned()) +} + +/// Report whether `SURFACE` inventories a registration of this kind and name. +fn is_inventoried(kind: HelperKind, name: &str) -> bool { + SURFACE + .iter() + .any(|entry| entry.kind == kind && entry.name == name) +} + +/// Assert every literal registration appears in `SURFACE`. +#[test] +fn every_scanned_registration_is_inventoried() -> Result<()> { + let scanned = scanned_registrations()?; + ensure!( + !scanned.is_empty(), + "the source scan found no registrations; the walk is broken" + ); + let mut unexpected: Vec = scanned + .iter() + .filter(|(_, kind, name)| !is_inventoried(*kind, name)) + .map(|(path, kind, name)| format!("`{kind:?} {name}` in {path}")) + .collect(); + unexpected.sort(); + ensure!( + unexpected.is_empty(), + "literal registrations missing from `SURFACE`: {unexpected:?}" + ); + Ok(()) +} + +/// Assert every invented name has a registration site or a stated exemption. +#[test] +fn every_inventory_name_is_registered_somewhere() -> Result<()> { + let scanned = scanned_registrations()?; + let mut missing: Vec = SURFACE + .iter() + .filter(|entry| { + let is_loop_registered = entry.kind == HelperKind::Test; + let is_scanned = scanned + .iter() + .any(|(_, kind, name)| *kind == entry.kind && name == entry.name); + !(is_loader_owned(entry) || is_loop_registered || is_scanned) + }) + .map(|entry| format!("{:?} {}", entry.kind, entry.name)) + .collect(); + missing.sort_unstable(); + ensure!( + missing.is_empty(), + "inventory helpers with no registration site: {missing:?}" + ); + Ok(()) +} + +/// Reject a registration whose name is inventoried only under another kind. +/// +/// `MiniJinja` keeps functions, filters, and tests in separate namespaces, so +/// `SURFACE` may legitimately hold the same name twice — `which` is both a +/// function and a filter. The scan must therefore compare the kind as well as +/// the name; comparing names alone would accept a filter called `now` merely +/// because the inventoried `now` is a function. +#[test] +fn a_registration_is_not_inventoried_under_another_kind() { + assert!( + is_inventoried(HelperKind::Function, "now"), + "the function `now` should be inventoried" + ); + assert!( + !is_inventoried(HelperKind::Filter, "now"), + "a filter named `now` must not inherit the function's inventory row" + ); + assert!( + is_inventoried(HelperKind::Filter, "which"), + "the filter `which` should be inventoried" + ); + assert!( + is_inventoried(HelperKind::Function, "which"), + "the function `which` should be inventoried" + ); +} diff --git a/src/stdlib/restricted_helpers/test_mode.rs b/src/stdlib/restricted_helpers/test_mode.rs new file mode 100644 index 000000000..218fc545b --- /dev/null +++ b/src/stdlib/restricted_helpers/test_mode.rs @@ -0,0 +1,154 @@ +//! Refuse host-facing helpers during a `netsuke test` manifest load. +//! +//! A test load is deny-by-default: helpers that inspect the host, read the +//! filesystem, run a command, reach the network, or consult the ambient clock +//! are registered as deliberate failures carrying the +//! [`TestModeRefusal`] source. Refusing rather than omitting matters because a +//! missing helper reports `MiniJinja`'s unknown-name error, which reads as a +//! typo in the manifest; a refusal names the operation and tells the author +//! that the capability was withheld. +//! +//! Every stub accepts any positional and keyword arguments. A refusal must be +//! reachable from whatever call shape a real helper documents, and a narrower +//! signature would report an argument-count error instead of the refusal it is +//! meant to deliver. + +use std::fmt; + +use minijinja::{ + Environment, Error, ErrorKind, State, + value::{Kwargs, Rest, Value}, +}; + +use crate::localization::{self, keys}; + +use super::{HelperKind, RestrictedSurface, SURFACE}; + +/// Why a helper is unavailable under `netsuke test`. +/// +/// The refusal is a typed error source so a caller can distinguish "this +/// capability was withheld" from "the manifest used an unknown helper" without +/// matching on diagnostic text. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TestModeRefusal { + /// Helper name the manifest called. + operation: String, +} + +impl TestModeRefusal { + /// Record the helper that a test load refused. + pub(crate) fn new(operation: impl Into) -> Self { + Self { + operation: operation.into(), + } + } + + /// Return the helper name this refusal names. + /// + /// Public because a caller that loads a manifest under the test surface — + /// including the corpus differential test — has to decide whether a failed + /// load was a withheld capability, and the operation name is the data that + /// answers which one. + #[must_use] + pub fn operation(&self) -> &str { + &self.operation + } +} + +impl fmt::Display for TestModeRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str( + &localization::message(keys::STDLIB_TEST_MODE_HELPER_UNAVAILABLE) + .with_arg("operation", self.operation()) + .to_string(), + ) + } +} + +impl std::error::Error for TestModeRefusal {} + +/// Register a deliberate failure in place of every refused test helper. +/// +/// The loader owns `env()` and `glob()` because whether a load may reach the +/// host is a property of the load; registering them here would overwrite the +/// loader's own decision. +pub(super) fn register_refusals(env: &mut Environment<'_>) { + for entry in SURFACE + .iter() + .filter(|entry| RestrictedSurface::Test.refuses(entry)) + .filter(|entry| !super::is_loader_owned(entry)) + { + match entry.kind { + HelperKind::Function => register_function_refusal(env, entry.name), + HelperKind::Filter => register_filter_refusal(env, entry.name), + HelperKind::Test => register_test_refusal(env, entry.name), + } + } +} + +/// Register the `glob()` refusal installed by the loader for a test load. +pub(super) fn register_glob_refusal(env: &mut Environment<'_>) { + register_function_refusal(env, "glob"); +} + +/// Register one function name as a refusal reachable from any call shape. +fn register_function_refusal(env: &mut Environment<'_>, name: &'static str) { + env.add_function( + name, + move |_args: Rest, _kwargs: Kwargs| -> Result { + Err(refusal_error(name)) + }, + ); +} + +/// Register one filter name as a refusal reachable from any call shape. +fn register_filter_refusal(env: &mut Environment<'_>, name: &'static str) { + env.add_filter( + name, + move |_state: &State, + _value: Value, + _args: Rest, + _kwargs: Kwargs| + -> Result { Err(refusal_error(name)) }, + ); +} + +/// Register one test name as a refusal reachable from any call shape. +fn register_test_refusal(env: &mut Environment<'_>, name: &'static str) { + env.add_test( + name, + move |_value: Value, _args: Rest, _kwargs: Kwargs| -> Result { + Err(refusal_error(name)) + }, + ); +} + +/// Build the `MiniJinja` error that carries a [`TestModeRefusal`] source. +fn refusal_error(operation: &str) -> Error { + let refusal = TestModeRefusal::new(operation); + Error::new(ErrorKind::InvalidOperation, refusal.to_string()).with_source(refusal) +} + +/// Register the deny-by-default stdlib surface used by `netsuke test`. +/// +/// The surface is deterministic: lexical path filters, collection filters, the +/// Boolean formatter, and `timedelta` are available; `now()` is available only +/// when the caller injected a clock into `config`, because the ambient clock is +/// a host read. Everything else is a refusal. +pub(crate) fn register_for_test( + env: &mut Environment<'_>, + config: &crate::stdlib::StdlibConfig, +) -> crate::stdlib::StdlibState { + super::super::register::register_legacy_boolean_formatter(env); + super::super::path::register_query_filters(env); + super::super::collections::register_filters(env); + super::super::recipe_text::register_filters(env, config.dialect()); + if config.clock().is_system() { + register_function_refusal(env, "now"); + } else { + super::super::time::register_now(env, config.clock().clone()); + } + super::super::time::register_query_functions(env); + register_refusals(env); + crate::stdlib::StdlibState::default() +} diff --git a/src/stdlib/restricted_helpers/tests.rs b/src/stdlib/restricted_helpers/tests.rs new file mode 100644 index 000000000..58dc74eb8 --- /dev/null +++ b/src/stdlib/restricted_helpers/tests.rs @@ -0,0 +1,294 @@ +//! What each query-refused helper says when a manifest calls it. +//! +//! Manifest discovery renders `netsuke help targets`, which must not disclose +//! host state or perform side effects. Every helper that is legitimate at build +//! time but unsafe while discovering is registered as a stub that fails with a +//! recognizable marker, and those messages are user-visible text: they reach +//! the diagnostic a user sees when a manifest overreaches. This module pins +//! them one row per helper, so a later refactor that reorganizes the stubs +//! cannot quietly reword a refusal. +//! +//! # Why one document rather than one snapshot per helper +//! +//! The build surface's characterization groups every fixture into one document +//! per entry point, and this follows it for the same reason. Messages that +//! differ only in their leading word are read *comparatively*: a reviewer +//! checking that every name is spelled as the helper registers it needs the +//! rows side by side, which one file gives and one file per helper does not. +//! Every row also shares the marker tail, so a rewording anywhere changes this +//! one file — the per-helper split would buy no extra visibility for the +//! change that actually matters. +//! +//! The rows are labelled by helper name and call shape, so a reader who greps +//! for one helper lands on its block. +//! +//! The settings drop insta's module prefix, so the snapshot is named for the +//! assertion rather than for this file's path. A reorganization that keeps +//! `refusals.snap` in place therefore reads as no behaviour change at all, +//! which is what let the rows move here from the retired query-helper module. +//! +//! # What is asserted +//! +//! Each row renders a one-line template that calls its helper and contributes +//! the resulting error to a single snapshot document. The template is rendered +//! through [`register_manifest_query`], the same registration `netsuke help +//! targets` installs, so the row observes the real query surface rather than a +//! reconstruction of it. +//! +//! A refusal must also be *recognizable*, which is a separate claim from its +//! text: `is_manifest_query_disabled_error` decides whether the classifier +//! treats a `when` failure as a conditional rather than a load failure. Every +//! row asserts recognition too, because a helper registered by the *real* +//! implementation instead of a stub would render successfully, and the text +//! assertion alone would report only a missing error. + +use super::{RestrictedSurface, is_manifest_query_disabled_error}; +use crate::stdlib::{register_env_refusal, register_glob_refusal, register_manifest_query}; +use anyhow::Result; +use minijinja::Environment; +use rstest::rstest; + +/// One refusing helper: the name its refusal must lead with, and a template +/// that calls it. +/// +/// The two are kept as separate fields rather than one derived from the other. +/// A filter and a function can share a name (`which` is both), so the template +/// is the only thing that says which registration a row exercises, and the name +/// is the only thing that says what the refusal must lead with. Deriving either +/// from the other would silence exactly the mismatch worth catching. +struct Refusal { + /// The helper name the refusal text must begin with. + name: &'static str, + /// The template that calls the helper. + template: &'static str, +} + +/// The refusing inventory. +/// +/// The templates are written to match the widest call shape each helper's real +/// counterpart accepts, so a stub that stopped being registered as a stub would +/// not pass merely by rejecting the arguments. `shell` and `grep` are filters +/// and need a value on the left, which the empty string supplies. +/// +/// `which` appears twice, once as a filter and once as a function. Both +/// registrations are separate calls in the implementation, so a change that +/// dropped one would leave the other green. +/// +/// Every row here is a helper the surface *refuses*, which is a different set +/// from the file-test rows in `SURFACE`. Completeness is the requirement — a +/// refusal with no row is a message that can be reworded unobserved — so when +/// adding a query refusal, add its row here too. This list drifted +/// once already: `glob` was registered but unrowed until it was found by +/// enumerating the registrations against this table. +const REFUSALS: &[Refusal] = &[ + Refusal { + name: "env", + template: "{{ env('PATH') }}", + }, + Refusal { + name: "glob", + template: "{{ glob('glob_files/*.txt') }}", + }, + Refusal { + name: "fetch", + template: "{{ fetch('https://example.invalid/toolchain.json') }}", + }, + Refusal { + name: "shell", + template: "{{ '' | shell('echo fixture') }}", + }, + Refusal { + name: "grep", + template: "{{ 'x' | grep('x') }}", + }, + Refusal { + name: "contents", + template: "{{ 'Cargo.toml' | contents }}", + }, + Refusal { + name: "which", + template: "{{ 'cargo' | which }}", + }, + Refusal { + name: "which", + template: "{{ which('cargo') }}", + }, + Refusal { + name: "command_available", + template: "{{ command_available('cargo') }}", + }, + Refusal { + name: "now", + template: "{{ now() }}", + }, + Refusal { + name: "realpath", + template: "{{ '.' | realpath }}", + }, + Refusal { + name: "expanduser", + template: "{{ '~' | expanduser }}", + }, + Refusal { + name: "size", + template: "{{ 'Cargo.toml' | size }}", + }, + Refusal { + name: "linecount", + template: "{{ 'Cargo.toml' | linecount }}", + }, + Refusal { + name: "hash", + template: "{{ 'Cargo.toml' | hash }}", + }, + Refusal { + name: "digest", + template: "{{ 'Cargo.toml' | digest }}", + }, + Refusal { + name: "dir", + template: "{{ 'Cargo.toml' is dir }}", + }, + Refusal { + name: "file", + template: "{{ 'Cargo.toml' is file }}", + }, + Refusal { + name: "symlink", + template: "{{ 'Cargo.toml' is symlink }}", + }, + Refusal { + name: "pipe", + template: "{{ 'Cargo.toml' is pipe }}", + }, + Refusal { + name: "block_device", + template: "{{ 'Cargo.toml' is block_device }}", + }, + Refusal { + name: "char_device", + template: "{{ 'Cargo.toml' is char_device }}", + }, + Refusal { + name: "device", + template: "{{ 'Cargo.toml' is device }}", + }, +]; + +#[test] +fn refusal_rows_cover_the_query_restricted_inventory() { + let mut expected = super::SURFACE + .iter() + .filter(|entry| entry.query == super::QueryDisposition::Refused) + .map(|entry| entry.name) + .collect::>(); + let mut observed = REFUSALS + .iter() + .map(|refusal| refusal.name) + .collect::>(); + expected.sort_unstable(); + observed.sort_unstable(); + + assert_eq!(observed, expected); +} + +/// Snapshot every query refusal the restricted surface produces. +/// +/// One test rather than one per helper: each helper is already a row of +/// [`REFUSALS`], and splitting it into twenty-odd test functions would move the +/// table's shape into the test harness without making any single refusal easier +/// to read. The document preserves the table's order for the same reason. +/// +/// Every row runs even when an earlier one fails, so one broken helper does not +/// hide the state of the rest. The failures are collected and reported together +/// for the same reason, and they are reported *instead of* the snapshot: a row +/// that produced no text would otherwise show up only as a missing block in a +/// document diff, which reads as an edit rather than as a broken helper. +#[rstest] +fn refused_helpers_report_their_own_name() -> Result<()> { + let mut failures = Vec::new(); + let mut blocks = Vec::new(); + for (index, refusal) in REFUSALS.iter().enumerate() { + match check_refusal(refusal) { + Ok(text) => blocks.push(format!( + "=== {index:02} {}: {} ===\n{text}", + refusal.name, refusal.template + )), + Err(error) => failures.push(format!("{}: {error}", refusal.name)), + } + } + anyhow::ensure!( + failures.is_empty(), + "{} of {} query-refusal rows failed:\n{}", + failures.len(), + REFUSALS.len(), + failures.join("\n") + ); + let document = blocks.join("\n\n"); + crate::snapshot_test_support::stable_snapshot_settings("manifest_load_options").bind(|| { + insta::assert_snapshot!("refusals", document); + }); + Ok(()) +} + +/// Assert one helper is refused, is recognizable, and names itself. +/// +/// Returns the refusal text so the caller can place it in the document. The +/// text is owned rather than borrowed because it comes from the rendered +/// error's detail, which the error owns and drops on return. +/// +/// # Errors +/// +/// Returns an error when the template renders, when the error is not a query +/// refusal, or when the refusal leads with a different helper's name. +fn check_refusal(refusal: &Refusal) -> Result { + let error = refusal_from(refusal.template)?; + // The message rather than the rendered error: `Display` prepends the error + // kind and appends the template position, neither of which the stub + // controls, and both of which would blur a one-line row. Requiring the + // message is a claim in its own right — a stub that raised a detail-less + // error would render the kind alone, which is indistinguishable from + // MiniJinja's own failure and so is worth failing on. + let text = error + .detail() + .ok_or_else(|| anyhow::anyhow!("the refusal carries no message: {error}"))?; + // The name is asserted before the snapshot, because a stub built from the + // wrong table row would still carry the marker — the document would match + // while the diagnostic named another helper. + anyhow::ensure!( + text.starts_with(refusal.name), + "the refusal names another helper: {text}" + ); + Ok(text.to_owned()) +} + +/// Render `template` through the query surface and return the error. +/// +/// # Errors +/// +/// Returns an error when the template renders successfully, which means the +/// helper named in it is no longer refused, or when rendering fails with an +/// error that is not a query refusal. Both are failures of the row's premise +/// rather than of the assertion it makes. +fn refusal_from(template: &str) -> Result { + let mut env = Environment::new(); + // `env()` and `glob()` are installed by the manifest loader from the load's + // own disposition, so a query load registers them alongside the surface. + register_manifest_query(&mut env); + register_env_refusal(&mut env); + register_glob_refusal(&mut env, RestrictedSurface::ManifestQuery); + match env.render_str(template, ()) { + Ok(rendered) => anyhow::bail!( + "the query surface rendered {template:?} successfully as {rendered:?}; \ + the helper it calls is no longer refused" + ), + Err(error) => { + anyhow::ensure!( + is_manifest_query_disabled_error(&error), + "the query surface refused {template:?} with an error that is not a \ + query refusal: {error}" + ); + Ok(error) + } + } +} diff --git a/src/stdlib/time/clock/mod.rs b/src/stdlib/time/clock/mod.rs index 5a823beec..4753b9f76 100644 --- a/src/stdlib/time/clock/mod.rs +++ b/src/stdlib/time/clock/mod.rs @@ -115,6 +115,11 @@ impl WallClock { } /// Whether the ambient host clock is installed. + /// + /// This is a capability answer, not a diagnostic: the test surface admits + /// `now()` only when this is `false`, because an injected clock makes the + /// result repeatable while the ambient clock is a host read. Production + /// renders never ask — they register `now()` unconditionally. pub(crate) const fn is_system(&self) -> bool { self.is_system } diff --git a/src/stdlib/time/mod.rs b/src/stdlib/time/mod.rs index fa506cda6..9abbea06a 100644 --- a/src/stdlib/time/mod.rs +++ b/src/stdlib/time/mod.rs @@ -47,10 +47,19 @@ const OFFSET_FMT: &[FormatItem<'static>] = /// every evaluation, so a caller that injects a provider observes it on each /// call rather than once at registration. pub(crate) fn register_functions(env: &mut Environment<'_>, clock: WallClock) { - env.add_function("now", move |kwargs: Kwargs| now(&kwargs, &clock)); + register_now(env, clock); register_query_functions(env); } +/// Register the `now()` helper over an injected clock. +/// +/// The test surface calls this only when its configuration carries an injected +/// clock; with the ambient clock it registers a refusal instead, because +/// reading the host clock would make a test load unrepeatable. +pub(crate) fn register_now(env: &mut Environment<'_>, clock: WallClock) { + env.add_function("now", move |kwargs: Kwargs| now(&kwargs, &clock)); +} + /// Register time helpers whose output does not depend on the current clock. pub(crate) fn register_query_functions(env: &mut Environment<'_>) { env.add_function("timedelta", |kwargs: Kwargs| timedelta(&kwargs)); diff --git a/test_support/Cargo.toml b/test_support/Cargo.toml index b18a49d7b..06c4743cf 100644 --- a/test_support/Cargo.toml +++ b/test_support/Cargo.toml @@ -14,6 +14,11 @@ legacy-digests = ["netsuke/legacy-digests"] [dependencies] tempfile = "3.8.0" +# The shared temporary-path filter escapes each directory component, so both +# snapshot trees build the same pattern from one place. Already in the workspace +# dependency graph as a dev-dependency of the root crate; this adds a second +# consumer of the same resolved version, not a new crate. +regex = "1.12.2" # Decoding PowerShell `-EncodedCommand` payloads in the shared Ninja-semantics # helper must match the encoder the production renderer uses. base64 = "0.22.1" diff --git a/test_support/src/lib.rs b/test_support/src/lib.rs index 94fac0610..82e75da16 100644 --- a/test_support/src/lib.rs +++ b/test_support/src/lib.rs @@ -8,6 +8,7 @@ //! - computing SHA-256 hashes for cache keys (hash module) //! - spawning lightweight HTTP servers for network tests (http module) //! - sandboxing PATH and HOME for the build-tools target tests (`build_tools` module) +//! - building the shared temporary-path snapshot filter (`temp_path_filter` module) //! //! All items are intended for use in tests within this workspace; avoid using //! them in production code. @@ -34,6 +35,7 @@ pub mod ninja; pub mod ninja_gen; pub mod ninja_semantics; pub mod stdlib_assert; +pub mod temp_path_filter; /// Re-export the SHA-256 helper for concise call sites. pub use hash::sha256_hex; diff --git a/test_support/src/temp_path_filter.rs b/test_support/src/temp_path_filter.rs new file mode 100644 index 000000000..2799659d0 --- /dev/null +++ b/test_support/src/temp_path_filter.rs @@ -0,0 +1,128 @@ +//! The temporary-path filter shared by both snapshot trees. +//! +//! Insta settings live in two crates — `src/snapshot_test_support.rs` serves +//! `src/snapshots/`, and `tests/manifest_load_options/fixtures/settings.rs` +//! serves `tests/snapshots/` — and neither can reach the other, because the +//! first is `#[cfg(test)]` code inside the library and the second is a separate +//! integration crate. The pure pattern builder is therefore shared from here, +//! while each crate wires it into its own settings object. +//! +//! Sharing the builder rather than the settings is deliberate. The two settings +//! objects differ beyond the filter — snapshot directory and module-prefix +//! handling are each crate's own business — and the filter is the only part +//! that cannot be written against one crate's snapshot text alone. It is also +//! the only part whose defect is invisible on the recording platform, which is +//! exactly the property that made it worth centralizing: two copies can drift +//! apart and still pass, because each is guarded only by its own copy of the +//! same cases. +//! +//! # Examples +//! +//! ```rust +//! use test_support::temp_path_filter::temp_dir_filter_pattern; +//! +//! // Each separator becomes a class matching one *or more* of either kind, +//! // which is what lets one pattern cover the JSON-escaped spelling too. +//! let pattern = temp_dir_filter_pattern(r"C:\Temp"); +//! assert!(pattern.contains(r"[\\/]+")); +//! ``` + +/// Build the pattern that replaces a temporary directory with `[TMP]`. +/// +/// Each separator becomes a class matching one or more of either separator, and +/// every other character is escaped. The class is what lets one pattern cover +/// both spellings a snapshot can hold: a diagnostic quotes a Windows path with +/// the single backslashes the platform renders, while the serialized half of the +/// same snapshot doubles them, so a pattern requiring exactly one separator +/// would leave the serialized copy unnormalized — and it is the serialized copy +/// that carries the run-specific workspace name. +/// +/// The `+` is load-bearing. A first repair widened a literal `/` to the class +/// `[\\/]`, which reads as "either separator" but still matches exactly one +/// character, so the doubled form stayed unmatched. +/// +/// A trailing separator is dropped first, because leaving it in would make two +/// adjacent classes demand two separators where the text has one. +/// +/// Requiring a whole component at each step, rather than matching the directory +/// name as a bare substring, is what leaves a directory that merely starts with +/// the same characters alone: `/tmp` would otherwise rewrite an unrelated +/// `/tmpfoo/` component in a path or in a diagnostic. +/// +/// The placeholder is the same on every platform, which matters because the +/// snapshots are checked in: a row that records only `[TMP]` reads the same +/// whichever machine recorded it. +/// +/// The directory arrives as an argument rather than being read from +/// `std::env::temp_dir()` here. That keeps the function pure, which is what +/// lets a test assert the Windows spelling on any host — the defect this +/// guards is invisible on Linux, so a test reading the ambient directory could +/// only ever check whatever platform happened to record the snapshot. +#[must_use] +pub fn temp_dir_filter_pattern(temp_dir: &str) -> String { + let separator = r"[\\/]+"; + let prefix = temp_dir + .trim_end_matches(['/', '\\']) + .chars() + .map(|ch| match ch { + '/' | '\\' => separator.to_owned(), + other => regex::escape(&other.to_string()), + }) + .collect::(); + format!("{prefix}{separator}[^\\s\"']*") +} + +#[cfg(test)] +mod tests { + //! Assert the temporary-path filter covers both spellings of one path. + //! + //! The Windows rows run on every platform because + //! [`temp_dir_filter_pattern`] takes its directory as an argument. That + //! matters: the defect these rows pin is invisible on Linux, so a test that + //! asked `temp_dir()` for the prefix could only ever guard the half of the + //! problem the recording host happens to have. + + use super::temp_dir_filter_pattern; + use anyhow::Result; + use regex::Regex; + use rstest::rstest; + + /// Replace every match of the filter for `temp_dir` with `[TMP]`. + fn normalized(temp_dir: &str, text: &str) -> Result { + let pattern = temp_dir_filter_pattern(temp_dir); + let filter = Regex::new(&pattern)?; + Ok(filter.replace_all(text, "[TMP]").into_owned()) + } + + const WINDOWS_TEMP: &str = r"C:\Users\runner\AppData\Local\Temp"; + + #[rstest] + #[case( + WINDOWS_TEMP, + r"cannot read C:\Users\runner\AppData\Local\Temp\ws\Netsukefile", + "cannot read [TMP]", + "a diagnostic quotes a Windows path with single backslashes" + )] + #[case( + WINDOWS_TEMP, + r#"{"path": "C:\\Users\\runner\\AppData\\Local\\Temp\\ws"}"#, + r#"{"path": "[TMP]"}"#, + "serializing a Windows path doubles every backslash" + )] + #[case("/tmp", "/tmp/ws/Netsukefile", "[TMP]", "a Unix path")] + #[case("/tmp/", "/tmp/ws", "[TMP]", "a trailing separator changes nothing")] + #[case("/tmp", "/tmpfoo/ws", "/tmpfoo/ws", "a sibling sharing the prefix")] + fn temp_dir_filter_replaces_exactly_the_temporary_prefix( + #[case] temp_dir: &str, + #[case] text: &str, + #[case] expected: &str, + #[case] because: &str, + ) -> Result<()> { + let actual = normalized(temp_dir, text)?; + anyhow::ensure!( + actual == expected, + "{because}: expected {expected:?}, got {actual:?}" + ); + Ok(()) + } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 6ae993fd2..95dddc235 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -10,7 +10,7 @@ use camino::Utf8PathBuf; use minijinja::value::Value; use netsuke::cli::Cli; use netsuke::localization::LocalizerGuard; -use netsuke::manifest::EnvAccessPolicy; +use netsuke::manifest::{EnvAccessPolicy, TemplateOverlays}; use netsuke::output_mode::OutputMode; use netsuke::output_prefs::OutputPrefs; use netsuke::runner::CommandEnv; @@ -52,6 +52,14 @@ pub struct TestWorld { pub manifest_budget_limits: Slot, /// Environment access policy applied while parsing the current manifest. pub manifest_env_access_policy: RefCell, + /// Repository-relative manifest selected by the loader-options scenarios. + pub load_options_path: Slot, + /// Ninja text produced by the build-surface load. + pub load_options_build_ninja: Slot, + /// Ninja text produced by the test-surface load. + pub load_options_test_ninja: Slot, + /// Template overlays the test-surface load registers. + pub load_options_overlays: Slot, // IR state (non-Clone) /// Build graph derived from the active manifest. diff --git a/tests/bdd/steps/manifest_load_options.rs b/tests/bdd/steps/manifest_load_options.rs new file mode 100644 index 000000000..9520b5bc5 --- /dev/null +++ b/tests/bdd/steps/manifest_load_options.rs @@ -0,0 +1,197 @@ +//! Step definitions for options-carrying manifest loading scenarios. +//! +//! The scenarios observe the two surfaces the way a manifest author meets +//! them. The first loads one pure manifest twice — once through +//! `ManifestLoadOptions::full` and once through `ManifestLoadOptions::test` — +//! and requires the emitted Ninja text to agree, so a capability difference +//! that changed the meaning of a pure manifest would fail here rather than in +//! the differential suites. The second loads a manifest that pipes a command +//! through `shell`, which the build surface runs literally and the test +//! surface refuses; the refusal must reach the author as an error naming both +//! the refusal class and the helper. The third supplies a template overlay, so +//! a test can stand in for a helper the surface withholds without granting the +//! helper itself. +//! +//! Fixtures are read from the repository rather than staged into a temporary +//! workspace: the loader entry point takes manifest text, so the scenario +//! needs a source file and nothing else. + +use anyhow::{Context as _, Result, ensure}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use netsuke::{ + ast::NetsukeManifest, + ir::BuildGraph, + manifest::{ + self, ManifestLoadOptions, ManifestName, OverlayCall, OverlayCallable, TemplateOverlays, + TestSurface, from_str_with_options, + }, + ninja_gen::generate_with_shell, + recipe_shell::RecipeShell, + stdlib::StdlibConfig, +}; +use rstest_bdd_macros::{given, then, when}; +use test_support::display_error_chain; + +use super::manifest::environment::manifest_env_reader; +use super::stdlib::parse_iso_timestamp; +use crate::bdd::fixtures::TestWorld; +use crate::bdd::helpers::parse_store::store_parse_outcome; + +/// Return the repository root, resolved at compile time. +fn repo_root() -> Utf8PathBuf { + Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// Return the manifest path the scenario selected. +fn selected_path(world: &TestWorld) -> Result { + world + .load_options_path + .get() + .map(Utf8PathBuf::from) + .context("the scenario must select a manifest file first") +} + +/// Read one repository-relative fixture. +fn read_fixture(relative: &Utf8Path) -> Result { + ensure!( + relative.is_relative(), + "a fixture path must be repository-relative: {relative}" + ); + let root = Dir::open_ambient_dir(repo_root(), ambient_authority()) + .context("open the repository root")?; + root.read_to_string(relative.as_str()) + .with_context(|| format!("read `{relative}`")) +} + +/// Return the stdlib configuration, carrying the scenario's clock when set. +/// +/// An unset clock is left at the default so a scenario that does not fix a +/// clock observes the same ambient behaviour a caller would. +fn scenario_config(world: &TestWorld) -> Result { + let config = StdlibConfig::from_current_dir()?; + Ok(match world.stdlib_clock.get() { + Some(clock) => config.with_clock(clock), + None => config, + }) +} + +/// Load `text` through the build registration. +fn load_build( + world: &TestWorld, + text: &str, + name: &ManifestName, + config: StdlibConfig, +) -> Result { + let reader = manifest_env_reader(world); + let policy = world.manifest_env_access_policy.borrow().clone(); + let environment = manifest::ManifestEnvironment::new(&reader, policy); + from_str_with_options(text, name, ManifestLoadOptions::full(config, environment)) + .context("the build surface must load the scenario manifest") +} + +/// Render a loaded manifest to Ninja text. +fn ninja_text(manifest: &NetsukeManifest) -> Result { + let graph = BuildGraph::from_manifest_for_shell(manifest, RecipeShell::Posix) + .context("lower the loaded manifest")?; + generate_with_shell(&graph, RecipeShell::Posix).context("generate Ninja text") +} + +/// Select the manifest the scenario loads. +#[given("the manifest file {path:string}")] +fn select_manifest(world: &TestWorld, path: &str) -> Result<()> { + ensure!( + !path.trim().is_empty(), + "manifest path must not be an empty string" + ); + world.load_options_path.set(path.to_owned()); + Ok(()) +} + +/// Fix the clock both surfaces are configured with. +#[given("a fixed clock at {instant:string}")] +fn fix_clock(world: &TestWorld, instant: &str) -> Result<()> { + let parsed = parse_iso_timestamp(instant)?; + world.stdlib_clock.set(netsuke::stdlib::fixed_clock(parsed)); + Ok(()) +} + +/// Load the selected manifest through both registrations. +#[when("the manifest is loaded in build mode and in test mode")] +fn load_in_both_modes(world: &TestWorld) -> Result<()> { + let path = selected_path(world)?; + let text = read_fixture(&path)?; + let config = scenario_config(world)?; + let name = ManifestName::new(path.as_str()); + + let build = load_build(world, &text, &name, config.clone())?; + let test = from_str_with_options( + &text, + &name, + ManifestLoadOptions::test(TestSurface::new(config)), + ) + .context("the test surface must load a pure manifest")?; + + world.load_options_build_ninja.set(ninja_text(&build)?); + world.load_options_test_ninja.set(ninja_text(&test)?); + Ok(()) +} + +/// Return the test surface, carrying the scenario's overlays when set. +fn test_surface(world: &TestWorld) -> Result { + let surface = TestSurface::new(scenario_config(world)?); + Ok(match world.load_options_overlays.get() { + Some(overlays) => surface.with_overlays(overlays), + None => surface, + }) +} + +/// Load the selected manifest through the test registration only. +#[when("the manifest is loaded in test mode")] +fn load_in_test_mode(world: &TestWorld) -> Result<()> { + let path = selected_path(world)?; + let text = read_fixture(&path)?; + let outcome = from_str_with_options( + &text, + &ManifestName::new(path.as_str()), + ManifestLoadOptions::test(test_surface(world)?), + ) + .map_err(|error| display_error_chain(error.as_ref())); + store_parse_outcome(&world.manifest, &world.manifest_error, outcome); + Ok(()) +} + +/// Register a two-value template overlay the next test load will see. +#[given("a template overlay {name:string} returning {first:string} and {second:string}")] +fn add_template_overlay(world: &TestWorld, name: &str, first: &str, second: &str) -> Result<()> { + ensure!( + !name.trim().is_empty(), + "an overlay name must not be an empty string" + ); + let values = vec![first.to_owned(), second.to_owned()]; + let callable = OverlayCallable::new(move |_call: &OverlayCall<'_, '_>| { + Ok(minijinja::value::Value::from_serialize(values.clone())) + }); + world + .load_options_overlays + .set(TemplateOverlays::default().with_function(name.to_owned(), callable)); + Ok(()) +} + +/// Assert both registrations emitted the same Ninja text. +#[then("both loads produce the same Ninja file")] +fn both_loads_agree(world: &TestWorld) -> Result<()> { + let build = world + .load_options_build_ninja + .get() + .context("the build load must have run")?; + let test = world + .load_options_test_ninja + .get() + .context("the test load must have run")?; + ensure!( + build == test, + "the surfaces emitted different Ninja text.\nBuild:\n{build}\nTest:\n{test}" + ); + Ok(()) +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 8700b54a2..259a44879 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -39,6 +39,7 @@ mod json_diagnostics; mod locale_resolution; mod manifest; mod manifest_command; +mod manifest_load_options; mod ninja; mod process; mod progress_output; diff --git a/tests/bdd/steps/stdlib/mod.rs b/tests/bdd/steps/stdlib/mod.rs index 4592d9597..7f84bb864 100644 --- a/tests/bdd/steps/stdlib/mod.rs +++ b/tests/bdd/steps/stdlib/mod.rs @@ -8,3 +8,6 @@ mod policy; mod rendering; mod types; mod workspace; + +// Scenario timestamps are parsed the same way wherever a step fixes a clock. +pub(crate) use parsing::parse_iso_timestamp; diff --git a/tests/build_l10n_audit_tests.rs b/tests/build_l10n_audit_tests.rs index cf2a67a0e..f5751458b 100644 --- a/tests/build_l10n_audit_tests.rs +++ b/tests/build_l10n_audit_tests.rs @@ -57,6 +57,7 @@ fn stage_audit_inputs(destination: &Path) -> Result<()> { "src/localization/keys.rs", "src/localization/check_keys.rs", "src/localization/clap_error_keys.rs", + "src/localization/manifest_keys.rs", ] { fs::copy(root.join(keys), destination.join(keys))?; } diff --git a/tests/build_l10n_keys_tests.rs b/tests/build_l10n_keys_tests.rs index a64d392a9..a33b0076f 100644 --- a/tests/build_l10n_keys_tests.rs +++ b/tests/build_l10n_keys_tests.rs @@ -254,8 +254,8 @@ fn the_real_invocation_is_selected(#[case] source: &str) -> Result<()> { /// The real macro bodies are the contract this parser exists to read. /// -/// The key table is split across three files, and the audit reads each, so -/// every one must parse on its own. +/// The key table is split across four files, and the audit reads all of them, +/// so each must parse on its own. #[rstest] #[case::core(include_str!("../src/localization/keys.rs"), "cli.about", 300)] #[case::check(include_str!("../src/localization/check_keys.rs"), "check.summary.truncated", 10)] @@ -264,6 +264,11 @@ fn the_real_invocation_is_selected(#[case] source: &str) -> Result<()> { "clap-error-too-many-values", 6 )] +#[case::manifest( + include_str!("../src/localization/manifest_keys.rs"), + "manifest.parse", + 40 +)] fn the_repository_macro_parses( #[case] source: &str, #[case] expected_key: &str, diff --git a/tests/data/manifest_load_options/boolean_interpolation.yml b/tests/data/manifest_load_options/boolean_interpolation.yml new file mode 100644 index 000000000..93a044f59 --- /dev/null +++ b/tests/data/manifest_load_options/boolean_interpolation.yml @@ -0,0 +1,21 @@ +# The only fixture whose output differs by registration surface. +# +# `{{ true }}` and `{{ false }}` are Jinja literals, so `Display for Value` +# decides their text: `True`/`False` by default, `true`/`false` once +# `register_legacy_boolean_formatter` installs its formatter. That formatter is +# installed by the build registration and *not* by the manifest-query +# registration, so this fixture is the observable lever separating the two +# surfaces at the manifest level. +# +# The literals sit in a target-level `vars` entry and in the target +# `description` because `render_target` substitutes those fields in both render +# modes; a top-level `vars` block would not do, since `render_manifest_with_mode` +# only clones it as rule context and never renders its values. +netsuke_version: "1.0.0" +targets: + - name: booleans + description: "literals: {{ true }}/{{ false }}" + vars: + enabled: "{{ true }}" + disabled: "{{ false }}" + command: "echo {{ enabled }} {{ disabled }}" diff --git a/tests/data/manifest_load_options/clock_now.yml b/tests/data/manifest_load_options/clock_now.yml new file mode 100644 index 000000000..3963ef20c --- /dev/null +++ b/tests/data/manifest_load_options/clock_now.yml @@ -0,0 +1,16 @@ +# `now()` output under a fixed clock. +# +# Deterministic only for entries that accept a `StdlibConfig`, since that is the +# only seam carrying the clock. The wrapper-equivalence rows inject +# `fixed_clock(2026-06-08 12:00:00 UTC)`, so every row over this fixture records +# the same instant. +# +# The call sits in the `description`: `render_target` substitutes it in both +# render modes, and it is not a recipe body, so the manifest-query surface +# reaches it too — where `now` is the host-dependent stub rather than the real +# helper. +netsuke_version: "1.0.0" +targets: + - name: clock + description: "started {{ now() }}" + command: "echo stamped" diff --git a/tests/data/manifest_load_options/malformed.yml b/tests/data/manifest_load_options/malformed.yml new file mode 100644 index 000000000..9e172fc2c --- /dev/null +++ b/tests/data/manifest_load_options/malformed.yml @@ -0,0 +1,4 @@ +# Not valid YAML: the sequence is never closed. YAML is parsed before any Jinja +# runs, so the error text this produces is the same under every registration and +# the fixture pins the loader's parse-failure path rather than a helper. +targets: [{name: broken diff --git a/tests/data/manifest_load_options/pure.yml b/tests/data/manifest_load_options/pure.yml new file mode 100644 index 000000000..20a9e8d25 --- /dev/null +++ b/tests/data/manifest_load_options/pure.yml @@ -0,0 +1,8 @@ +# The baseline fixture: it calls no helper that reads or acts on the host, so +# any loader can render it and any difference between two loaders is a +# difference in the loader rather than in what the manifest asked for. +netsuke_version: "1.0.0" +targets: + - name: pure + description: "joined {{ ['a', 'b'] | join('-') }}" + command: "echo pure" diff --git a/tests/data/manifest_load_options/reads_env.yml b/tests/data/manifest_load_options/reads_env.yml new file mode 100644 index 000000000..fb8c297aa --- /dev/null +++ b/tests/data/manifest_load_options/reads_env.yml @@ -0,0 +1,9 @@ +# Reads one variable through whichever reader the loader injects. Every row +# that loads it supplies a reader that answers precisely this name and refuses +# the rest, so the rendered value is a constant and the fixture pins the +# plumbing rather than the host's environment. +netsuke_version: "1.0.0" +targets: + - name: env + description: "profile {{ env('NETSUKE_TEST_ENV') }}" + command: "echo env" diff --git a/tests/data/manifest_load_options/uses_fetch.yml b/tests/data/manifest_load_options/uses_fetch.yml new file mode 100644 index 000000000..ba0bde35f --- /dev/null +++ b/tests/data/manifest_load_options/uses_fetch.yml @@ -0,0 +1,24 @@ +# `fetch()` against a scheme the default policy refuses. +# +# `NetworkPolicy::default()` is `https_only()`, and `NetworkPolicy::evaluate` +# checks the scheme allowlist before it does anything else, so this fixture +# fails with `SchemeNotAllowed` without opening a socket. That is deliberate: a +# fixture that actually performed a request would put a network dependency +# inside a characterization test. +# +# No row in the public corpus loads this fixture yet. `Case::is_loadable` +# withholds a host-acting fixture from a loader whose capabilities do not refuse +# host helpers, and every capabilities constructor in +# `tests/manifest_load_options/fixtures/` takes the real helpers. The surface +# this fixture is written for is the refusing one, where `fetch` is a typed +# refusal rather than a request, so the `http://` scheme can never be reached +# from a load that would honour it. +# +# The `http://` scheme is therefore load-bearing, not an oversight. Changing it +# to `https://` would make the fixture reach the network under any future +# surface that permits `fetch`. +netsuke_version: "1.0.0" +targets: + - name: fetched + description: "{{ fetch('http://127.0.0.1:1/toolchain.json') }}" + command: "echo fetched" diff --git a/tests/data/manifest_load_options/uses_shell.yml b/tests/data/manifest_load_options/uses_shell.yml new file mode 100644 index 000000000..a26b541b7 --- /dev/null +++ b/tests/data/manifest_load_options/uses_shell.yml @@ -0,0 +1,19 @@ +# `shell` under a benign command. +# +# The build surface executes this filter literally, so the command is `echo +# fixture`: a row that reaches it spawns a shell and prints one word, which is +# harmless and has no filesystem effect. The manifest-query surface registers +# `shell` as a deliberate failure instead, so the same fixture exercises the +# refusal path there. +# +# `shell` is a filter, so it needs a value on the left; the empty string +# supplies one and the command ignores its input. +# +# The call is in the `description` so both the build and query surfaces reach +# it: recipe bodies are skipped in the query render mode, but descriptions are +# substituted in both. +netsuke_version: "1.0.0" +targets: + - name: shelled + description: "{{ '' | shell('echo fixture') }}" + command: "echo shelled" diff --git a/tests/features/manifest_load_options.feature b/tests/features/manifest_load_options.feature new file mode 100644 index 000000000..4af6f27fc --- /dev/null +++ b/tests/features/manifest_load_options.feature @@ -0,0 +1,26 @@ +Feature: Options-carrying manifest loading + + The loader offers the same manifest two capability surfaces. The build + surface keeps every helper; the test surface withholds the ones that reach + the host. These scenarios pin the two ends of that contract: a manifest that + needs nothing outside itself loads identically in both, and one that needs a + process fails under test with a refusal that names what it wanted. + + Scenario: A pure manifest loads identically in build and test modes + Given the manifest file "tests/data/manifest_load_options/boolean_interpolation.yml" + And a fixed clock at "2026-06-08T12:00:00Z" + When the manifest is loaded in build mode and in test mode + Then both loads produce the same Ninja file + + Scenario: Test mode refuses a process helper + Given the manifest file "tests/data/manifest_load_options/uses_shell.yml" + When the manifest is loaded in test mode + Then parsing the manifest fails + And the error message contains "[netsuke::jinja::test_mode::unavailable]" + And the error message contains "shell" + + Scenario: A template overlay stands in for glob under test + Given the manifest file "tests/data/glob.yml" + And a template overlay "glob" returning "glob_files/a.txt" and "glob_files/b.txt" + When the manifest is loaded in test mode + Then the manifest has targets named "a.out, b.out" \ No newline at end of file diff --git a/tests/manifest_load_options/corpus_fidelity.rs b/tests/manifest_load_options/corpus_fidelity.rs new file mode 100644 index 000000000..c326ee02e --- /dev/null +++ b/tests/manifest_load_options/corpus_fidelity.rs @@ -0,0 +1,378 @@ +//! Every corpus manifest loads identically in both modes, or refuses by name. +//! +//! The property run covers manifests built to be pure. This one covers the +//! manifests the repository already keeps, including the ones that reach the +//! network, run a command, read the filesystem, look up a host tool, or consult +//! the clock: loading each with the same injected reader and the same fixed +//! clock must either produce the same result in the build and test surfaces, or +//! the test load must fail with a typed [`TestModeRefusal`] whose operation is +//! one the inventory withholds. It must never succeed with a *different* +//! manifest, because that would mean the test surface silently changed what the +//! author asked for rather than saying it cannot do it. +//! +//! "Same result" includes the failing case: two loads that fail with identical +//! error text are equal, because the test surface changed nothing the loader +//! would have reported. Two failures with *different* text are a difference +//! unless the test one is a refusal, which is the whole point of the refusal +//! carrying its operation as data. +//! +//! The run also asserts its own breadth. At least one file must come out equal, +//! and at least one refusal must be seen from each family of withheld +//! capability, so a surface that stopped refusing — or a corpus that stopped +//! exercising it — cannot pass by comparing nothing. +//! +//! The clock family is observed separately. Both modes are given the same fixed +//! clock, so `now()` is *admitted* under test and the corpus cannot produce a +//! clock refusal; [`ambient_clock_refusal`] loads the clock fixture once more +//! with the default clock, which is the configuration in which the refusal +//! exists at all. + +use std::{collections::BTreeSet, error::Error as _, sync::Arc}; + +use anyhow::{Context as _, Result, bail, ensure}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use minijinja::Error as MiniJinjaError; +use netsuke::{ + ast::NetsukeManifest, + manifest::{ + EnvAccessPolicy, EnvReadError, EnvReader, ManifestEnvironment, ManifestLoadOptions, + ManifestName, TestSurface, from_str_with_options, + }, + stdlib::{StdlibConfig, TestModeRefusal, fixed_clock}, +}; +use time::OffsetDateTime; +use time::macros::datetime; + +use super::fixtures::fixed_clock_config; + +/// The instant both surfaces are configured with, so `now()` agrees. +const FIXED_INSTANT: OffsetDateTime = datetime!(2026-06-08 12:00:00 UTC); + +/// The directories whose manifests make up the corpus. +const CORPUS_ROOTS: [&str; 2] = ["tests/data", "tests/data/manifest_load_options"]; + +/// The clock fixture, loaded again with the default clock for the clock family. +const CLOCK_FIXTURE: &str = "tests/data/manifest_load_options/clock_now.yml"; + +/// A family of withheld host capability. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum Family { + /// Reaching the network. + Network, + /// Spawning a process. + Process, + /// Touching the filesystem. + Filesystem, + /// Looking a tool up on the host. + HostLookup, + /// Consulting the ambient clock. + Clock, +} + +impl Family { + /// Every family a refusal must be observed from. + const ALL: [Self; 5] = [ + Self::Network, + Self::Process, + Self::Filesystem, + Self::HostLookup, + Self::Clock, + ]; + + /// Return the name used in a failure message. + const fn label(self) -> &'static str { + match self { + Self::Network => "network", + Self::Process => "process", + Self::Filesystem => "filesystem", + Self::HostLookup => "host lookup", + Self::Clock => "clock", + } + } +} + +/// The withheld operations, with the capability family each belongs to. +/// +/// The integration crate cannot read the crate-internal `SURFACE` table, so +/// this one restates the classification. That is deliberate rather than +/// unfortunate: an operation refused without a family here makes this test +/// fail, which is the reminder to classify it. `env` is absent because the test +/// surface admits it. `now` is present even though the fixed-clock corpus run +/// admits it, because the ambient-clock probe withholds it. +const OPERATION_FAMILIES: &[(&str, Family)] = &[ + ("fetch", Family::Network), + ("shell", Family::Process), + ("grep", Family::Process), + ("glob", Family::Filesystem), + ("contents", Family::Filesystem), + ("realpath", Family::Filesystem), + ("expanduser", Family::Filesystem), + ("size", Family::Filesystem), + ("linecount", Family::Filesystem), + ("hash", Family::Filesystem), + ("digest", Family::Filesystem), + ("dir", Family::Filesystem), + ("file", Family::Filesystem), + ("symlink", Family::Filesystem), + ("pipe", Family::Filesystem), + ("block_device", Family::Filesystem), + ("char_device", Family::Filesystem), + ("device", Family::Filesystem), + ("which", Family::HostLookup), + ("command_available", Family::HostLookup), + ("now", Family::Clock), +]; + +/// Return the family a refused operation belongs to. +fn family_of(operation: &str) -> Option { + OPERATION_FAMILIES + .iter() + .find(|(name, _)| *name == operation) + .map(|(_, family)| *family) +} + +/// Return the repository root, resolved at compile time. +fn repo_root() -> Utf8PathBuf { + Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// Return every corpus manifest path, repository-relative and deduplicated. +/// +/// The second root is nested inside the first, so a plain concatenation would +/// visit those manifests twice. A set makes the walk's breadth independent of +/// how the roots happen to be laid out. +fn corpus_files() -> Result> { + let root = Dir::open_ambient_dir(repo_root(), ambient_authority()) + .context("open the repository root")?; + let mut files = BTreeSet::new(); + for directory in CORPUS_ROOTS { + collect_manifests(&root, Utf8Path::new(directory), &mut files) + .with_context(|| format!("walk `{directory}`"))?; + } + ensure!(!files.is_empty(), "the corpus walk found no manifests"); + Ok(files) +} + +/// Append every YAML manifest beneath `directory` to `files`. +fn collect_manifests( + root: &Dir, + directory: &Utf8Path, + files: &mut BTreeSet, +) -> Result<()> { + for entry in root + .read_dir(directory) + .with_context(|| format!("read `{directory}`"))? + { + let resolved = entry.with_context(|| format!("read an entry of `{directory}`"))?; + let name = resolved + .file_name() + .with_context(|| format!("read an entry name in `{directory}`"))?; + let path = directory.join(&name); + if resolved.file_type()?.is_dir() { + collect_manifests(root, &path, files)?; + } else if matches!(path.extension(), Some("yml" | "yaml")) { + files.insert(path); + } + } + Ok(()) +} + +/// Read one corpus manifest. +fn read_manifest(path: &Utf8Path) -> Result { + let root = Dir::open_ambient_dir(repo_root(), ambient_authority()) + .context("open the repository root")?; + root.read_to_string(path.as_str()) + .with_context(|| format!("read `{path}`")) +} + +/// Return a reader that answers one name and refuses the rest. +fn reader() -> EnvReader { + Arc::new(|name: &str| { + if name == "NETSUKE_TEST_ENV" { + Ok("corpus".to_owned()) + } else { + Err(EnvReadError::NotPresent) + } + }) +} + +/// Load `yaml` through the build registration with `config`. +fn load_full(yaml: &str, config: &StdlibConfig) -> Result { + let reader = reader(); + let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); + from_str_with_options( + yaml, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::full(config.clone(), environment), + ) +} + +/// Load `yaml` through the test registration with `config`. +fn load_test(yaml: &str, config: &StdlibConfig) -> Result { + let surface = TestSurface::new(config.clone()).with_env_reader(reader()); + from_str_with_options( + yaml, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::test(surface), + ) +} + +/// Return the operation named by a test-mode refusal in an error chain. +fn refusal_operation(error: &anyhow::Error) -> Option<&str> { + error.chain().find_map(|cause| { + let jinja = cause.downcast_ref::()?; + jinja + .source()? + .downcast_ref::() + .map(TestModeRefusal::operation) + }) +} + +/// Render a manifest as canonical JSON for comparison. +fn canonical(manifest: &NetsukeManifest) -> Result { + serde_json_canonicalizer::to_string(manifest).context("serialize a loaded manifest") +} + +/// Compare one manifest's outcome under both surfaces. +/// +/// Returns the refused operation when the test load refused, and `None` when +/// the two outcomes were equal. +fn compare(text: &str, config: &StdlibConfig) -> Result> { + let build_outcome = load_full(text, config); + let test_outcome = load_test(text, config); + match (&build_outcome, &test_outcome) { + (Ok(build_manifest), Ok(test_manifest)) => { + ensure_same_manifest(build_manifest, test_manifest)?; + Ok(None) + } + (Ok(_), Err(error)) => Ok(Some(refused_operation(error)?)), + (Err(error), Ok(_)) => { + bail!("the test load succeeded where the build load failed: {error:#}") + } + (Err(build_error), Err(test_error)) => compare_failures(build_error, test_error), + } +} + +/// Require two successful loads to produce the same manifest. +fn ensure_same_manifest(build: &NetsukeManifest, test: &NetsukeManifest) -> Result<()> { + ensure!( + canonical(build)? == canonical(test)?, + "both surfaces loaded, but produced different manifests" + ); + Ok(()) +} + +/// Return the operation named by a test-load refusal. +/// +/// A test load that failed without a refusal is an error here: the difference +/// between the surfaces is then unexplained, not a withheld capability. +fn refused_operation(error: &anyhow::Error) -> Result { + refusal_operation(error) + .map(ToOwned::to_owned) + .with_context(|| format!("the test load failed without a typed refusal: {error:#}")) +} + +/// Compare two failing loads, accepting a refusal on the test side. +fn compare_failures( + build_error: &anyhow::Error, + test_error: &anyhow::Error, +) -> Result> { + if let Some(operation) = refusal_operation(test_error) { + return Ok(Some(operation.to_owned())); + } + ensure_same_failure_text(build_error, test_error)?; + Ok(None) +} + +/// Require two non-refusal failures to report identical text. +fn ensure_same_failure_text(build_error: &anyhow::Error, test_error: &anyhow::Error) -> Result<()> { + let build_text = format!("{build_error:#}"); + let test_text = format!("{test_error:#}"); + ensure!( + build_text == test_text, + "both surfaces failed, but differently:\nBuild: {build_text}\nTest: {test_text}" + ); + Ok(()) +} + +/// Observe the clock refusal the fixed-clock corpus run cannot produce. +fn ambient_clock_refusal() -> Result<(String, String)> { + let text = read_manifest(Utf8Path::new(CLOCK_FIXTURE))?; + let surface = TestSurface::new(StdlibConfig::from_current_dir()?); + let error = from_str_with_options( + &text, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::test(surface), + ) + .expect_err("the ambient clock must refuse under test"); + let operation = refusal_operation(&error) + .with_context(|| format!("the clock fixture failed without a typed refusal: {error:#}"))?; + Ok((CLOCK_FIXTURE.to_owned(), operation.to_owned())) +} + +/// Every corpus manifest is equal across surfaces or refuses by name. +#[test] +fn corpus_loads_identically_or_refuses() -> Result<()> { + let config = fixed_clock_config()?.with_clock(fixed_clock(FIXED_INSTANT)); + let mut equal = Vec::new(); + let mut refusals = vec![ambient_clock_refusal()?]; + let mut failures = Vec::new(); + + for path in corpus_files()? { + let text = match read_manifest(&path) { + Ok(text) => text, + Err(error) => { + failures.push(format!("{path}: {error:#}")); + continue; + } + }; + match compare(&text, &config) { + Ok(None) => equal.push(path), + Ok(Some(operation)) => refusals.push((path.to_string(), operation)), + Err(error) => failures.push(format!("{path}: {error:#}")), + } + } + + ensure!( + failures.is_empty(), + "{} of the corpus disagreed between surfaces:\n{}", + failures.len(), + failures.join("\n") + ); + ensure!( + !equal.is_empty(), + "no corpus manifest loaded identically in both surfaces; the comparison is vacuous" + ); + assert_every_family_refuses(&refusals)?; + Ok(()) +} + +/// Assert the run saw a refusal from every capability family. +fn assert_every_family_refuses(refusals: &[(String, String)]) -> Result<()> { + let mut unclassified = Vec::new(); + let mut seen = BTreeSet::new(); + for (path, operation) in refusals { + match family_of(operation) { + Some(family) => { + seen.insert(family); + } + None => unclassified.push(format!("{path}: `{operation}`")), + } + } + ensure!( + unclassified.is_empty(), + "refused operations outside the classified inventory:\n{}", + unclassified.join("\n") + ); + let missing: Vec<&str> = Family::ALL + .into_iter() + .filter(|family| !seen.contains(family)) + .map(Family::label) + .collect(); + ensure!( + missing.is_empty(), + "no corpus manifest refused from these families: {missing:?}; refusals seen: {refusals:?}" + ); + Ok(()) +} diff --git a/tests/manifest_load_options/fidelity_property/compare.rs b/tests/manifest_load_options/fidelity_property/compare.rs new file mode 100644 index 000000000..1c1e508d6 --- /dev/null +++ b/tests/manifest_load_options/fidelity_property/compare.rs @@ -0,0 +1,104 @@ +//! The three-layer comparison one generated manifest is put through. +//! +//! Split from `fidelity_property/mod.rs`, which owns the property and its +//! coverage floor, to keep both files under Whitaker's raw-line module cap. Each +//! layer is compared separately so a failure names which one drifted: the +//! canonical manifest, the [`GraphView`] projection of the lowered graph, or the +//! emitted Ninja text. +//! +//! [`GraphView`]: netsuke::graph_view::GraphView + +use anyhow::Result; +use netsuke::{ + ast::NetsukeManifest, + graph_view::GraphView, + ir::BuildGraph, + manifest::{ + EnvAccessPolicy, ManifestEnvironment, ManifestLoadOptions, ManifestName, TestSurface, + from_str_with_options, + }, + ninja_gen::generate_with_shell, + recipe_shell::RecipeShell, + stdlib::StdlibConfig, +}; +use proptest::test_runner::TestCaseError; + +use super::super::fixtures::one_var_reader; +use super::spec::Spec; + +/// Load `yaml` through the build registration with a fixed clock. +fn load_full(yaml: &str, config: &StdlibConfig) -> Result { + let reader = one_var_reader(); + let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); + from_str_with_options( + yaml, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::full(config.clone(), environment), + ) +} + +/// Load `yaml` through the test registration with the same fixed clock. +fn load_test(yaml: &str, config: &StdlibConfig) -> Result { + let surface = TestSurface::new(config.clone()).with_env_reader(one_var_reader()); + from_str_with_options( + yaml, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::test(surface), + ) +} + +/// Fail a property case with a message naming the spec that produced it. +fn fail(spec: &Spec, message: impl std::fmt::Display) -> TestCaseError { + TestCaseError::fail(format!("{message}\n--- manifest ---\n{}", spec.yaml())) +} + +/// Compare the manifest, graph projection, and Ninja text of one spec. +pub(super) fn compare_modes( + spec: &Spec, + config: &StdlibConfig, +) -> std::result::Result<(), TestCaseError> { + let yaml = spec.yaml(); + let full = load_full(&yaml, config) + .map_err(|error| fail(spec, format_args!("Full load failed: {error:#}")))?; + let test = load_test(&yaml, config) + .map_err(|error| fail(spec, format_args!("Test load failed: {error:#}")))?; + + let full_json = serde_json_canonicalizer::to_string(&full).map_err(|error| { + fail( + spec, + format_args!("Full manifest did not serialize: {error}"), + ) + })?; + let test_json = serde_json_canonicalizer::to_string(&test).map_err(|error| { + fail( + spec, + format_args!("Test manifest did not serialize: {error}"), + ) + })?; + if full_json != test_json { + return Err(fail( + spec, + format_args!("manifests differ:\nFull: {full_json}\nTest: {test_json}"), + )); + } + + let full_graph = BuildGraph::from_manifest_for_shell(&full, RecipeShell::Posix) + .map_err(|error| fail(spec, format_args!("Full graph failed: {error}")))?; + let test_graph = BuildGraph::from_manifest_for_shell(&test, RecipeShell::Posix) + .map_err(|error| fail(spec, format_args!("Test graph failed: {error}")))?; + if GraphView::from_build_graph(&full_graph) != GraphView::from_build_graph(&test_graph) { + return Err(fail(spec, "graph projections differ")); + } + + let full_ninja = generate_with_shell(&full_graph, RecipeShell::Posix) + .map_err(|error| fail(spec, format_args!("Full Ninja generation failed: {error}")))?; + let test_ninja = generate_with_shell(&test_graph, RecipeShell::Posix) + .map_err(|error| fail(spec, format_args!("Test Ninja generation failed: {error}")))?; + if full_ninja != test_ninja { + return Err(fail( + spec, + format_args!("Ninja text differs:\nFull:\n{full_ninja}\nTest:\n{test_ninja}"), + )); + } + Ok(()) +} diff --git a/tests/manifest_load_options/fidelity_property/mod.rs b/tests/manifest_load_options/fidelity_property/mod.rs new file mode 100644 index 000000000..9673ace57 --- /dev/null +++ b/tests/manifest_load_options/fidelity_property/mod.rs @@ -0,0 +1,97 @@ +//! Differential property: a pure manifest means the same thing in both modes. +//! +//! The claim is narrow on purpose. It covers manifests built only from +//! constructs the test surface already admits — variables, macros, `foreach` +//! over literal lists, `when` comparisons, Boolean interpolation, the lexical +//! path filters, the collection filters, `timedelta`, and `now()` under an +//! injected clock — and says that the build surface and the test surface +//! produce the same manifest, the same graph, and the same Ninja text for +//! them. A manifest that reaches the host is covered by the corpus run, which +//! expectation is "equal *or* a typed refusal"; here every generated manifest +//! must load in both modes. +//! +//! Three comparison layers are recorded rather than one because each can drift +//! independently. The manifest catches a rendering difference; the [`GraphView`] +//! projection catches a difference in how the rendered manifest was interpreted; +//! and the Ninja text catches a difference in the final emitted build file. +//! Comparing only the manifest would call two loads equal whose recipes were +//! lowered differently. +//! +//! The generator is valid by construction rather than filtered after the fact: +//! a rejected case would silently shrink the domain and the property would +//! weaken without saying so. Two counters keep the coverage honest — every +//! construct must be generated at least ten times in a run, so a strategy that +//! stops producing, say, `timedelta` fails loudly instead of passing over a +//! smaller grammar. +//! +//! The generator's data model and strategy live in `spec.rs` and the +//! three-layer comparison in `compare.rs`; this module owns the property, the +//! run configuration, and the coverage floor. +//! +//! [`GraphView`]: netsuke::graph_view::GraphView + +mod compare; +mod spec; + +use anyhow::{Context as _, Result}; +use proptest::test_runner::{Config as ProptestConfig, TestRunner}; +use rstest::rstest; +use time::OffsetDateTime; +use time::macros::datetime; + +use self::compare::compare_modes; +use self::spec::{Coverage, Tally, spec_strategy}; +use super::fixtures::fixed_clock_config; + +/// The instant both surfaces are configured with, so `now()` agrees. +const FIXED_INSTANT: OffsetDateTime = datetime!(2026-06-08 12:00:00 UTC); + +/// How many generated manifests a run must contain. +const CASES: u32 = 128; + +/// How many times each construct must be generated in a run. +/// +/// Ten is low enough that a 128-case run clears it comfortably and high enough +/// that a construct which has dropped out of the strategy cannot pass by luck. +const MIN_CONSTRUCT_COVERAGE: usize = 10; + +/// Every construct clears the per-run coverage floor. +fn assert_coverage(coverage: Coverage) { + let required = [ + ("targets", coverage.targets), + ("foreach targets", coverage.foreach_targets), + ("when clauses", coverage.whens), + ("Boolean interpolation", coverage.boolean), + ("lexical path filters", coverage.path_filters), + ("collection filters", coverage.collection_filters), + ("timedelta", coverage.timedelta), + ("now()", coverage.now), + ("macro calls", coverage.macro_calls), + ]; + for (name, count) in required { + assert!( + count >= MIN_CONSTRUCT_COVERAGE, + "the strategy generated `{name}` {count} times, fewer than {MIN_CONSTRUCT_COVERAGE}; \ + the grammar is narrower than the property claims" + ); + } +} + +/// The build and test surfaces agree on every generated pure manifest. +#[rstest] +fn full_and_test_agree_on_generated_manifests() -> Result<()> { + let config = fixed_clock_config()?.with_clock(netsuke::stdlib::fixed_clock(FIXED_INSTANT)); + let tally = Tally::default(); + let mut runner = TestRunner::new(ProptestConfig { + cases: CASES, + ..ProptestConfig::default() + }); + runner + .run(&spec_strategy(), |spec| { + tally.record(spec.coverage()); + compare_modes(&spec, &config) + }) + .context("the Full/Test differential property failed")?; + assert_coverage(tally.get()); + Ok(()) +} diff --git a/tests/manifest_load_options/fidelity_property/spec.rs b/tests/manifest_load_options/fidelity_property/spec.rs new file mode 100644 index 000000000..3951c85a4 --- /dev/null +++ b/tests/manifest_load_options/fidelity_property/spec.rs @@ -0,0 +1,312 @@ +//! The generated-manifest model and the strategy that produces it. +//! +//! Split from `fidelity_property/mod.rs`, which holds the property itself, to +//! keep both files under Whitaker's raw-line module cap. A generated manifest is +//! held as data — [`Spec`] over [`Target`] over [`Construct`] — so its YAML, its +//! coverage, and the strategy's combinations are all derived from one value +//! rather than maintained as parallel descriptions. +//! +//! The model is crate-private: `fidelity_property` re-exports the pieces its +//! run needs through `pub(super)`, and nothing outside that module tree names +//! these types. + +use std::cell::Cell; +use std::fmt::Write as _; + +use proptest::{prelude::*, sample::select}; + +/// One template construct the property varies. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Construct { + /// Boolean interpolation, which exercises the legacy formatter. + Boolean, + /// `basename`, one of the lexical path filters. + Basename, + /// `dirname`, one of the lexical path filters. + Dirname, + /// `with_suffix`, one of the lexical path filters. + WithSuffix, + /// `relative_to`, one of the lexical path filters. + RelativeTo, + /// `uniq`, one of the collection filters. + Uniq, + /// `flatten`, one of the collection filters. + Flatten, + /// `compact`, one of the collection filters. + Compact, + /// `group_by`, one of the collection filters. + GroupBy, + /// `timedelta`, a deterministic time function. + TimeDelta, + /// `now()`, deterministic only because a clock was injected. + Now, + /// A macro call defined in the same manifest. + Macro, +} + +/// Every construct the strategy may choose from. +const CONSTRUCTS: &[Construct] = &[ + Construct::Boolean, + Construct::Basename, + Construct::Dirname, + Construct::WithSuffix, + Construct::RelativeTo, + Construct::Uniq, + Construct::Flatten, + Construct::Compact, + Construct::GroupBy, + Construct::TimeDelta, + Construct::Now, + Construct::Macro, +]; + +impl Construct { + /// Return the template expression that exercises this construct. + fn expression(self) -> &'static str { + match self { + Self::Boolean => "{{ flag }}", + Self::Basename => "{{ 'dir/file.tar.gz' | basename }}", + Self::Dirname => "{{ 'dir/file.tar.gz' | dirname }}", + Self::WithSuffix => "{{ 'file.txt' | with_suffix('.md') }}", + Self::RelativeTo => "{{ 'dir/file.txt' | relative_to('dir') }}", + Self::Uniq => "{{ ['b', 'a', 'a'] | uniq | join(',') }}", + Self::Flatten => "{{ [['a'], ['b']] | flatten | join(',') }}", + Self::Compact => "{{ ['a', '', none] | compact | join(',') }}", + Self::GroupBy => { + "{{ ([{'kind': 'tool'}, {'kind': 'tool'}] | group_by('kind')).tool | length }}" + } + Self::TimeDelta => "{{ timedelta(seconds=5) }}", + Self::Now => "{{ now() }}", + Self::Macro => "{{ shout('hello') }}", + } + } +} + +/// One target in a generated manifest. +#[derive(Clone, Debug)] +struct Target { + /// Position in the manifest, used to keep names unique. + index: usize, + /// Whether the target iterates a literal three-item list. + is_foreach: bool, + /// Constructs this target's command interpolates. + constructs: Vec, + /// Whether the target declares a source. + has_source: bool, +} + +/// A generated manifest, held as data so its YAML and its coverage are both +/// derived from the same value. +#[derive(Clone, Debug)] +pub(super) struct Spec { + /// Targets, in manifest order. + targets: Vec, + /// Whether a top-level Boolean variable is declared. + has_vars: bool, + /// Whether a macro is declared. + has_macro: bool, +} + +/// The number of times each construct appears in one spec. +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct Coverage { + /// `Boolean` occurrences. + pub(super) boolean: usize, + /// Lexical path filter occurrences. + pub(super) path_filters: usize, + /// Collection filter occurrences. + pub(super) collection_filters: usize, + /// `timedelta` occurrences. + pub(super) timedelta: usize, + /// `now()` occurrences. + pub(super) now: usize, + /// Macro-call occurrences. + pub(super) macro_calls: usize, + /// `foreach` target occurrences. + pub(super) foreach_targets: usize, + /// `when` clause occurrences. + pub(super) whens: usize, + /// Target occurrences. + pub(super) targets: usize, +} + +impl Coverage { + /// Add `other` into this tally. + fn absorb(&mut self, other: Self) { + self.boolean += other.boolean; + self.path_filters += other.path_filters; + self.collection_filters += other.collection_filters; + self.timedelta += other.timedelta; + self.now += other.now; + self.macro_calls += other.macro_calls; + self.foreach_targets += other.foreach_targets; + self.whens += other.whens; + self.targets += other.targets; + } +} + +/// The running tally for one property run. +#[derive(Debug, Default)] +pub(super) struct Tally { + /// Construct counts accumulated so far. + coverage: Cell, +} + +impl Tally { + /// Record one generated spec. + pub(super) fn record(&self, coverage: Coverage) { + let mut total = self.coverage.get(); + total.absorb(coverage); + self.coverage.set(total); + } + + /// Return the tally recorded so far. + pub(super) fn get(&self) -> Coverage { + self.coverage.get() + } +} + +impl Spec { + /// Render the manifest as YAML. + pub(super) fn yaml(&self) -> String { + let mut yaml = String::from("netsuke_version: \"1.0.0\"\n"); + if self.has_vars { + yaml.push_str("vars:\n flag: true\n"); + } + if self.has_macro { + yaml.push_str( + "macros:\n - signature: \"shout(text)\"\n body: \"{{ text | upper }}\"\n", + ); + } + yaml.push_str("targets:\n"); + for target in &self.targets { + Self::write_target(&mut yaml, target); + } + yaml + } + + /// Append one target's YAML to `yaml`. + fn write_target(yaml: &mut String, target: &Target) { + let name = if target.is_foreach { + format!("out{}-{{{{ item }}}}.txt", target.index) + } else { + format!("out{}.txt", target.index) + }; + let command = target + .constructs + .iter() + .map(|construct| construct.expression()) + .collect::>() + .join(" "); + writeln!(yaml, " - name: \"{name}\"").expect("writing to a String cannot fail"); + writeln!(yaml, " command: \"echo {command}\"").expect("writing to a String cannot fail"); + if target.has_source { + writeln!(yaml, " sources: \"in{}.txt\"", target.index) + .expect("writing to a String cannot fail"); + } + if target.is_foreach { + yaml.push_str(" foreach:\n - alpha\n - beta\n - gamma\n"); + writeln!(yaml, " when: \"item != 'skip'\"") + .expect("writing to a String cannot fail"); + } else if target.index == 0 { + // A comparison, not a Boolean variable: `when` must accept an + // expression and the two modes must agree on its result. + writeln!(yaml, " when: \"1 < 2\"").expect("writing to a String cannot fail"); + } + } + + /// Count what this spec exercises. + pub(super) fn coverage(&self) -> Coverage { + let mut coverage = Coverage { + targets: self.targets.len(), + ..Coverage::default() + }; + for target in &self.targets { + coverage.foreach_targets += usize::from(target.is_foreach); + coverage.whens += usize::from(target.is_foreach || target.index == 0); + for construct in &target.constructs { + match construct { + Construct::Boolean => coverage.boolean += 1, + Construct::Basename + | Construct::Dirname + | Construct::WithSuffix + | Construct::RelativeTo => coverage.path_filters += 1, + Construct::Uniq + | Construct::Flatten + | Construct::Compact + | Construct::GroupBy => coverage.collection_filters += 1, + Construct::TimeDelta => coverage.timedelta += 1, + Construct::Now => coverage.now += 1, + Construct::Macro => coverage.macro_calls += 1, + } + } + } + coverage + } +} + +/// Build a spec from the strategy's raw choices. +/// +/// The first target always carries the full deterministic repertoire, so every +/// construct is exercised at least once per case; the per-target extras then +/// vary the combinations. Constructs whose precondition is absent — a Boolean +/// variable or a macro — are dropped here rather than left to fail a load. +fn build_spec(extras: Vec>, has_vars: bool, has_macro: bool, mask: u8) -> Spec { + let targets = extras + .into_iter() + .enumerate() + .map(|(index, mut constructs)| { + if index == 0 { + let mut repertoire = vec![ + Construct::Basename, + Construct::Dirname, + Construct::WithSuffix, + Construct::RelativeTo, + Construct::Uniq, + Construct::Flatten, + Construct::Compact, + Construct::GroupBy, + Construct::TimeDelta, + Construct::Now, + ]; + if has_vars { + repertoire.push(Construct::Boolean); + } + if has_macro { + repertoire.push(Construct::Macro); + } + repertoire.append(&mut constructs); + constructs = repertoire; + } + constructs.retain(|construct| match construct { + Construct::Boolean => has_vars, + Construct::Macro => has_macro, + _ => true, + }); + Target { + index, + is_foreach: (mask >> index) & 1 == 1, + constructs, + has_source: index.is_multiple_of(2), + } + }) + .collect(); + Spec { + targets, + has_vars, + has_macro, + } +} + +/// The strategy the property runs over. +pub(super) fn spec_strategy() -> impl Strategy { + ( + prop::collection::vec(prop::collection::vec(select(CONSTRUCTS), 0..=2), 1..=4), + any::(), + any::(), + any::(), + ) + .prop_map(|(extras, has_vars, has_macro, mask)| { + build_spec(extras, has_vars, has_macro, mask) + }) +} diff --git a/tests/manifest_load_options/fixtures/canonical.rs b/tests/manifest_load_options/fixtures/canonical.rs new file mode 100644 index 000000000..93a496a8a --- /dev/null +++ b/tests/manifest_load_options/fixtures/canonical.rs @@ -0,0 +1,108 @@ +//! Reducing a load outcome to the text a snapshot records. +//! +//! A characterization snapshot has to compare two things that are not the same +//! type: a successful load, which is a populated manifest, and a failed one, +//! which is an error chain. Both are reduced to a string so that a row +//! snapshots one shape whatever happens. +//! +//! Two decisions make the strings repeatable while staying reviewable: +//! +//! - Serialization goes through `serde_json_canonicalizer`, which fixes key +//! order and number form. Two runs over equal values produce equal text even +//! where the YAML mapping order differed, so a snapshot cannot fail for a +//! reason unrelated to the loader. +//! - The canonical text is re-serialized with an indent before it is recorded. +//! Canonicalization is what makes the comparison sound; the indent is what +//! makes a reviewer able to read the baseline. Key order survives the round +//! trip because `serde_json` preserves insertion order and the canonicalizer +//! emitted the keys already sorted. +//! +//! The error path keeps `anyhow`'s alternate separator, which walks the chain +//! from the outermost context to the root cause. That is the part worth +//! pinning: a refactor that starts wrapping or dropping a context changes the +//! text, and that is exactly the kind of change the baseline exists to catch. +//! +//! Each block also leads with the stages the entry point's observer was +//! notified with. A seam a caller can pass is a seam a caller can observe, so +//! the notified sequence is part of what a row freezes. + +use netsuke::ast::NetsukeManifest; +use netsuke::manifest::ManifestLoadStage; + +/// Record which stage-observer seam one row's entry point offers. +/// +/// The distinction is carried in the type rather than inferred from an empty +/// observation, because those two states mean different things: an entry point +/// that takes no observer cannot notify a stage however it is refactored, while +/// one that takes an observer and notifies nothing has lost behaviour it used +/// to have. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StageSeam { + /// The entry point takes no observer parameter. + Absent, + /// The entry point takes an observer, which the row supplies. + Accepted, +} + +/// Render a load outcome as the text a snapshot records. +pub fn canonical(result: &anyhow::Result) -> String { + match result { + Ok(manifest) => canonical_manifest(manifest), + Err(error) => format!("{error:#}"), + } +} + +/// Render one labelled block per fixture, in the order the cases were given. +/// +/// A row snapshots one document rather than one file per fixture. Both record +/// the same results; the single document keeps an entry point's whole +/// characterization in one place, so a reviewer reads one file to see whether a +/// refactor changed *anything* about that entry, and the snapshot directory +/// stays small enough to hold in view. +/// +/// `stages` is what the row's observer was notified with, and is empty for a +/// row whose entry point has no observer to notify. +pub fn report( + entries: &[(String, anyhow::Result)], + seam: StageSeam, + stages: &[ManifestLoadStage], +) -> String { + let observed = observer_line(seam, stages); + entries + .iter() + .map(|(label, result)| format!("=== {label} ===\n{observed}{}", canonical(result))) + .collect::>() + .join("\n\n") +} + +/// Render the stage-observer line that opens a fixture's block. +/// +/// The three states are spelled out rather than collapsed into one, because a +/// missing observer and a silent one would otherwise read the same in a diff. +fn observer_line(seam: StageSeam, stages: &[ManifestLoadStage]) -> String { + match (seam, stages) { + (StageSeam::Absent, _) => "observer: none accepted by this entry point\n".to_owned(), + (StageSeam::Accepted, []) => "observer: accepted, notified no stage\n".to_owned(), + (StageSeam::Accepted, notified) => { + let names = notified + .iter() + .map(|stage| format!("{stage:?}")) + .collect::>() + .join(", "); + format!("observer: {names}\n") + } + } +} + +/// Render a manifest as indented canonical JSON. +fn canonical_manifest(manifest: &NetsukeManifest) -> String { + let canonical = match serde_json_canonicalizer::to_string(manifest) { + Ok(text) => text, + Err(error) => return format!(""), + }; + match serde_json::from_str::(&canonical) { + Ok(value) => serde_json::to_string_pretty(&value) + .unwrap_or_else(|error| format!("")), + Err(error) => format!(""), + } +} diff --git a/tests/manifest_load_options/fixtures/corpus.rs b/tests/manifest_load_options/fixtures/corpus.rs new file mode 100644 index 000000000..78d694469 --- /dev/null +++ b/tests/manifest_load_options/fixtures/corpus.rs @@ -0,0 +1,109 @@ +//! Staging a corpus fixture in a temporary workspace. +//! +//! A path entry point loads a manifest from a workspace it opens itself, and a +//! manifest's supporting files have to be reachable from that workspace. The +//! fixtures live under `tests/data/`, so the workspace has to be a copy: the +//! loader refuses to leave the manifest's own directory, and that directory is +//! shared with every other suite in the repository. +//! +//! Copying the whole directory would be the obvious approach and is the wrong +//! one. `tests/data` holds manifests that call `shell` and `fetch`, and a +//! characterization run must not execute any of them as a side effect of +//! staging. Only the file and the siblings a fixture names are copied, so what a +//! row can reach is exactly what it declared. +//! +//! Relative structure is preserved rather than flattened. `glob()` resolves its +//! pattern against the manifest's own directory, so a fixture matching +//! `glob_files/*.txt` needs its matches one directory down; flattening them +//! beside the manifest would leave the row passing over an empty match set. + +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; + +use super::fixture::Case; + +/// Hold a staged copy of a corpus fixture for the duration of one row. +/// +/// Dropping the value removes the workspace. The staged manifest therefore has +/// a run-unique temporary path, which is why the rows filter it: without the +/// filter the snapshot would record the temporary directory's name and fail on +/// every run. +#[derive(Debug)] +pub struct StagedFixture { + /// Owns the temporary directory; dropping it deletes the workspace. + _tempdir: tempfile::TempDir, + /// The staged manifest, ready to hand to a path entry point. + manifest: Utf8PathBuf, +} + +impl StagedFixture { + /// Copy `case` and its declared siblings into a fresh temporary workspace. + /// + /// # Errors + /// + /// Returns an error when the workspace cannot be created, when the fixture + /// or one of its siblings cannot be found, or when a copy fails. + pub fn new(case: &Case) -> anyhow::Result { + let manifest_relative = Utf8Path::new(case.path); + let fixture_dir = manifest_relative + .parent() + .ok_or_else(|| anyhow::anyhow!("fixture path {} has no directory", case.path))?; + let file_name = manifest_relative + .file_name() + .ok_or_else(|| anyhow::anyhow!("fixture path {} has no file name", case.path))?; + + let tempdir = tempfile::tempdir()?; + let workspace = Utf8Path::from_path(tempdir.path()) + .ok_or_else(|| anyhow::anyhow!("temporary directory path is not UTF-8"))?; + let destination = Dir::open_ambient_dir(workspace, ambient_authority())?; + let source = Dir::open_ambient_dir(fixture_dir, ambient_authority())?; + source.copy(file_name, &destination, file_name)?; + for sibling in case.siblings { + copy_sibling(&source, &destination, fixture_dir, sibling)?; + } + let staged = tempdir.path().join(file_name); + let manifest = Utf8PathBuf::from_path_buf(staged).map_err(|path| { + anyhow::anyhow!("staged manifest path is not UTF-8: {}", path.display()) + })?; + Ok(Self { + manifest, + _tempdir: tempdir, + }) + } + + /// Return the staged manifest's path. + pub fn manifest_path(&self) -> &Utf8Path { + &self.manifest + } +} + +/// Copy one declared sibling into the workspace, preserving its relative path. +/// +/// The sibling is named repository-relative, so it is made relative to the +/// fixture's own directory first; that is the path the staged manifest will +/// look for it at. The parent directories are created on the way, because the +/// copy is of a file, not of the tree that holds it. +/// +/// # Errors +/// +/// Returns an error when the sibling is not under the fixture's directory, when +/// an intermediate directory cannot be created, or when the copy fails. +fn copy_sibling( + source: &Dir, + destination: &Dir, + fixture_dir: &Utf8Path, + sibling: &str, +) -> anyhow::Result<()> { + let relative = Utf8Path::new(sibling) + .strip_prefix(fixture_dir) + .map_err(|error| { + anyhow::anyhow!("declared sibling {sibling} is not under {fixture_dir}: {error}") + })?; + for parent in relative.ancestors().skip(1) { + if !parent.as_str().is_empty() { + destination.create_dir_all(parent)?; + } + } + source.copy(relative, destination, relative)?; + Ok(()) +} diff --git a/tests/manifest_load_options/fixtures/fixture.rs b/tests/manifest_load_options/fixtures/fixture.rs new file mode 100644 index 000000000..472332d38 --- /dev/null +++ b/tests/manifest_load_options/fixtures/fixture.rs @@ -0,0 +1,369 @@ +//! The characterization corpus and the inputs each fixture needs. +//! +//! Every fixture is a file under `tests/data/manifest_load_options/`. Holding +//! them all as files is a decision, not a default: a path entry point loads a +//! manifest from a workspace, and its error text names the manifest by the path +//! it was given, so the only way a path row can pin that text — and the only +//! way the `[TMP]` filter in [`super::settings`] has anything to filter — is +//! for the fixture to have a path. An inline fixture would leave both +//! unexercised. +//! +//! Each fixture earns its place: one is an ordinary load, one reads the +//! environment, one is not valid YAML, one renders differently under the build +//! and query surfaces, one consults the wall clock, two act on the host, and +//! one resolves a `glob()` pattern against its own directory. +//! +//! # Applicability +//! +//! What a fixture forces on its loader is recorded on [`Case`] rather than +//! inferred by each row, and [`Case::is_loadable`] is the single place the +//! rules are combined. Four constraints do the work: +//! +//! - A fixture with siblings needs a workspace, because its `glob()` has +//! nothing to resolve against otherwise. +//! - An environment- or clock-reading fixture needs the matching seam, or a +//! loader that answers the helper with a refusal. A refusal is deterministic; +//! the process environment and the wall clock are not. +//! - A fixture that acts on the host is loadable *only* where the surface +//! refuses the helper. That is what keeps a characterization run from running +//! a command or reaching the network, and it is why those fixtures are +//! observed through a refusal rather than an effect. + +use std::sync::Arc; +use time::OffsetDateTime; +use time::macros::datetime; + +use super::settings::repo_root; +use netsuke::manifest::{ + EnvAccessPolicy, EnvReadError, EnvReader, ManifestBudgetLimits, ManifestEnvironment, +}; +use netsuke::stdlib::{StdlibConfig, fixed_clock}; + +/// The instant every clock-carrying row pins, so `now()` is repeatable. +pub const FIXED_INSTANT: OffsetDateTime = datetime!(2026-06-08 12:00:00 UTC); + +/// Return the characterization corpus. +pub const fn cases() -> &'static [Case] { + &[ + Case { + label: "pure", + path: concat!("tests/data/manifest_load_options/", "pure.yml"), + siblings: &[], + hazards: Hazards::NONE, + }, + Case { + label: "reads_env", + path: concat!("tests/data/manifest_load_options/", "reads_env.yml"), + siblings: &[], + hazards: Hazards { + reads_environment: true, + ..Hazards::NONE + }, + }, + Case { + label: "malformed_yaml", + path: concat!("tests/data/manifest_load_options/", "malformed.yml"), + siblings: &[], + hazards: Hazards::NONE, + }, + Case { + label: "boolean_interpolation", + path: concat!( + "tests/data/manifest_load_options/", + "boolean_interpolation.yml" + ), + siblings: &[], + hazards: Hazards::NONE, + }, + Case { + label: "clock_now", + path: concat!("tests/data/manifest_load_options/", "clock_now.yml"), + siblings: &[], + hazards: Hazards { + consults_clock: true, + ..Hazards::NONE + }, + }, + Case { + label: "uses_fetch", + path: concat!("tests/data/manifest_load_options/", "uses_fetch.yml"), + siblings: &[], + hazards: Hazards { + acts_on_host: true, + ..Hazards::NONE + }, + }, + Case { + label: "uses_shell", + path: concat!("tests/data/manifest_load_options/", "uses_shell.yml"), + siblings: &[], + hazards: Hazards { + acts_on_host: true, + ..Hazards::NONE + }, + }, + Case { + label: "glob_corpus", + path: "tests/data/glob.yml", + siblings: &["tests/data/glob_files/a.txt", "tests/data/glob_files/b.txt"], + hazards: Hazards::NONE, + }, + ] +} + +/// One manifest in the characterization corpus. +#[derive(Clone, Copy, Debug)] +pub struct Case { + /// Unique, filesystem-safe label; every row reports it beside the result. + pub label: &'static str, + /// The manifest, named repository-relative. + pub path: &'static str, + /// Supporting files copied into the same directory before a path load. + /// + /// A fixture with siblings is only meaningful from a workspace: the loader + /// anchors the manifest at a temporary directory, so a sibling left behind + /// in the repository would not be found and the row would pass over an + /// empty match set. + pub siblings: &'static [&'static str], + /// What the manifest's content forces on the loader. + pub hazards: Hazards, +} + +impl Case { + /// Read the manifest text, so a string entry point can load it. + /// + /// # Errors + /// + /// Returns an error when the fixture cannot be read. + pub fn manifest_text(self) -> anyhow::Result { + test_support::fs::read_to_string(repo_root().join(self.path)).map_err(Into::into) + } + + /// Report whether the fixture needs a workspace to resolve its content. + pub const fn requires_workspace(self) -> bool { + !self.siblings.is_empty() + } + + /// Report whether `capabilities` describes a loader that can load this + /// fixture repeatably. + /// + /// Each rule pairs something the fixture needs with something the loader + /// offers, and they are stated once here rather than restated by every row. + pub const fn is_loadable(self, capabilities: Capabilities) -> bool { + self.workspace_is_available(capabilities) + && self.environment_is_answerable(capabilities) + && self.clock_is_answerable(capabilities) + && self.host_is_left_alone(capabilities) + } + + /// Report whether a fixture with siblings can find them. + const fn workspace_is_available(self, capabilities: Capabilities) -> bool { + !self.requires_workspace() || capabilities.from_workspace + } + + /// Report whether an environment-reading fixture has a repeatable answer. + const fn environment_is_answerable(self, capabilities: Capabilities) -> bool { + !self.hazards.reads_environment || capabilities.resolves_environment_hazard() + } + + /// Report whether a clock-consulting fixture has a repeatable answer. + const fn clock_is_answerable(self, capabilities: Capabilities) -> bool { + !self.hazards.consults_clock || capabilities.resolves_clock_hazard() + } + + /// Report whether a host-acting fixture is held back by a refusal. + const fn host_is_left_alone(self, capabilities: Capabilities) -> bool { + !self.hazards.acts_on_host || capabilities.refusing_helpers + } +} + +/// What a fixture's content forces on the loader. +#[derive(Clone, Copy, Debug, Default)] +pub struct Hazards { + /// The manifest acts on the host: it calls `shell()`, `grep()`, or + /// `fetch()` in a field the loader renders. + pub acts_on_host: bool, + /// The manifest calls `env()`, so it needs a loader with an injected reader + /// rather than the process environment. + pub reads_environment: bool, + /// The manifest calls `now()`, so it needs a loader that can inject a + /// clock. + pub consults_clock: bool, +} + +impl Hazards { + /// A manifest with no hazards at all. + pub const NONE: Self = Self { + acts_on_host: false, + reads_environment: false, + consults_clock: false, + }; +} + +/// What a loader is able to offer the corpus. +/// +/// The two injected seams are held as one field rather than two sibling +/// Booleans because they answer one question — which boundaries does the caller +/// supply? — and because grouping them keeps the struct within Clippy's +/// four-Boolean ceiling without bending the rules that read it. They are not +/// forced to agree: [`injected_environment`] supplies a reader without a +/// clock, which is exactly the distinction that keeps clock-reading fixtures +/// out of the rows that pass no [`StdlibConfig`]. +#[derive(Clone, Copy, Debug, Default)] +pub struct Capabilities { + /// The seams the loader accepts from its caller. + pub seams: Seams, + /// The loader loads from a workspace path rather than from a string. + pub from_workspace: bool, + /// The loader registers a refusing stub in place of every helper that + /// inspects or acts on the host. + pub refusing_helpers: bool, +} + +/// The injected boundaries a loader accepts from its caller. +#[derive(Clone, Copy, Debug, Default)] +pub struct Seams { + /// The loader accepts an injected environment reader. + pub environment: bool, + /// The loader accepts a [`StdlibConfig`], and so a fixed clock. + pub clock: bool, +} + +impl Capabilities { + /// Offer no seam at all, leaving every host read ambient. + pub const NONE: Self = Self { + seams: Seams { + environment: false, + clock: false, + }, + from_workspace: false, + refusing_helpers: false, + }; + + /// Return these capabilities, loading from a workspace path instead. + pub const fn in_a_workspace(self) -> Self { + Self { + from_workspace: true, + ..self + } + } + + /// Return these capabilities, taking an injected environment reader. + pub const fn with_environment_reader(self) -> Self { + Self { + seams: Seams { + environment: true, + ..self.seams + }, + ..self + } + } + + /// Return these capabilities, taking an injected clock. + pub const fn with_clock(self) -> Self { + Self { + seams: Seams { + clock: true, + ..self.seams + }, + ..self + } + } + + /// Report whether the loader can answer a manifest that reads the + /// environment. + /// + /// It answers one either way: by supplying a reader, or by registering a + /// refusing stub in place of the helper that would have consulted the + /// process environment. Refusal counts because it is still a repeatable + /// outcome — the corpus holds the refusal rows precisely to pin the + /// diagnostic a caller sees when the boundary is withheld. + pub const fn resolves_environment_hazard(self) -> bool { + self.seams.environment || self.refusing_helpers + } + + /// Report whether the loader can answer a manifest that consults the clock. + /// + /// The reasoning mirrors [`resolves_environment_hazard`](Self::resolves_environment_hazard): + /// a supplied clock and a refusing stub both replace a host read with a + /// fixed outcome. + pub const fn resolves_clock_hazard(self) -> bool { + self.seams.clock || self.refusing_helpers + } +} + +/// Return a loader that reads the process environment and the host clock. +/// +/// The string entry points that take no seam, and the path adapters that build +/// their own configuration from the workspace they open, all behave this way. +pub const fn process_environment() -> Capabilities { + Capabilities::NONE +} + +/// Return a loader that opens a workspace but takes no other seam. +pub const fn path_entry_point() -> Capabilities { + process_environment().in_a_workspace() +} + +/// Return a loader that takes an injected environment reader only. +/// +/// Rows that pass a reader but no [`StdlibConfig`] — including the policy-taking +/// and budget-taking string adapters — all have these capabilities. +pub const fn injected_environment() -> Capabilities { + process_environment().with_environment_reader() +} + +/// Return a loader that takes both injected seams. +/// +/// Only the rows that pass a [`StdlibConfig`] qualify, which is what makes this +/// distinct from [`injected_environment`]: a clock-carrying fixture is loadable +/// here and not there. +pub const fn injected_environment_and_clock() -> Capabilities { + injected_environment().with_clock() +} + +/// Return a workspace-opening loader that also takes an injected reader. +pub const fn path_entry_point_with_environment() -> Capabilities { + path_entry_point().with_environment_reader() +} + +/// Return a reader that answers one manifest variable and refuses the rest. +pub fn one_var_reader() -> EnvReader { + Arc::new(|name: &str| match name { + "NETSUKE_TEST_ENV" => Ok("characterization".to_owned()), + _ => Err(EnvReadError::NotPresent), + }) +} + +/// Bundle a reader with the permissive access policy the adapters default to. +pub fn environment(reader: &EnvReader) -> ManifestEnvironment<'_> { + ManifestEnvironment::new(reader, EnvAccessPolicy::default()) +} + +/// Build the stdlib configuration every clock-carrying row binds. +/// +/// The workspace root is the test process's current directory, which no fixture +/// here reads: the configuration is carried for its clock alone. The path +/// override is emptied for the same reason — `which()` is a host lookup, and a +/// row that never calls it should not be able to vary with the host's `PATH`. +/// +/// # Errors +/// +/// Returns an error when the ambient workspace cannot be opened. +pub fn fixed_clock_config() -> anyhow::Result { + Ok(StdlibConfig::from_current_dir()? + .with_clock(fixed_clock(FIXED_INSTANT)) + .with_path_override(std::ffi::OsString::new())) +} + +/// Return ceilings that exhaust before any manifest is parsed. +/// +/// Exhaustion is charged against the source bytes first, so a limit below the +/// manifest's own text fails ahead of YAML parsing, Jinja evaluation, and +/// rendering. The limit is a constant, which keeps the diagnostic repeatable. +pub fn exhausted_budget() -> ManifestBudgetLimits { + ManifestBudgetLimits { + source_bytes: 8, + ..ManifestBudgetLimits::default() + } +} diff --git a/tests/manifest_load_options/fixtures/mod.rs b/tests/manifest_load_options/fixtures/mod.rs new file mode 100644 index 000000000..aa73b810b --- /dev/null +++ b/tests/manifest_load_options/fixtures/mod.rs @@ -0,0 +1,33 @@ +//! Shared machinery for the manifest loader entry-point characterization suite. +//! +//! Four concerns live here, kept apart from the suites that use them: +//! +//! - [`fixture`] enumerates the characterization corpus and reports what each +//! manifest forces on the loader that reads it — an injected reader, a fixed +//! clock, or a refusing surface. +//! - [`corpus`] stages a fixture in a temporary workspace, copying exactly the +//! siblings that fixture declared. +//! - [`settings`] builds the insta settings every row binds, pinned to one +//! snapshot directory and to a temporary-path filter. +//! - [`canonical`] reduces a load outcome to the text a snapshot records — the +//! observed load stages, then the outcome itself — so a successful load and a +//! failed one are snapshotted the same way. +//! +//! Every fixture is a file, which is what lets a path row pin the error text +//! that names it and gives the temporary-path filter something to replace. + +pub mod canonical; +mod corpus; +mod fixture; +mod settings; + +pub use self::{ + canonical::{StageSeam, report}, + corpus::StagedFixture, + fixture::{ + Capabilities, Case, cases, environment, exhausted_budget, fixed_clock_config, + injected_environment, injected_environment_and_clock, one_var_reader, path_entry_point, + path_entry_point_with_environment, process_environment, + }, + settings::settings, +}; diff --git a/tests/manifest_load_options/fixtures/settings.rs b/tests/manifest_load_options/fixtures/settings.rs new file mode 100644 index 000000000..64c5746df --- /dev/null +++ b/tests/manifest_load_options/fixtures/settings.rs @@ -0,0 +1,55 @@ +//! Insta settings for the characterization snapshots. +//! +//! The settings exist to make one property true: a snapshot file's name and +//! directory depend only on the row that produced it, never on which module the +//! row happens to live in. Insta's defaults are the opposite — the name gains +//! the module path and the directory follows the assertion file — so a routine +//! refactor would move every snapshot and read as a behaviour change. Both +//! defaults are switched off here and replaced with one fixed directory. +//! +//! One filter stays on: a path entry point loads its fixture from a workspace it +//! creates, and an error names the manifest by the absolute path it was given, +//! so the temporary prefix has to be replaced for that text to repeat. + +use camino::{Utf8Path, Utf8PathBuf}; +use insta::Settings; +use test_support::temp_path_filter::temp_dir_filter_pattern; + +/// Return the repository root, resolved at compile time. +pub fn repo_root() -> Utf8PathBuf { + Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// Return the directory holding the public suite's snapshots. +pub fn public_snapshot_dir() -> Utf8PathBuf { + repo_root().join("tests/snapshots/manifest_load_options") +} + +/// Build settings that pin snapshot paths and hide temporary directories. +/// +/// `snapshot_dir` must be absolute. Insta resolves a relative path against the +/// directory of the file containing the assertion, so a relative path would +/// reintroduce exactly the module-dependence the absolute path removes. +pub fn settings_in(snapshot_dir: &Utf8Path) -> Settings { + let mut settings = Settings::new(); + settings.set_snapshot_path(snapshot_dir); + settings.set_prepend_module_to_snapshot(false); + add_temp_dir_filter(&mut settings); + settings +} + +/// Return the public suite's settings. +pub fn settings() -> Settings { + settings_in(&public_snapshot_dir()) +} + +/// Replace the temporary-directory prefix of any absolute path with a +/// placeholder. +/// +/// Resolving the directory here rather than in [`temp_dir_filter_pattern`] keeps +/// the pattern a pure function of its input, so its tests can assert the +/// Windows spelling on any host. +fn add_temp_dir_filter(settings: &mut Settings) { + let temp_dir = std::env::temp_dir().display().to_string(); + settings.add_filter(&temp_dir_filter_pattern(&temp_dir), "[TMP]"); +} diff --git a/tests/manifest_load_options/mod.rs b/tests/manifest_load_options/mod.rs new file mode 100644 index 000000000..65db294f0 --- /dev/null +++ b/tests/manifest_load_options/mod.rs @@ -0,0 +1,21 @@ +//! Module tree for the manifest loader entry-point characterization suite. +//! +//! `wrapper_equivalence` records one canonical-result snapshot per public entry +//! point over the fixtures applicable to it. `fidelity_property` asserts the +//! Full/Test differential property over generated pure manifests, and +//! `corpus_fidelity` asserts the same claim — equal, or a named refusal — over +//! every manifest the repository already keeps. +//! +//! `fixtures` holds the shared machinery — the corpus and its hazards, the +//! staging that gives a fixture a workspace, the snapshot settings, and the +//! canonical-result renderer — so the three suites describe *what* they compare +//! without restating *how*. +//! +//! The crate-internal query rows are *not* here: they live in +//! `src/manifest/tests/wrapper_equivalence.rs`, because the entry point they +//! characterize is crate-private and this integration crate cannot reach it. +//! They restate the few fixtures they need rather than sharing these. +mod corpus_fidelity; +mod fidelity_property; +mod fixtures; +mod wrapper_equivalence; diff --git a/tests/manifest_load_options/wrapper_equivalence/mod.rs b/tests/manifest_load_options/wrapper_equivalence/mod.rs new file mode 100644 index 000000000..2cf4e57bf --- /dev/null +++ b/tests/manifest_load_options/wrapper_equivalence/mod.rs @@ -0,0 +1,203 @@ +//! What each public loader entry point produces today. +//! +//! Eleven public entry points load a manifest, and they differ only in how much +//! of the loader's seam they accept: a network policy, an environment reader or +//! a paired environment, resource ceilings, a stdlib configuration, or a stage +//! observer. The refactor that replaces their shared core must leave every one +//! of them producing exactly what it produces now, and the only trustworthy +//! oracle for that claim is the code before the refactor — so these snapshots +//! are recorded against the unchanged loader and re-checked at every later +//! milestone. +//! +//! Comparing a wrapper against the core *after* the refactor would be equal by +//! construction and would prove nothing. That is why the baseline is recorded +//! first and never re-accepted afterwards. +//! +//! # One row, one document +//! +//! A row snapshots one document covering every fixture it may load, rather than +//! one snapshot per fixture. The directory stays small enough to hold in view, +//! a reviewer reads one file to see whether a refactor changed *anything* about +//! an entry point, and the documents are diffable against each other — which is +//! what turns "these wrappers delegate identically" from an assertion into an +//! observation. +//! +//! Each snapshot name is written out rather than derived from the test +//! function. Three pairs of entry points differ only in a suffix (`_and_env` +//! against `_and_environment`, and the `_and_limits` forms of both), so a +//! derived name would read as the same row spelled twice. +//! +//! # Applicability and the clock +//! +//! What each row may load is decided by [`Case::is_loadable`] against the +//! loader's [`Capabilities`], and a row that finds no applicable fixture fails +//! rather than snapshotting an empty document. Without that guard a mistake in +//! the capability table would silently reduce an entry point's characterization +//! to nothing while the snapshot stayed green. +//! +//! Only the entries that accept a [`StdlibConfig`] can pin the clock, so +//! `clock_now` is loaded only there. That is a property of the public surface +//! rather than a choice: a path entry point builds its own configuration from +//! the workspace it opens and has no seam for a caller's clock, so +//! [`path_rows::path_entry_point_cannot_inject_a_clock`] measures that rather +//! than leaving it as an assumption the capability table happens to encode. +//! +//! # Splitting the rows +//! +//! [`path_rows`] holds every row whose entry point opens a workspace, and this +//! module holds the string rows plus the shared harness. The split is the +//! string-versus-path seam, not a line count: a path row stages a fixture +//! before it can load anything, and a string row never does. + +mod path_rows; + +use anyhow::Result; +use camino::Utf8Path; +use rstest::rstest; + +use netsuke::ast::NetsukeManifest; +use netsuke::manifest::{ + EnvAccessPolicy, ManifestLoadStage, from_str, from_str_with_env, from_str_with_env_and_config, + from_str_with_env_and_policy, +}; +use netsuke::stdlib::NetworkPolicy; + +use super::fixtures::{ + Capabilities, Case, StageSeam, StagedFixture, cases, fixed_clock_config, injected_environment, + injected_environment_and_clock, one_var_reader, process_environment, report, settings, +}; + +/// Return the fixtures a loader with `capabilities` may load. +pub(super) fn applicable(capabilities: Capabilities) -> Vec { + cases() + .iter() + .copied() + .filter(|case| case.is_loadable(capabilities)) + .collect() +} + +/// Render the document a row snapshots, staging each fixture in turn. +/// +/// `load` receives the fixture, the path a path entry point should read, and +/// the buffer an observer should append to: the staged copy lives in a fresh +/// temporary workspace. +/// +/// The observer's stages are recorded per fixture, in the block that fixture +/// produced. A shared buffer would misreport every block but the first, because +/// a load that fails early notifies fewer stages than one that succeeds, and +/// the entry point's own line already records where it stopped. +/// +/// `seam` states whether the entry point offers an observer at all. It is the +/// caller's claim rather than something this function can infer, so a row that +/// passes `Accepted` while its entry point takes no observer fails to compile, +/// and one that passes `Absent` for an observer-taking entry point would leave +/// its closures unused. +/// +/// # Errors +/// +/// Returns an error when the capability table admits no fixture — the row would +/// snapshot an empty document and characterize nothing — or when a fixture +/// cannot be staged or read. +pub(super) fn document( + capabilities: Capabilities, + seam: StageSeam, + mut load: F, +) -> Result +where + F: FnMut(&Case, &Utf8Path, &mut Vec) -> Result, +{ + let applicable = applicable(capabilities); + anyhow::ensure!( + !applicable.is_empty(), + "no fixture is applicable to this entry point; the capability table is wrong" + ); + let mut blocks = Vec::with_capacity(applicable.len()); + for case in applicable { + let fixture = StagedFixture::new(&case)?; + let mut stages = Vec::new(); + let outcome = load(&case, fixture.manifest_path(), &mut stages); + blocks.push(report(&[(case.label.to_owned(), outcome)], seam, &stages)); + } + Ok(blocks.join("\n\n")) +} + +/// Render the document a row snapshots for a string entry point. +/// +/// A string load names the manifest only for diagnostics, so the fixture's own +/// repository-relative path serves as that name. Naming it anything else would +/// suggest the loader had been handed a workspace it does not have. +/// +/// No string entry point takes a stage observer, so the rows built on this +/// helper are all [`StageSeam::Absent`]. +/// +/// # Errors +/// +/// Returns an error under the same conditions as [`document`], plus when a +/// fixture cannot be read. +fn document_from_string(capabilities: Capabilities, mut load: F) -> Result +where + F: FnMut(&str) -> Result, +{ + document(capabilities, StageSeam::Absent, |case, _path, _stages| { + load(&case.manifest_text()?) + }) +} + +/// Return the policy every policy-taking row passes. +/// +/// `NetworkPolicy::default` is HTTPS-only. No applicable fixture reaches the +/// network: `uses_fetch` addresses `http://127.0.0.1:1/`, which the policy +/// refuses at the scheme check before a socket is opened, and only a +/// refusing-surface row loads it. The policy is therefore not what any row +/// characterizes; it is the value an ordinary caller supplies. +pub(super) fn policy() -> NetworkPolicy { + NetworkPolicy::default() +} + +/// Return the permissive access policy the adapters default to. +fn permissive() -> EnvAccessPolicy { + EnvAccessPolicy::default() +} + +/// Snapshot what `from_str` produces. +/// +/// `from_str` takes no seam at all: it reads the process environment and the +/// host clock. It is therefore the narrowest row, and the fixtures it may load +/// are exactly the ones no seam can change. +#[rstest] +fn from_str_row() -> Result<()> { + let document = document_from_string(process_environment(), from_str)?; + settings().bind(|| insta::assert_snapshot!("from_str", document)); + Ok(()) +} + +#[rstest] +fn from_str_with_env_row() -> Result<()> { + let document = document_from_string(injected_environment(), |text| { + from_str_with_env(text, &one_var_reader()) + })?; + settings().bind(|| insta::assert_snapshot!("from_str_with_env", document)); + Ok(()) +} + +#[rstest] +fn from_str_with_env_and_policy_row() -> Result<()> { + let document = document_from_string(injected_environment(), |text| { + from_str_with_env_and_policy(text, &one_var_reader(), &permissive()) + })?; + settings().bind(|| { + insta::assert_snapshot!("from_str_with_env_and_policy", document); + }); + Ok(()) +} + +#[rstest] +fn from_str_with_env_and_config_row() -> Result<()> { + let document = document_from_string(injected_environment_and_clock(), |text| { + from_str_with_env_and_config(text, &one_var_reader(), fixed_clock_config()?) + })?; + settings().bind(|| { + insta::assert_snapshot!("from_str_with_env_and_config", document); + }); + Ok(()) +} diff --git a/tests/manifest_load_options/wrapper_equivalence/path_rows.rs b/tests/manifest_load_options/wrapper_equivalence/path_rows.rs new file mode 100644 index 000000000..9bd7cb113 --- /dev/null +++ b/tests/manifest_load_options/wrapper_equivalence/path_rows.rs @@ -0,0 +1,311 @@ +//! The rows that characterize the workspace-opening entry points. +//! +//! Every entry point here takes a manifest *path* and opens the workspace that +//! holds it, which is what separates these rows from the string rows in the +//! parent module: a path load can only read what its staged workspace contains, +//! so each fixture is copied in by [`StagedFixture`] before the load. +//! +//! This is a submodule rather than a third top-level suite because the rows +//! share the parent's harness — its staging, its capability table, and its +//! [`document`](super::document) renderer — and a sibling suite would have to +//! restate all three. +//! +//! # One table of observing entry points +//! +//! Seven adapters take the same argument shape — a path, the default policy, +//! an observer, and whichever seams they add — and differ only in which entry +//! point they call. Writing them as seven functions would repeat that shape +//! seven times, so each is one [`ObservedEntryPoint`] variant and the adapter +//! table below drives them all. The bare adapter, which takes no observer at +//! all, stays its own row, and so does the clock measurement, because neither +//! shares that shape. + +use anyhow::Result; +use camino::Utf8Path; +use rstest::rstest; + +use netsuke::ast::NetsukeManifest; +use netsuke::manifest::{ + ManifestBudgetLimits, ManifestLoadStage, from_path, from_path_with_policy, + from_path_with_policy_and_env, from_path_with_policy_and_env_and_limits, + from_path_with_policy_and_environment, from_path_with_policy_and_environment_and_limits, + from_path_with_policy_and_limits, from_str_with_env_and_config, +}; +use netsuke::recipe_shell::RecipeShell; + +use super::{StageSeam, document, policy}; +use crate::manifest_load_options::fixtures::{ + Capabilities, Case, StagedFixture, cases, environment, exhausted_budget, fixed_clock_config, + one_var_reader, path_entry_point, path_entry_point_with_environment, settings, +}; + +/// Snapshot one row's document under the name `name`. +/// +/// Every row here does the same three things: render the document its +/// capabilities and seam call for, bind the snapshot settings, and assert +/// against `name`. Stating that once keeps each row's own code to the entry +/// point it characterizes. +/// +/// `name` is passed rather than derived from the test function, because three +/// pairs of entry points differ only by a suffix and a derived name would read +/// as the same row spelled twice. +/// +/// # Errors +/// +/// Returns an error when no fixture is applicable or one cannot be staged and +/// loaded. +fn record_row(name: &str, capabilities: Capabilities, seam: StageSeam, load: F) -> Result<()> +where + F: FnMut(&Case, &Utf8Path, &mut Vec) -> Result, +{ + let document = document(capabilities, seam, load)?; + settings().bind(|| insta::assert_snapshot!(name, document)); + Ok(()) +} + +/// A path adapter that accepts a stage observer, and the seams it adds. +/// +/// Each variant names one public entry point; its doc comment states why that +/// row exists. [`ObservedEntryPoint::load`] is the single place the calls +/// appear, so a row states which adapter it characterizes rather than restating +/// the adapter's argument list. +#[derive(Clone, Copy, Debug)] +enum ObservedEntryPoint { + /// The policy-taking adapter on the defaults. + /// + /// It exists so a caller that wants none of the other seams does not have + /// to name them, and the row records that it behaves as the fuller + /// adapters do once their defaults are supplied. + Policy, + /// The policy-taking adapter with explicit ceilings. + /// + /// The `_and_limits` name describes the seam, not the value: a caller that + /// wants the ceilings the constants define passes them explicitly rather + /// than omitting them. Passing the default here is what keeps this row from + /// recording a limit that is a choice of this test rather than behaviour of + /// the loader. + PolicyAndLimits, + /// The policy-taking adapter with an injected environment reader. + PolicyAndEnv, + /// The environment adapter with explicit ceilings at their defaults. + PolicyAndEnvAndLimits, + /// The adapter that takes the reader already paired with its policy. + PolicyAndEnvironment, + /// The fully parameterized adapter with every ceiling at its default. + PolicyAndEnvironmentAndLimits, + /// The same adapter with ceilings that exhaust before any manifest parses. + /// + /// Exhaustion is charged against the source bytes first, so a ceiling below + /// the manifest's own text fails ahead of YAML parsing, Jinja evaluation, + /// and rendering — before the observer is notified of anything past + /// ingestion. It is its own row so the default-ceiling rows above cannot + /// conflate the loader's ordinary behaviour with a limit this test chose. + ExhaustedBudget, +} + +impl ObservedEntryPoint { + /// Call this entry point on `path`, forwarding `observer`. + /// + /// # Errors + /// + /// Returns an error when the entry point cannot load the manifest. + fn load( + self, + path: &Utf8Path, + observer: Option<&mut dyn FnMut(ManifestLoadStage)>, + ) -> Result { + match self { + Self::Policy => from_path_with_policy(path, policy(), observer), + Self::PolicyAndLimits => from_path_with_policy_and_limits( + path, + policy(), + ManifestBudgetLimits::default(), + observer, + ), + Self::PolicyAndEnv => { + from_path_with_policy_and_env(path, policy(), &one_var_reader(), observer) + } + Self::PolicyAndEnvAndLimits => from_path_with_policy_and_env_and_limits( + path, + policy(), + &one_var_reader(), + ManifestBudgetLimits::default(), + observer, + ), + Self::PolicyAndEnvironment => from_path_with_policy_and_environment( + path, + policy(), + &environment(&one_var_reader()), + observer, + ), + Self::PolicyAndEnvironmentAndLimits => { + full_row(path, ManifestBudgetLimits::default(), observer) + } + Self::ExhaustedBudget => full_row(path, exhausted_budget(), observer), + } + } +} + +/// Call the fully parameterized adapter with `budget_limits`. +/// +/// The two rows over this entry point differ only in which ceilings they pass, +/// so the call itself is stated once here. +/// +/// # Errors +/// +/// Returns an error when the adapter cannot load the manifest. +fn full_row( + path: &Utf8Path, + budget_limits: ManifestBudgetLimits, + observer: Option<&mut dyn FnMut(ManifestLoadStage)>, +) -> Result { + from_path_with_policy_and_environment_and_limits( + path, + policy(), + &environment(&one_var_reader()), + budget_limits, + RecipeShell::host_default(), + observer, + ) +} + +/// Snapshot what `from_path` produces. +/// +/// `from_path` is the path entry point with no seam: it takes no observer +/// parameter at all, which this row records rather than leaving implicit. It +/// exists so a caller that wants none of the other seams does not have to name +/// them, and the row records that it behaves as the policy-taking entry point +/// does once the defaults are supplied. +#[rstest] +fn from_path_row() -> Result<()> { + record_row( + "from_path", + path_entry_point(), + StageSeam::Absent, + |_, path, _| from_path(path), + ) +} + +/// Snapshot every observer-taking path adapter. +/// +/// One case per adapter, with the snapshot name written out: three pairs differ +/// only by a suffix, so a derived name would read as the same row twice. +#[rstest] +#[case::policy( + "from_path_with_policy", + ObservedEntryPoint::Policy, + path_entry_point() +)] +#[case::policy_and_limits( + "from_path_with_policy_and_limits", + ObservedEntryPoint::PolicyAndLimits, + path_entry_point() +)] +#[case::policy_and_env( + "from_path_with_policy_and_env", + ObservedEntryPoint::PolicyAndEnv, + path_entry_point_with_environment() +)] +#[case::policy_and_env_and_limits( + "from_path_with_policy_and_env_and_limits", + ObservedEntryPoint::PolicyAndEnvAndLimits, + path_entry_point_with_environment() +)] +#[case::policy_and_environment( + "from_path_with_policy_and_environment", + ObservedEntryPoint::PolicyAndEnvironment, + path_entry_point_with_environment() +)] +#[case::policy_and_environment_and_limits( + "from_path_with_policy_and_environment_and_limits", + ObservedEntryPoint::PolicyAndEnvironmentAndLimits, + path_entry_point_with_environment() +)] +#[case::exhausted_budget( + "budget_exhaustion", + ObservedEntryPoint::ExhaustedBudget, + path_entry_point_with_environment() +)] +fn observed_path_row( + #[case] name: &str, + #[case] entry: ObservedEntryPoint, + #[case] capabilities: Capabilities, +) -> Result<()> { + record_row( + name, + capabilities, + StageSeam::Accepted, + |_, path, stages| entry.load(path, Some(&mut |stage| stages.push(stage))), + ) +} + +/// Assert that a path entry point consults the host clock and cannot be given +/// another. +/// +/// The capability table gives every path row no clock seam, and this row is the +/// measurement behind that claim. Without it, a later change that *did* give a +/// path entry point a clock seam would leave the clock fixture silently +/// excluded from seven rows, where the omission would read as a decision rather +/// than a gap. +/// +/// The same manifest is loaded twice: once through a string entry that can pin +/// the clock, once through a path entry that cannot. Equal descriptions would +/// mean the path entry had somehow obtained the pinned instant, so it must +/// differ — and it differs by rendering the host's, which the fixed instant is +/// a decade away from. +#[rstest] +fn path_entry_point_cannot_inject_a_clock() -> Result<()> { + let case = cases() + .iter() + .copied() + .find(|case| case.hazards.consults_clock) + .ok_or_else(|| anyhow::anyhow!("the corpus no longer holds a clock-reading fixture"))?; + anyhow::ensure!( + !case.is_loadable(path_entry_point()), + "a path entry point now admits the clock fixture; give the path rows an injected clock" + ); + + let text = case.manifest_text()?; + let pinned = from_str_with_env_and_config(&text, &one_var_reader(), fixed_clock_config()?)?; + let staged = StagedFixture::new(&case)?; + let mut stages = Vec::new(); + let host = from_path_with_policy( + staged.manifest_path(), + policy(), + Some(&mut |stage| stages.push(stage)), + )?; + + anyhow::ensure!( + stages + == [ + ManifestLoadStage::ManifestIngestion, + ManifestLoadStage::InitialYamlParsing, + ManifestLoadStage::TemplateExpansion, + ManifestLoadStage::FinalRendering, + ], + "a successful path load notified {stages:?} rather than every stage" + ); + anyhow::ensure!( + describe(&pinned)? != describe(&host)?, + "a path entry point rendered the pinned clock; it has gained a clock seam \ + and the path rows must start using it" + ); + Ok(()) +} + +/// Return the first target's rendered description. +/// +/// # Errors +/// +/// Returns an error when the manifest has no target, or when the fixture's +/// description does not survive as a string. +fn describe(manifest: &NetsukeManifest) -> Result { + let target = manifest + .targets + .first() + .ok_or_else(|| anyhow::anyhow!("the clock fixture has no target"))?; + target + .description + .clone() + .ok_or_else(|| anyhow::anyhow!("the clock fixture's target has no description")) +} diff --git a/tests/manifest_load_options_embedder.rs b/tests/manifest_load_options_embedder.rs new file mode 100644 index 000000000..aff68b882 --- /dev/null +++ b/tests/manifest_load_options_embedder.rs @@ -0,0 +1,83 @@ +//! An external crate can construct loader options and load a manifest. +//! +//! This is OBL-12's embedder contract: every input `ManifestLoadOptions::full` +//! and `from_str_with_options` need is public, while `StdlibRegistration` +//! stays crate-private. Integration tests compile as their own crate, so an +//! unpublished constructor or accessor fails to compile here, exactly as it +//! would for a downstream consumer. +//! +//! The test surface is covered too: a consumer that can build a test +//! registration can also hand it a template overlay, so `TemplateOverlays`, +//! `OverlayCallable`, and `OverlayCall` are exercised from outside the crate. +//! +//! # Why this is not a `tests/ui` fixture +//! +//! The existing UI harness invokes `rustc` directly with no dependency +//! resolution, so it cannot link `netsuke`; `trybuild` would resolve it but is +//! not a dependency (C7). Compiling this file as an integration test gives the +//! same external-crate visibility without either cost. + +use netsuke::manifest::{ + EnvAccessPolicy, ManifestEnvironment, ManifestLoadOptions, ManifestName, OverlayCall, + OverlayCallable, TemplateOverlays, TestSurface, from_str_with_options, process_env_reader, +}; +use netsuke::stdlib::StdlibConfig; + +/// A manifest that reads and acts on nothing. +const PURE_MANIFEST: &str = concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: pure\n", + " command: \"echo pure\"\n", +); + +/// The manifest whose `foreach` calls the helper the overlay replaces. +const OVERLAID_MANIFEST: &str = concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - foreach: glob('glob_files/*.txt')\n", + " name: \"{{ item | replace('glob_files/', '') | replace('.txt', '.out') }}\"\n", + " command: \"echo {{ item }}\"\n", +); + +#[test] +fn embedder_loads_a_manifest_through_the_options_entry_point() { + let reader = process_env_reader(); + let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); + let config = StdlibConfig::from_current_dir().expect("open the test workspace"); + let options = ManifestLoadOptions::full(config, environment); + + let manifest = from_str_with_options(PURE_MANIFEST, &ManifestName::new("Netsukefile"), options) + .expect("load the embedder manifest"); + + assert_eq!(manifest.targets.len(), 1); +} + +#[test] +fn embedder_registers_a_template_overlay_on_the_test_surface() { + let config = StdlibConfig::from_current_dir().expect("open the test workspace"); + let overlays = TemplateOverlays::default().with_function( + "glob", + OverlayCallable::new(|_call: &OverlayCall<'_, '_>| { + Ok(minijinja::value::Value::from_serialize([ + "glob_files/a.txt", + "glob_files/b.txt", + ])) + }), + ); + let surface = TestSurface::new(config).with_overlays(overlays); + + let manifest = from_str_with_options( + OVERLAID_MANIFEST, + &ManifestName::new("Netsukefile"), + ManifestLoadOptions::test(surface), + ) + .expect("load the overlaid manifest"); + + let names: Vec = manifest + .targets + .iter() + .flat_map(|target| target.name.to_string_vec()) + .collect(); + assert_eq!(names, ["a.out", "b.out"]); +} diff --git a/tests/manifest_load_options_tests.rs b/tests/manifest_load_options_tests.rs new file mode 100644 index 000000000..4d22b109b --- /dev/null +++ b/tests/manifest_load_options_tests.rs @@ -0,0 +1,8 @@ +//! Characterization and differential tests for manifest loader entry points. +//! +//! The module tree under `tests/manifest_load_options/` records what every +//! public loader entry point produces today, before the options-carrying entry +//! point replaces the loader's internals. The pre-refactor behaviour is the +//! only trustworthy oracle for the refactor's equivalence claim, so these +//! snapshots are recorded once and never re-accepted. +mod manifest_load_options; diff --git a/tests/snapshots/manifest_load_options/budget_exhaustion.snap b/tests/snapshots/manifest_load_options/budget_exhaustion.snap new file mode 100644 index 000000000..80ea7d2c8 --- /dev/null +++ b/tests/snapshots/manifest_load_options/budget_exhaustion.snap @@ -0,0 +1,23 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: ManifestIngestion, InitialYamlParsing +invalid operation: Manifest resource budget exhausted during ⁨source⁩ after reaching ⁨8⁩.: manifest resource budget exhausted at Source (limit 8) + +=== reads_env === +observer: ManifestIngestion, InitialYamlParsing +invalid operation: Manifest resource budget exhausted during ⁨source⁩ after reaching ⁨8⁩.: manifest resource budget exhausted at Source (limit 8) + +=== malformed_yaml === +observer: ManifestIngestion, InitialYamlParsing +invalid operation: Manifest resource budget exhausted during ⁨source⁩ after reaching ⁨8⁩.: manifest resource budget exhausted at Source (limit 8) + +=== boolean_interpolation === +observer: ManifestIngestion, InitialYamlParsing +invalid operation: Manifest resource budget exhausted during ⁨source⁩ after reaching ⁨8⁩.: manifest resource budget exhausted at Source (limit 8) + +=== glob_corpus === +observer: ManifestIngestion, InitialYamlParsing +invalid operation: Manifest resource budget exhausted during ⁨source⁩ after reaching ⁨8⁩.: manifest resource budget exhausted at Source (limit 8) diff --git a/tests/snapshots/manifest_load_options/from_path.snap b/tests/snapshots/manifest_load_options/from_path.snap new file mode 100644 index 000000000..1a9a28e8b --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_path.snap @@ -0,0 +1,115 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: none accepted by this entry point +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} + +=== glob_corpus === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo glob_files/a.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "a.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 0, + "item": "glob_files/a.txt" + } + }, + { + "Command": { + "command": "echo glob_files/b.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "b.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 1, + "item": "glob_files/b.txt" + } + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy.snap b/tests/snapshots/manifest_load_options/from_path_with_policy.snap new file mode 100644 index 000000000..109d72203 --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_path_with_policy.snap @@ -0,0 +1,115 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: ManifestIngestion, InitialYamlParsing +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} + +=== glob_corpus === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo glob_files/a.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "a.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 0, + "item": "glob_files/a.txt" + } + }, + { + "Command": { + "command": "echo glob_files/b.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "b.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 1, + "item": "glob_files/b.txt" + } + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap new file mode 100644 index 000000000..3d9454f12 --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap @@ -0,0 +1,143 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== reads_env === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo env" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "profile characterization", + "name": "env", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: ManifestIngestion, InitialYamlParsing +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} + +=== glob_corpus === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo glob_files/a.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "a.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 0, + "item": "glob_files/a.txt" + } + }, + { + "Command": { + "command": "echo glob_files/b.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "b.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 1, + "item": "glob_files/b.txt" + } + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap new file mode 100644 index 000000000..3d9454f12 --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap @@ -0,0 +1,143 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== reads_env === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo env" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "profile characterization", + "name": "env", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: ManifestIngestion, InitialYamlParsing +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} + +=== glob_corpus === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo glob_files/a.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "a.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 0, + "item": "glob_files/a.txt" + } + }, + { + "Command": { + "command": "echo glob_files/b.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "b.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 1, + "item": "glob_files/b.txt" + } + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap new file mode 100644 index 000000000..3d9454f12 --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap @@ -0,0 +1,143 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== reads_env === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo env" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "profile characterization", + "name": "env", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: ManifestIngestion, InitialYamlParsing +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} + +=== glob_corpus === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo glob_files/a.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "a.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 0, + "item": "glob_files/a.txt" + } + }, + { + "Command": { + "command": "echo glob_files/b.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "b.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 1, + "item": "glob_files/b.txt" + } + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap new file mode 100644 index 000000000..3d9454f12 --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap @@ -0,0 +1,143 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== reads_env === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo env" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "profile characterization", + "name": "env", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: ManifestIngestion, InitialYamlParsing +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} + +=== glob_corpus === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo glob_files/a.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "a.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 0, + "item": "glob_files/a.txt" + } + }, + { + "Command": { + "command": "echo glob_files/b.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "b.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 1, + "item": "glob_files/b.txt" + } + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap new file mode 100644 index 000000000..109d72203 --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap @@ -0,0 +1,115 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: ManifestIngestion, InitialYamlParsing +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} + +=== glob_corpus === +observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRendering +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo glob_files/a.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "a.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 0, + "item": "glob_files/a.txt" + } + }, + { + "Command": { + "command": "echo glob_files/b.txt" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": null, + "name": "b.out", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "index": 1, + "item": "glob_files/b.txt" + } + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_str.snap b/tests/snapshots/manifest_load_options/from_str.snap new file mode 100644 index 000000000..83da27e17 --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_str.snap @@ -0,0 +1,66 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: none accepted by this entry point +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_str_with_env.snap b/tests/snapshots/manifest_load_options/from_str_with_env.snap new file mode 100644 index 000000000..430f05a6f --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_str_with_env.snap @@ -0,0 +1,94 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== reads_env === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo env" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "profile characterization", + "name": "env", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: none accepted by this entry point +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap b/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap new file mode 100644 index 000000000..f30a02eeb --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap @@ -0,0 +1,122 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== reads_env === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo env" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "profile characterization", + "name": "env", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: none accepted by this entry point +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} + +=== clock_now === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo stamped" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "started 2026-06-08T12:00:00Z", + "name": "clock", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} diff --git a/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap b/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap new file mode 100644 index 000000000..430f05a6f --- /dev/null +++ b/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap @@ -0,0 +1,94 @@ +--- +source: tests/manifest_load_options/wrapper_equivalence.rs +expression: document +--- +=== pure === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo pure" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "joined a-b", + "name": "pure", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== reads_env === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo env" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "profile characterization", + "name": "env", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": {} + } + ], + "vars": {} +} + +=== malformed_yaml === +observer: none accepted by this entry point +Manifest parse failed.: YAML parse error at line ⁨4⁩, column ⁨11⁩: ⁨unclosed bracket '{' at line 4, column 11⁩: unclosed bracket '{' at line 4, column 11 + +=== boolean_interpolation === +observer: none accepted by this entry point +{ + "actions": [], + "defaults": [], + "macros": [], + "netsuke_version": "1.0.0", + "rules": [], + "targets": [ + { + "Command": { + "command": "echo true false" + }, + "always": false, + "conditional": false, + "dependency_order": "parallel", + "deps": null, + "description": "literals: true/false", + "name": "booleans", + "order_only_deps": null, + "phony": false, + "sources": null, + "vars": { + "disabled": "false", + "enabled": "true" + } + } + ], + "vars": {} +} diff --git a/tools/kani/proof-scope.toml b/tools/kani/proof-scope.toml index 830f0751a..8e8045169 100644 --- a/tools/kani/proof-scope.toml +++ b/tools/kani/proof-scope.toml @@ -106,8 +106,10 @@ sources = [ "src/manifest/jinja_macros/expression.rs", "src/manifest/jinja_macros/invocation.rs", "src/manifest/jinja_macros/mod.rs", + "src/manifest/jinja_macros/query_classification.rs", "src/manifest/jinja_macros/telemetry.rs", "src/manifest/load_stage.rs", + "src/manifest/loader_options.rs", "src/manifest/loading.rs", "src/manifest/mod.rs", "src/manifest/parse_with_config.rs", @@ -115,6 +117,9 @@ sources = [ "src/manifest/query.rs", "src/manifest/registration.rs", "src/manifest/render/mod.rs", + "src/manifest/str_loaders.rs", + "src/manifest/test_surface/mod.rs", + "src/manifest/test_surface/overlays.rs", "src/manifest/workspace.rs", "src/ninja_gen/", "src/output/mod.rs", @@ -164,7 +169,9 @@ sources = [ "src/stdlib/recipe_text/dialect_telemetry.rs", "src/stdlib/recipe_text/mod.rs", "src/stdlib/register.rs", - "src/stdlib/register/query_helpers.rs", + "src/stdlib/restricted_helpers/mod.rs", + "src/stdlib/restricted_helpers/query.rs", + "src/stdlib/restricted_helpers/test_mode.rs", "src/stdlib/time/clock/mod.rs", "src/stdlib/time/format.rs", "src/stdlib/time/mod.rs",